Commit a68041f7 by Quaternijkon Committed by GitHub

feat(dashboard): add traffic flow sankey chart (#5465)

* 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.

* feat: build flow chart from quota data

---------

Co-authored-by: CaIon <i@caion.me>
parent f9e508bd
......@@ -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)
......@@ -66,3 +84,48 @@ func GetUserQuotaDates(c *gin.Context) {
})
return
}
func GetAllFlowQuotaDates(c *gin.Context) {
startTimestamp, endTimestamp, ok := parseFlowQuotaTimeRange(c)
if !ok {
return
}
username := c.Query("username")
dates, err := model.GetFlowQuotaData(startTimestamp, endTimestamp, username, 0, c.GetInt("role"))
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, endTimestamp, ok := parseFlowQuotaTimeRange(c)
if !ok {
return
}
if endTimestamp-startTimestamp > 2592000 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "时间跨度不能超过 1 个月",
})
return
}
dates, err := model.GetFlowQuotaData(startTimestamp, endTimestamp, "", userId, common.RoleCommonUser)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": dates,
})
return
}
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.Token{}, &model.QuotaData{}))
require.NoError(t, model.DB.Create(&model.Channel{Id: 1, Name: "east"}).Error)
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,
Count: 2,
Quota: 100,
TokenUsed: 40,
}).Error)
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,
Count: 1,
Quota: 70,
TokenUsed: 30,
}).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 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)
payload := decodeFlowQuotaResponse(t, recorder)
require.Len(t, payload.Data, 1)
require.Equal(t, "bob", payload.Data[0].Username)
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) {
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.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)
}
......@@ -298,6 +298,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
......@@ -309,7 +310,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,
......@@ -338,7 +339,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,
})
})
}
}
......@@ -366,10 +378,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,
......@@ -384,6 +397,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) {
......
......@@ -40,6 +40,7 @@ func TestMain(m *testing.M) {
&Token{},
&Log{},
&Channel{},
&QuotaData{},
&Ability{},
&TopUp{},
&SubscriptionPlan{},
......@@ -62,6 +63,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")
......
......@@ -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,12 +124,14 @@ 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),
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(&quotaDatas).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(&quotaDatas).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(&quotaDatas).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(&quotaDatas).Error
return quotaDatas, err
}
......
package model
import (
"fmt"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
type FlowQuotaData struct {
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, 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)
}
}
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("username = ?", username)
}
err := query.
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)
}
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 := fillFlowTokenNames(rows); err != nil {
return rows, err
}
return rows, fillFlowChannelNames(rows)
}
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 len(tokenIDs) == 0 {
return nil
}
var tokens []struct {
Id int `gorm:"column:id"`
Name string `gorm:"column:name"`
}
if err := DB.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
}
// Deleted tokens are intentionally not resolved here: leave TokenName empty
// so the frontend can render a localized "deleted (id)" label instead.
for _, row := range rows {
if name := tokenNameByID[row.TokenID]; name != "" {
row.TokenName = name
}
}
return nil
}
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
}
channelNameByID := make(map[int]string, len(channelIDs))
if common.MemoryCacheEnabled {
for _, channelID := range channelIDs {
if channel, err := CacheGetChannel(channelID); err == nil {
channelNameByID[channelID] = channel.Name
}
}
} else {
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
}
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
}
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/require"
)
func seedFlowQuotaData(t *testing.T, quotaData QuotaData) {
t.Helper()
require.NoError(t, DB.Create(&quotaData).Error)
}
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)
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 TestGetFlowQuotaDataUsesQuotaDataRoleSpecificDimensions(t *testing.T) {
truncateTables(t)
seedFlowLookupData(t)
seedFlowQuotaData(t, QuotaData{
UserID: 1,
Username: "alice",
NodeName: "node-a",
TokenID: 11,
UseGroup: "vip",
ModelName: "gpt-a",
ChannelID: 1,
CreatedAt: 1000,
Count: 2,
Quota: 100,
TokenUsed: 40,
})
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,
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,
})
rootRows, err := GetFlowQuotaData(900, 2000, "", 0, common.RoleRootUser)
require.NoError(t, err)
require.Len(t, rootRows, 3)
// Token 11 was soft-deleted, so its name is intentionally left empty for the
// frontend to render a localized "deleted (id)" label instead.
require.Equal(t, FlowQuotaData{
UserID: 1,
Username: "alice",
NodeName: "node-a",
TokenID: 11,
TokenName: "",
UseGroup: "vip",
ChannelID: 1,
ChannelName: "east",
ModelName: "gpt-a",
TokenUsed: 60,
Count: 3,
Quota: 150,
}, *rootRows[0])
// A token that still exists resolves to its current name.
require.Equal(t, 22, rootRows[1].TokenID)
require.Equal(t, "backup", rootRows[1].TokenName)
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.Empty(t, selfRows[0].Username)
require.Equal(t, 0, selfRows[0].ChannelID)
require.Empty(t, selfRows[0].ChannelName)
require.Empty(t, selfRows[0].TokenName)
require.Equal(t, "vip", selfRows[0].UseGroup)
require.Equal(t, 175, selfRows[0].Quota)
}
func TestLogQuotaDataSplitsRowsByUseGroupTokenChannelAndNode(t *testing.T) {
truncateTables(t)
CacheQuotaDataLock.Lock()
CacheQuotaData = make(map[string]*QuotaData)
CacheQuotaDataLock.Unlock()
LogQuotaData(QuotaDataLogParams{
UserID: 1,
Username: "alice",
ModelName: "gpt-a",
CreatedAt: 3661,
UseGroup: "vip",
TokenID: 11,
ChannelID: 1,
NodeName: "node-a",
Quota: 100,
TokenUsed: 40,
})
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,
})
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)
}
......@@ -316,6 +316,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())
{
......
......@@ -67,6 +67,11 @@ interface MultiSelectProps {
*/
maxVisibleChips?: number
/**
* Replaces individual chips with a compact summary while preserving the
* normal dropdown/search behaviour.
*/
renderSelectedSummary?: (values: string[]) => React.ReactNode
/**
* When true, clicking a chip's label copies its value to the clipboard
* instead of being inert. The remove (×) button keeps its own behaviour.
*/
......@@ -258,6 +263,14 @@ export function MultiSelect(props: MultiSelectProps) {
>
<ComboboxValue>
{(values: string[]) => {
if (props.renderSelectedSummary) {
return (
<span className='bg-muted text-muted-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center rounded-sm px-1.5 font-mono text-xs font-medium whitespace-nowrap'>
{props.renderSelectedSummary(values)}
</span>
)
}
const shouldLimit =
typeof props.maxVisibleChips === 'number' && !expanded
const visibleValues = shouldLimit
......@@ -327,7 +340,11 @@ export function MultiSelect(props: MultiSelectProps) {
</ComboboxValue>
<ComboboxChipsInput
id={props.id}
placeholder={props.selected.length === 0 ? placeholder : undefined}
placeholder={
props.selected.length === 0 && !props.renderSelectedSummary
? placeholder
: undefined
}
onKeyDown={handleKeyDown}
aria-label={placeholder}
/>
......
......@@ -17,7 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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[] }>(
......
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Fragment, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { VChart } from '@visactor/react-vchart'
import {
Activity,
ChevronRight,
CircleAlert,
EyeOff,
GitBranch,
Hash,
Info,
Loader2,
Route,
WalletCards,
} from 'lucide-react'
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 {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Toggle } from '@/components/ui/toggle'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { MultiSelect } from '@/components/multi-select'
import { getFlowQuotaDates } from '@/features/dashboard/api'
import {
buildDashboardFlowData,
buildFlowSankeySpec,
buildQueryParams,
getDefaultDays,
getFlowStages,
} from '@/features/dashboard/lib'
import {
compactFlowSelectionLabel,
flowDisplayState,
requireSuccessfulFlowRows,
} from '@/features/dashboard/lib/flow-selection'
import type {
DashboardFilters,
FlowMetric,
FlowNodeKind,
FlowRole,
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
// A Sankey needs at least two columns to render any link.
const MIN_VISIBLE_STAGES = 2
const FLOW_STAGE_META: Record<
FlowNodeKind,
{ labelKey: string; descKey: string }
> = {
user: {
labelKey: 'User',
descKey: 'The user who made the requests',
},
node: {
labelKey: 'Node',
descKey: 'The deployment node that handled the requests',
},
token: {
labelKey: 'Token',
descKey: 'The API key used for the requests',
},
group: {
labelKey: 'Group',
descKey: 'The user group applied to the requests',
},
model: {
labelKey: 'Model',
descKey: 'The model that was requested',
},
channel: {
labelKey: 'Channel',
descKey: 'The upstream channel that served the requests',
},
}
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 (
<div className='overflow-hidden rounded-lg border'>
<div className='divide-border/60 grid grid-cols-3 divide-x'>
{items.map((item) => {
const Icon = item.icon
return (
<div key={item.key} className='px-3 py-2.5 sm:px-5 sm:py-4'>
<div className='flex items-center gap-2'>
<Icon className='text-muted-foreground/60 size-3.5 shrink-0' />
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
{t(item.title)}
</div>
</div>
{props.loading ? (
<div className='mt-2 flex flex-col gap-1.5'>
<Skeleton className='h-7 w-20' />
<Skeleton className='h-3.5 w-28' />
</div>
) : (
<div className='text-foreground mt-1.5 font-mono text-lg font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl'>
{item.value}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
export function FlowCharts(props: FlowChartsProps) {
const { t } = useTranslation()
const { resolvedTheme, themeReady } = useChartTheme()
const user = useAuthStore((state) => state.auth.user)
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<FlowMetric>('quota')
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
const [hiddenStages, setHiddenStages] = useState<FlowNodeKind[]>([])
const stages = useMemo(() => getFlowStages(flowRole), [flowRole])
const visibleStages = useMemo(
() => stages.filter((stage) => !hiddenStages.includes(stage)),
[stages, hiddenStages]
)
const toggleStage = (stage: FlowNodeKind) => {
setHiddenStages((prev) => {
const hidden = new Set(prev)
if (hidden.has(stage)) {
hidden.delete(stage)
} else {
const remaining = stages.filter((item) => !hidden.has(item)).length
if (remaining <= MIN_VISIBLE_STAGES) return prev
hidden.add(stage)
}
return stages.filter((item) => hidden.has(item))
})
}
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, flowRole],
queryFn: () => getFlowQuotaDates(flowQueryParams, isAdmin),
select: (res) =>
requireSuccessfulFlowRows(res, t('Please try again later.')),
staleTime: 60_000,
})
const flowData = useMemo(
() =>
buildDashboardFlowData(isLoading ? [] : (flowRows ?? []), metric, {
role: flowRole,
selectedUsers,
visibleStages,
deletedTokenLabel: (tokenId) => t('Deleted ({{id}})', { id: tokenId }),
}),
[flowRole, flowRows, isLoading, metric, selectedUsers, visibleStages, t]
)
const userFilterOptions = useMemo(
() =>
flowData.filterOptions.users.map((user) => ({
label: `${user.label} · ${user.valueLabel}`,
value: user.value,
})),
[flowData.filterOptions.users]
)
const chartTitle = t('Flow')
const flowSpec = useMemo(
() =>
buildFlowSankeySpec(flowData.flow, chartTitle, formatQuota, {
quota: t('Quota'),
tokens: t('Tokens'),
requests: t('Requests'),
share: t('Share'),
}),
[chartTitle, flowData.flow, t]
)
const chartTheme = resolvedTheme === 'dark' ? 'dark' : 'light'
const chartKey = [
metric,
flowRole,
selectedUsers.join(','),
visibleStages.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.')
return (
<div className='flex flex-col gap-3'>
<FlowStats summary={flowData.summary} loading={isLoading} />
<div className='flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between'>
<div className='flex flex-wrap items-center gap-2'>
<Tabs
value={metric}
onValueChange={(value) => setMetric(value as FlowMetric)}
className='shrink-0'
>
<TabsList>
{FLOW_METRIC_OPTIONS.map((option) => (
<TabsTrigger
key={option.value}
value={option.value}
className='px-2.5 text-xs'
>
{t(option.labelKey)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
{isAdmin && (
<div className='flex min-w-0 flex-col gap-2 sm:flex-row lg:w-[min(24rem,34vw)]'>
<MultiSelect
options={userFilterOptions}
selected={selectedUsers}
onChange={setSelectedUsers}
placeholder={t('All users')}
emptyText={t('No users')}
maxVisibleChips={2}
renderSelectedSummary={(values) =>
compactFlowSelectionLabel(values.length)
}
/>
</div>
)}
{isLoading && (
<Loader2 className='text-muted-foreground size-4 animate-spin' />
)}
</div>
<div className='overflow-hidden rounded-lg border'>
<div className='flex w-full flex-col gap-2 border-b px-3 py-2 sm:px-5 sm:py-3 lg:flex-row lg:items-center lg:justify-between'>
<div className='flex min-w-0 items-center gap-2'>
<GitBranch className='text-muted-foreground/60 size-4 shrink-0' />
<div className='text-sm font-semibold'>{chartTitle}</div>
</div>
<TooltipProvider>
<div className='flex min-w-0 items-center gap-1 overflow-x-auto pb-1 lg:justify-end lg:pb-0'>
<Tooltip>
<TooltipTrigger
render={
<button
type='button'
className='text-muted-foreground/60 hover:text-foreground flex size-6 shrink-0 items-center justify-center rounded-md'
aria-label={t('Show or hide flow columns')}
/>
}
>
<Info className='size-3.5' />
</TooltipTrigger>
<TooltipContent className='max-w-[16rem]'>
{t('Click a stage to show or hide that column')}
</TooltipContent>
</Tooltip>
{stages.map((stage, index) => {
const meta = FLOW_STAGE_META[stage]
const visible = !hiddenStages.includes(stage)
return (
<Fragment key={stage}>
{index > 0 && (
<ChevronRight className='text-muted-foreground/40 size-3.5 shrink-0' />
)}
<Tooltip>
<TooltipTrigger
render={
<Toggle
variant='outline'
size='sm'
pressed={visible}
onPressedChange={() => toggleStage(stage)}
aria-label={t(meta.labelKey)}
className={cn('shrink-0', !visible && 'opacity-50')}
/>
}
>
{!visible && <EyeOff className='size-3' />}
{t(meta.labelKey)}
</TooltipTrigger>
<TooltipContent>{t(meta.descKey)}</TooltipContent>
</Tooltip>
</Fragment>
)
})}
</div>
</TooltipProvider>
</div>
<div className='h-[560px] p-1.5 sm:h-[680px] sm:p-2 2xl:h-[760px]'>
{displayState === 'loading' ? (
<Skeleton className='h-full w-full' />
) : displayState === 'error' ? (
<div className='flex h-full items-center justify-center p-4'>
<Alert variant='destructive' className='max-w-md'>
<CircleAlert />
<AlertTitle>{t('Failed to load')}</AlertTitle>
<AlertDescription>{flowErrorMessage}</AlertDescription>
</Alert>
</div>
) : displayState === 'empty' ? (
<Empty className='h-full border-0 py-12'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Route />
</EmptyMedia>
<EmptyTitle>{t('No flow data available')}</EmptyTitle>
<EmptyDescription>{t('No data available')}</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<VChart
key={`flow-${chartKey}`}
spec={{
...flowSpec,
theme: chartTheme,
background: 'transparent',
}}
option={VCHART_OPTION}
/>
)}
</div>
</div>
</div>
)
}
......@@ -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')}
</Button>
}
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'
......
......@@ -77,6 +77,12 @@ const LazyUserCharts = lazy(() =>
}))
)
const LazyFlowCharts = lazy(() =>
import('./components/flow/flow-charts').then((m) => ({
default: m.FlowCharts,
}))
)
function LogStatCardsFallback() {
return (
<div className='overflow-hidden rounded-lg border'>
......@@ -137,6 +143,9 @@ const SECTION_META: Record<DashboardSectionId, { titleKey: string }> = {
models: {
titleKey: 'Model Call Analytics',
},
flow: {
titleKey: 'Flow',
},
users: {
titleKey: 'User Analytics',
},
......@@ -217,6 +226,17 @@ export function Dashboard() {
/>
</>
) : null
const flowActions =
activeSection === 'flow' ? (
<ModelsFilter
preferences={chartPreferences}
onFilterChange={handleFilterChange}
onReset={handleResetFilters}
titleKey='Flow Filters'
descriptionKey='Filter the traffic flow view by time range and user.'
/>
) : null
const sectionActions = modelActions ?? flowActions
return (
<SectionPageLayout>
......@@ -238,9 +258,9 @@ export function Dashboard() {
) : (
<div />
)}
{modelActions != null && (
{sectionActions != null && (
<div className='flex shrink-0 flex-wrap items-center gap-1.5 sm:gap-2'>
{modelActions}
{sectionActions}
</div>
)}
</div>
......@@ -298,6 +318,13 @@ export function Dashboard() {
</Suspense>
</FadeIn>
)}
{activeSection === 'flow' && (
<FadeIn>
<Suspense fallback={<ModelChartsFallback />}>
<LazyFlowCharts filters={modelFilters} />
</Suspense>
</FadeIn>
)}
</div>
</SectionPageLayout.Content>
</SectionPageLayout>
......
......@@ -38,13 +38,15 @@ type TooltipLineItem = {
shapeSize?: number
}
function getVChartDefaultColors(domainLength: number) {
export function getDashboardChartColors(domainLength: number): string[] {
const scheme =
vchartDefaultDataScheme.find(
(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 {
......@@ -259,7 +261,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(modelColorDomain.length)
const modelColorRange = getDashboardChartColors(modelColorDomain.length)
const otherColor = modelColorRange[modelColorDomain.indexOf(otherLabel)]
const otherTooltipColor =
typeof otherColor === 'string' ? otherColor : '#FF8A00'
......
import assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import type { FlowUserFilterOption } from '../types'
import {
compactFlowSelectionLabel,
flowDisplayState,
requireSuccessfulFlowRows,
visibleFlowUsers,
} from './flow-selection'
const users: FlowUserFilterOption[] = [
{
value: 'user:1',
label: 'dry',
valueLabel: '100',
valueRaw: 100,
color: '#1664ff',
},
{
value: 'user:2',
label: 'jrc',
valueLabel: '70',
valueRaw: 70,
color: '#1ac6ff',
},
]
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('filters visible users without mutating the source options', () => {
const visible = visibleFlowUsers(users, ['user:1'])
assert.deepEqual(
visible.map((user) => user.value),
['user:1']
)
assert.deepEqual(
users.map((user) => user.value),
['user:1', 'user:2']
)
})
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 }]
)
})
})
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type {
FlowQuotaDataItem,
FlowUserFilterOption,
} from '@/features/dashboard/types'
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))
}
import assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import type { FlowQuotaDataItem } from '../types'
import {
buildDashboardFlowData,
buildFlowFilterOptions,
buildFlowSankeySpec,
} from './flow'
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,
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,
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,
count: 3,
},
]
describe('dashboard flow data', () => {
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.links.map((link) => [link.source, link.target, link.value]),
[
['group:vip', 'model:gpt-4.1', 150],
['token:11', 'group:vip', 150],
]
)
assert.equal(
result.flow.nodes.some((node) => node.kind === 'channel'),
false
)
})
test('builds admin user-group-model-channel flow', () => {
const result = buildDashboardFlowData(rows, 'quota', {
role: 'admin',
})
assert.deepEqual(
result.flow.links.map((link) => [link.source, link.target, link.value]),
[
['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('builds root user-node-token-group-model-channel flow', () => {
const result = buildDashboardFlowData(rows, 'requests', {
role: 'root',
})
assert.deepEqual(
result.flow.links.map((link) => [link.source, link.target, link.value]),
[
['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', () => {
const result = buildDashboardFlowData(rows, 'quota', {
role: 'admin',
selectedUsers: ['user:2'],
})
assert.equal(result.summary.quota, 70)
assert.deepEqual(
result.flow.links.map((link) => [link.source, link.target, link.value]),
[
['group:default', 'model:claude-4-sonnet', 70],
['model:claude-4-sonnet', 'channel:101', 70],
['user:2', 'group:default', 70],
]
)
})
test('reconnects links when a middle stage is hidden', () => {
const result = buildDashboardFlowData(rows, 'quota', {
role: 'admin',
visibleStages: ['user', 'model', 'channel'],
})
assert.deepEqual(
result.flow.links.map((link) => [link.source, link.target, link.value]),
[
['model:claude-4-sonnet', 'channel:101', 70],
['model:gpt-4.1', 'channel:101', 100],
['model:gpt-4.1', 'channel:102', 50],
['user:1', 'model:gpt-4.1', 150],
['user:2', 'model:claude-4-sonnet', 70],
]
)
assert.equal(
result.flow.nodes.some((node) => node.kind === 'group'),
false
)
})
test('ignores stage filters that would leave fewer than two columns', () => {
const result = buildDashboardFlowData(rows.slice(0, 2), 'quota', {
role: 'user',
visibleStages: ['model'],
})
assert.deepEqual(
result.flow.links.map((link) => [link.source, link.target, link.value]),
[
['group:vip', 'model:gpt-4.1', 150],
['token:11', 'group:vip', 150],
]
)
})
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:1', 'alice', '150'],
['user:2', 'bob', '70'],
]
)
assert.notEqual(options.users[0].color, options.users[1].color)
})
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 aliceNode = values.nodes.find(
(node: Record<string, unknown>) => node.key === 'user:1'
)
const userNodeLink = values.links.find(
(link: Record<string, unknown>) =>
link.source === 'user:1' && link.target === 'node:node-a'
)
assert.equal(flowSpec.type, 'sankey')
assert.equal(flowSpec.title.text, 'Flow')
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(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<string, unknown>) =>
typeof row.visible === 'function'
? row.visible({ datum: userNodeLink })
: true
)
.map((row: Record<string, unknown>) => [
row.key,
typeof row.value === 'function'
? row.value({ datum: userNodeLink })
: row.value,
]),
[
['Quota', '100'],
['Tokens', '40'],
['Requests', '2'],
['Share', '100.0%'],
]
)
})
})
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type {
DashboardFlowGraph,
DashboardFlowLink,
DashboardFlowNode,
FlowBuildOptions,
FlowFilterOptions,
FlowMetric,
FlowNodeKind,
FlowQuotaDataItem,
FlowRole,
FlowSummary,
ProcessedFlowData,
} from '@/features/dashboard/types'
import { getDashboardChartColors } from './charts'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type VChartSpec = Record<string, any>
type FlowMetrics = {
quota: number
tokens: number
requests: number
}
type FlowSankeyLabels = {
quota: string
tokens: string
requests: string
share: string
}
type FlowPathNode = {
id: string
label: string
kind: FlowNodeKind
}
type FlowPathContext = {
deletedTokenLabel?: (tokenId: number) => string
}
const EMPTY_FLOW_PATH_CONTEXT: FlowPathContext = {}
const DEFAULT_FLOW_ROLE: FlowRole = 'user'
const DEFAULT_FLOW_SANKEY_LABELS: FlowSankeyLabels = {
quota: 'Quota',
tokens: 'Tokens',
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 rowMetrics(row: FlowQuotaDataItem): FlowMetrics {
return {
quota: numberValue(row.quota),
tokens: numberValue(row.token_used),
requests: numberValue(row.count),
}
}
function metricValue(metrics: FlowMetrics, metric: FlowMetric): number {
if (metric === 'requests') return metrics.requests
if (metric === 'tokens') return metrics.tokens
return metrics.quota
}
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 nodeNameNode(row: FlowQuotaDataItem): FlowPathNode {
const nodeName = row.node_name || 'default-node'
return {
id: `node:${nodeName}`,
label: nodeName,
kind: 'node',
}
}
function tokenNode(
row: FlowQuotaDataItem,
ctx: FlowPathContext
): FlowPathNode {
const tokenID = numberValue(row.token_id)
return {
id: tokenID > 0 ? `token:${tokenID}` : `token:${row.token_name || 'unknown'}`,
label: row.token_name || deletedTokenLabel(tokenID, ctx),
kind: 'token',
}
}
function deletedTokenLabel(tokenID: number, ctx: FlowPathContext): string {
if (tokenID <= 0) return 'Unknown Token'
return ctx.deletedTokenLabel?.(tokenID) ?? `token-${tokenID}`
}
function groupNode(row: FlowQuotaDataItem): FlowPathNode {
const useGroup = row.use_group || 'unknown'
return {
id: `group:${useGroup}`,
label: useGroup,
kind: 'group',
}
}
function modelNode(row: FlowQuotaDataItem): FlowPathNode {
const model = row.model_name || 'unknown'
return {
id: `model:${model}`,
label: row.model_name || 'Unknown Model',
kind: 'model',
}
}
function channelNode(row: FlowQuotaDataItem): FlowPathNode {
const channelID = numberValue(row.channel_id)
return {
id:
channelID > 0
? `channel:${channelID}`
: `channel:${row.channel_name || 'unknown'}`,
label:
row.channel_name || (channelID > 0 ? `channel-${channelID}` : 'Unknown'),
kind: 'channel',
}
}
const NODE_BUILDERS: Record<
FlowNodeKind,
(row: FlowQuotaDataItem, ctx: FlowPathContext) => FlowPathNode
> = {
user: userNode,
node: nodeNameNode,
token: tokenNode,
group: groupNode,
model: modelNode,
channel: channelNode,
}
const ROLE_FLOW_STAGES: Record<FlowRole, FlowNodeKind[]> = {
root: ['user', 'node', 'token', 'group', 'model', 'channel'],
admin: ['user', 'group', 'model', 'channel'],
user: ['token', 'group', 'model'],
}
// A Sankey needs at least two columns to draw any link, so hiding stages can
// never collapse the path below this many columns.
const MIN_FLOW_STAGES = 2
export function getFlowStages(role: FlowRole): FlowNodeKind[] {
return ROLE_FLOW_STAGES[role] ?? ROLE_FLOW_STAGES.user
}
function resolveVisibleStages(
role: FlowRole,
visibleStages?: FlowNodeKind[]
): FlowNodeKind[] {
const stages = getFlowStages(role)
if (!visibleStages) return stages
const visible = new Set(visibleStages)
const filtered = stages.filter((stage) => visible.has(stage))
return filtered.length >= MIN_FLOW_STAGES ? filtered : stages
}
function flowPath(
row: FlowQuotaDataItem,
role: FlowRole,
visibleStages?: FlowNodeKind[],
ctx: FlowPathContext = EMPTY_FLOW_PATH_CONTEXT
): FlowPathNode[] {
return resolveVisibleStages(role, visibleStages).map((stage) =>
NODE_BUILDERS[stage](row, ctx)
)
}
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<string, string> {
const map = new Map<string, string>()
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 rootColorKeys(
rows: FlowQuotaDataItem[],
role: FlowRole,
visibleStages?: FlowNodeKind[]
): string[] {
return Array.from(
new Set(
rows.map((row) => flowPath(row, role, visibleStages)[0]?.id ?? 'unknown')
)
).sort((a, b) => a.localeCompare(b))
}
function filterRows(
rows: FlowQuotaDataItem[],
options: FlowBuildOptions = {}
): FlowQuotaDataItem[] {
const selectedUsers = new Set(options.selectedUsers ?? [])
if (selectedUsers.size === 0) return rows
return rows.filter((row) => selectedUsers.has(userNode(row).id))
}
function addNode(
map: Map<string, DashboardFlowNode>,
pathNode: FlowPathNode,
metrics: FlowMetrics,
metric: FlowMetric,
color: string,
colorKey: string
): void {
const previous = map.get(pathNode.id) ?? {
id: pathNode.id,
label: pathNode.label,
kind: pathNode.kind,
value: 0,
requests: 0,
quota: 0,
tokens: 0,
color,
colorKey,
}
previous.value += metricValue(metrics, metric)
previous.requests += metrics.requests
previous.quota += metrics.quota
previous.tokens += metrics.tokens
map.set(pathNode.id, previous)
}
function addLink(
map: Map<string, DashboardFlowLink>,
source: FlowPathNode,
target: FlowPathNode,
metrics: FlowMetrics,
metric: FlowMetric,
color: string,
colorKey: string
): void {
const key = `${source.id}\u0000${target.id}`
const previous = map.get(key) ?? {
source: source.id,
target: target.id,
value: 0,
requests: 0,
quota: 0,
tokens: 0,
sourceLabel: source.label,
targetLabel: target.label,
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
map.set(key, previous)
}
function assignLinkDisplayColors(links: DashboardFlowLink[]): void {
const linksBySource = new Map<string, DashboardFlowLink[]>()
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<T extends { value: number; label: string }>(
a: T,
b: T
): number {
return b.value - a.value || a.label.localeCompare(b.label)
}
function linkStableKey(link: Pick<DashboardFlowLink, 'source' | 'target'>) {
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<FlowSummary>(
(summary, row) => {
const metrics = rowMetrics(row)
summary.quota += metrics.quota
summary.tokens += metrics.tokens
summary.requests += metrics.requests
return summary
},
{
quota: 0,
tokens: 0,
requests: 0,
}
)
}
function buildFlowGraph(
rows: FlowQuotaDataItem[],
metric: FlowMetric,
role: FlowRole,
palette?: readonly string[],
visibleStages?: FlowNodeKind[],
ctx: FlowPathContext = EMPTY_FLOW_PATH_CONTEXT
): DashboardFlowGraph {
const nodes = new Map<string, DashboardFlowNode>()
const links = new Map<string, DashboardFlowLink>()
const colors = stableColorMap(
rootColorKeys(rows, role, visibleStages),
palette
)
for (const row of rows) {
const path = flowPath(row, role, visibleStages, ctx)
const root = path[0]
if (!root) continue
const metrics = rowMetrics(row)
const color = colors.get(root.id) ?? colorAt(0, palette)
for (const node of path) {
addNode(nodes, node, metrics, metric, color, root.id)
}
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 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, visibleStages)[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 flowLinks) {
link.share = total > 0 ? link.value / total : 0
}
assignLinkDisplayColors(flowLinks)
return {
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',
palette?: readonly string[]
): FlowFilterOptions {
const users = new Map<
string,
{
label: string
value: number
color: string
}
>()
const colors = stableColorMap(
rows.map((row) => userNode(row).id).sort((a, b) => a.localeCompare(b)),
palette
)
for (const row of rows) {
const user = userNode(row)
if (!row.user_id && !row.username) continue
const metrics = rowMetrics(row)
const value = metricValue(metrics, metric)
const current = users.get(user.id) ?? {
label: user.label,
value: 0,
color: colors.get(user.id) ?? colorAt(0, palette),
}
current.value += value
users.set(user.id, current)
}
return {
users: Array.from(users.entries())
.map(([value, user]) => ({
value,
label: user.label,
valueLabel: formatNumber(user.value),
valueRaw: user.value,
color: user.color,
}))
.sort(
(a, b) => b.valueRaw - a.valueRaw || a.label.localeCompare(b.label)
),
}
}
export function buildDashboardFlowData(
rows: FlowQuotaDataItem[],
metric: FlowMetric = 'quota',
options: FlowBuildOptions = {}
): ProcessedFlowData {
const role = options.role ?? DEFAULT_FLOW_ROLE
const filteredRows = filterRows(rows, options)
const palette = options.colorPalette
return {
summary: buildSummary(filteredRows),
flow: buildFlowGraph(filteredRows, metric, role, palette, options.visibleStages, {
deletedTokenLabel: options.deletedTokenLabel,
}),
filterOptions: buildFlowFilterOptions(rows, metric, palette),
}
}
function recordValue(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object'
? (value as Record<string, unknown>)
: undefined
}
function sankeyDatumSource(
datum: Record<string, unknown>
): Record<string, unknown> {
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<string, unknown>,
key: string
): unknown {
if (datum[key] !== undefined) return datum[key]
return sankeyDatumSource(datum)[key]
}
function isSankeyLinkDatum(datum: Record<string, unknown>): boolean {
return (
sankeyDatumValue(datum, 'source') !== undefined &&
sankeyDatumValue(datum, 'target') !== undefined
)
}
function tooltipMetricLines(
valueFormatter: (value: number) => string,
labels: FlowSankeyLabels
) {
const metricValue = (datum: Record<string, unknown>, key: string) =>
numberValue(sankeyDatumValue(datum, key))
const formattedNumber = (datum: Record<string, unknown>, key: string) =>
formatNumber(metricValue(datum, key))
const hasMetric = (datum: Record<string, unknown>, key: string) =>
metricValue(datum, key) > 0
return [
{
key: labels.quota,
value: (datum: Record<string, unknown>) =>
valueFormatter(metricValue(datum, 'quota')),
},
{
key: labels.tokens,
value: (datum: Record<string, unknown>) =>
formattedNumber(datum, 'tokens'),
},
{
key: labels.requests,
value: (datum: Record<string, unknown>) =>
formattedNumber(datum, 'requests'),
},
{
key: labels.share,
value: (datum: Record<string, unknown>) =>
`${(metricValue(datum, 'share') * 100).toFixed(1)}%`,
visible: (datum: Record<string, unknown>) => 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,
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,
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, unknown>) =>
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, unknown>) =>
String(
sankeyDatumValue(datum, 'linkColor') ??
sankeyDatumValue(datum, 'color') ??
colorAt(0)
),
fillOpacity: (datum: Record<string, unknown>) =>
numberValue(sankeyDatumValue(datum, 'linkAlpha')) || 1,
cursor: 'pointer',
pickMode: 'accurate',
boundsMode: 'accurate',
zIndex: (datum: Record<string, unknown>) => {
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, unknown>) =>
String(
sankeyDatumValue(datum, 'hoverColor') ??
sankeyDatumValue(datum, 'color') ??
colorAt(0)
),
fillOpacity: 0.9,
},
selected: {
fill: (datum: Record<string, unknown>) =>
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<string, unknown>) =>
isSankeyLinkDatum(datum) ||
sankeyDatumValue(datum, 'key') !== undefined,
title: {
value: (datum: Record<string, unknown>) => {
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,
}
}
......@@ -33,5 +33,10 @@ export {
getDefaultPingStatus,
} from './api-info'
export { processChartData, processUserChartData } from './charts'
export {
buildDashboardFlowData,
buildFlowSankeySpec,
getFlowStages,
} from './flow'
export { safeDivide, calculateDashboardStats } from './stats'
export { getPreviewText } from './text'
......@@ -34,6 +34,11 @@ const DASHBOARD_SECTIONS = [
build: () => null,
},
{
id: 'flow',
titleKey: 'Flow',
build: () => null,
},
{
id: 'users',
titleKey: 'User Analytics',
adminOnly: true,
......
......@@ -33,6 +33,101 @@ export interface QuotaDataItem {
quota?: number
}
export interface FlowQuotaDataItem {
user_id?: number
username?: string
node_name?: string
use_group?: string
token_id?: number
token_name?: string
channel_id?: number
channel_name?: string
model_name?: string
token_used?: number
count?: number
quota?: number
}
export type FlowMetric = 'quota' | 'tokens' | 'requests'
export type FlowRole = 'user' | 'admin' | 'root'
export type FlowNodeKind =
| 'user'
| 'node'
| 'token'
| 'group'
| 'model'
| 'channel'
export interface FlowBuildOptions {
role?: FlowRole
selectedUsers?: string[]
colorPalette?: readonly string[]
visibleStages?: FlowNodeKind[]
// Resolves the label for a token whose record no longer exists (deleted).
// Lets the caller inject a localized string such as "Deleted (123)".
deletedTokenLabel?: (tokenId: number) => string
}
export interface DashboardFlowNode {
id: string
label: string
kind: FlowNodeKind
value: number
requests: number
quota: number
tokens: number
color: string
colorKey: string
}
export interface DashboardFlowLink {
source: string
target: string
value: number
requests: number
quota: number
tokens: 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 FlowUserFilterOption {
value: string
label: string
valueLabel: string
valueRaw: number
color: string
}
export interface FlowFilterOptions {
users: FlowUserFilterOption[]
}
export interface FlowSummary {
quota: number
tokens: number
requests: number
}
export interface ProcessedFlowData {
summary: FlowSummary
flow: DashboardFlowGraph
filterOptions: FlowFilterOptions
}
// ============================================================================
// Uptime Monitoring Types
// ============================================================================
......
......@@ -17,13 +17,13 @@
"file": "ja.json",
"missingCount": 0,
"extrasCount": 0,
"untranslatedCount": 1
"untranslatedCount": 0
},
"ru": {
"file": "ru.json",
"missingCount": 0,
"extrasCount": 0,
"untranslatedCount": 1
"untranslatedCount": 0
},
"vi": {
"file": "vi.json",
......
......@@ -262,6 +262,7 @@
"Ali": "Alibaba Bailian",
"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.",
......@@ -277,6 +278,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",
......@@ -684,13 +686,13 @@
"Channel Affinity": "Channel Affinity",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.",
"Channel Affinity: Upstream Cache Hit": "Channel Affinity: Upstream Cache Hit",
"Channel health checks": "Channel health checks",
"Channel copied successfully": "Channel copied successfully",
"Channel created successfully": "Channel created successfully",
"Channel deleted successfully": "Channel deleted successfully",
"Channel disabled successfully": "Channel disabled successfully",
"Channel enabled successfully": "Channel enabled successfully",
"Channel Extra Settings": "Channel Extra Settings",
"Channel health checks": "Channel health checks",
"Channel ID": "Channel ID",
"Channel ID is required": "Channel ID is required",
"Channel key": "Channel key",
......@@ -786,6 +788,7 @@
"Cleared log files": "Cleared log files",
"Click \"Create Plan\" to create your first subscription plan": "Click \"Create Plan\" to create your first subscription plan",
"Click \"Generate\" to create a token": "Click \"Generate\" to create a token",
"Click a stage to show or hide that column": "Click a stage to show or hide that column",
"Click any category to drill into its models, apps, and trends": "Click any category to drill into its models, apps, and trends",
"Click for details": "Click for details",
"Click save when you're done.": "Click save when you're done.",
......@@ -1216,6 +1219,7 @@
"Delete selected channels": "Delete selected channels",
"Delete selected models": "Delete selected models",
"Deleted": "Deleted",
"Deleted ({{id}})": "Deleted ({{id}})",
"Deleted {{count}} failed models": "Deleted {{count}} failed models",
"Deleted a custom OAuth provider": "Deleted a custom OAuth provider",
"Deleted a deployment": "Deleted a deployment",
......@@ -1845,6 +1849,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",
......@@ -1861,6 +1866,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.",
......@@ -2393,10 +2400,10 @@
"Max Disk Cache Size (MB)": "Max Disk Cache Size (MB)",
"Max Entries": "Max Entries",
"Max output": "Max output",
"Max Retries": "Max Retries",
"Max Requests (incl. failures)": "Max Requests (incl. failures)",
"Max Requests (including failures)": "Max Requests (including failures)",
"Max requests per period": "Max requests per period",
"Max Retries": "Max Retries",
"Max Success": "Max Success",
"Max successful requests": "Max successful requests",
"Max Successful Requests": "Max Successful Requests",
......@@ -2420,8 +2427,6 @@
"Merchant ID is required": "Merchant ID is required",
"Message Priority": "Message Priority",
"Metadata": "Metadata",
"MjProxy": "MjProxy",
"MjProxyPlus": "MjProxyPlus",
"min downtime": "min downtime",
"Min Top-up": "Min Top-up",
"Min Top-up:": "Min Top-up:",
......@@ -2446,6 +2451,8 @@
"Missing Models": "Missing Models",
"Missing user data from Passkey login response": "Missing user data from Passkey login response",
"Mistral": "Mistral",
"MjProxy": "MjProxy",
"MjProxyPlus": "MjProxyPlus",
"Modalities": "Modalities",
"Modality": "Modality",
"Mode": "Mode",
......@@ -2636,6 +2643,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 Auth": "No Auth",
......@@ -2675,6 +2683,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",
......@@ -2694,6 +2703,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.",
......@@ -2768,10 +2778,12 @@
"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",
"No X Found": "No X Found",
"Node": "Node",
"Node Name": "Node Name",
"Non-stream": "Non-stream",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.",
......@@ -3481,11 +3493,13 @@
"Request Model": "Request Model",
"Request Model:": "Request Model:",
"Request overrides, routing behavior, and upstream model automation": "Request overrides, routing behavior, and upstream model automation",
"Request retry": "Request retry",
"Request rule pricing": "Request rule pricing",
"Request success rate sampled over the last 24 hours": "Request success rate sampled over the last 24 hours",
"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",
......@@ -3544,7 +3558,6 @@
"Restore defaults": "Restore defaults",
"Restrict user model request frequency (may impact high concurrency performance)": "Restrict user model request frequency (may impact high concurrency performance)",
"Result": "Result",
"Request retry": "Request retry",
"Retain last N days": "Retain last N days",
"Retain last N files": "Retain last N files",
"Retention days": "Retention days",
......@@ -3838,6 +3851,7 @@
"Show All": "Show All",
"Show all providers including unbound": "Show all providers including unbound",
"Show only bound providers": "Show only bound providers",
"Show or hide flow columns": "Show or hide flow columns",
"Show prices in currency instead of quota.": "Show prices in currency instead of quota.",
"Show setup guide": "Show setup guide",
"Show token usage statistics in the UI": "Show token usage statistics in the UI",
......@@ -4106,9 +4120,11 @@
"The administrator has not configured a privacy policy yet.": "The administrator has not configured a privacy policy yet.",
"The administrator has not configured a user agreement yet.": "The administrator has not configured a user agreement yet.",
"The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.": "The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.",
"The API key used for the requests": "The API key used for the requests",
"The binding will complete automatically after authorization": "The binding will complete automatically after authorization",
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.",
"The deployment node that handled the requests": "The deployment node that handled the requests",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "The effective domain for Passkey registration. Must match the current domain or be its parent domain.",
"The entered text does not match the required text.": "The entered text does not match the required text.",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.",
......@@ -4116,6 +4132,7 @@
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:",
"The mapped upstream model(s)": "The mapped upstream model(s)",
"The model that was requested": "The model that was requested",
"The model you're looking for doesn't exist.": "The model you're looking for doesn't exist.",
"The name displayed across the application": "The name displayed across the application",
"The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations",
......@@ -4128,7 +4145,10 @@
"The token group that will have a custom ratio": "The token group that will have a custom ratio",
"The unique identifier for this model": "The unique identifier for this model",
"The unique name for this vendor": "The unique name for this vendor",
"The upstream channel that served the requests": "The upstream channel that served the requests",
"The URL for this chat client.": "The URL for this chat client.",
"The user group applied to the requests": "The user group applied to the requests",
"The user who made the requests": "The user who made the requests",
"Theme": "Theme",
"Theme preset": "Theme preset",
"Theme Settings": "Theme Settings",
......
......@@ -262,6 +262,7 @@
"Ali": "Alibaba Bailian",
"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.",
......@@ -277,6 +278,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",
......@@ -684,13 +686,13 @@
"Channel Affinity": "Affinité de canal",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "L'affinité de canal réutilise le dernier canal ayant réussi, en se basant sur les clés extraites du contexte de la requête ou du corps JSON.",
"Channel Affinity: Upstream Cache Hit": "Affinité de canal : hit de cache en amont",
"Channel health checks": "Contrôles de santé des canaux",
"Channel copied successfully": "Canal copié avec succès",
"Channel created successfully": "Canal créé avec succès",
"Channel deleted successfully": "Canal supprimé avec succès",
"Channel disabled successfully": "Canal désactivé avec succès",
"Channel enabled successfully": "Canal activé avec succès",
"Channel Extra Settings": "Paramètres supplémentaires du canal",
"Channel health checks": "Contrôles de santé des canaux",
"Channel ID": "ID du Canal",
"Channel ID is required": "L'ID du canal est requis",
"Channel key": "Clé du canal",
......@@ -786,6 +788,7 @@
"Cleared log files": "Fichiers journaux effacés",
"Click \"Create Plan\" to create your first subscription plan": "Cliquez sur « Créer un plan » pour créer votre premier abonnement",
"Click \"Generate\" to create a token": "Cliquez sur \"Générer\" pour créer un jeton",
"Click a stage to show or hide that column": "Cliquez sur une étape pour afficher ou masquer cette colonne",
"Click any category to drill into its models, apps, and trends": "Cliquez sur une catégorie pour explorer ses modèles, applications et tendances",
"Click for details": "Cliquez pour les détails",
"Click save when you're done.": "Cliquez sur Enregistrer lorsque vous avez terminé.",
......@@ -1216,6 +1219,7 @@
"Delete selected channels": "Supprimer les canaux sélectionnés",
"Delete selected models": "Supprimer les modèles sélectionnés",
"Deleted": "Supprimé",
"Deleted ({{id}})": "Supprimé ({{id}})",
"Deleted {{count}} failed models": "{{count}} modèles en échec supprimés",
"Deleted a custom OAuth provider": "Fournisseur OAuth personnalisé supprimé",
"Deleted a deployment": "Déploiement supprimé",
......@@ -1845,6 +1849,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",
......@@ -1861,6 +1866,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.",
......@@ -2393,10 +2400,10 @@
"Max Disk Cache Size (MB)": "Taille max du cache disque (Mo)",
"Max Entries": "Entrées max",
"Max output": "Sortie max",
"Max Retries": "Relances max",
"Max Requests (incl. failures)": "Max Requêtes (incl. échecs)",
"Max Requests (including failures)": "Max Requêtes (incluant les échecs)",
"Max requests per period": "Nombre max de requêtes par période",
"Max Retries": "Relances max",
"Max Success": "Max Succès",
"Max successful requests": "Nombre max de requêtes réussies",
"Max Successful Requests": "Max Requêtes réussies",
......@@ -2420,8 +2427,6 @@
"Merchant ID is required": "L'ID marchand est requis",
"Message Priority": "Priorité du message",
"Metadata": "Métadonnées",
"MjProxy": "MjProxy",
"MjProxyPlus": "MjProxyPlus",
"min downtime": "min d'interruption",
"Min Top-up": "Recharge min.",
"Min Top-up:": "Recharge min. :",
......@@ -2446,6 +2451,8 @@
"Missing Models": "Modèles manquants",
"Missing user data from Passkey login response": "Données utilisateur manquantes de la réponse de connexion Passkey",
"Mistral": "Mistral",
"MjProxy": "MjProxy",
"MjProxyPlus": "MjProxyPlus",
"Modalities": "Modalités",
"Modality": "Modalité",
"Mode": "Mode",
......@@ -2636,6 +2643,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 Auth": "Sans auth",
......@@ -2675,6 +2683,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",
......@@ -2694,6 +2703,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é.",
......@@ -2768,10 +2778,12 @@
"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",
"No X Found": "Aucun X trouvé",
"Node": "Nœud",
"Node Name": "Nom du nœud",
"Non-stream": "Non-streaming",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Les récompenses d’invitation non nulles nécessitent une confirmation de conformité dans les paramètres de la passerelle de paiement.",
......@@ -3481,11 +3493,13 @@
"Request Model": "Modèle demandé",
"Request Model:": "Modèle demandé :",
"Request overrides, routing behavior, and upstream model automation": "Surcharges de requête, comportement de routage et automatisation des modèles amont",
"Request retry": "Relance des requêtes",
"Request rule pricing": "Règles de tarification de requête",
"Request success rate sampled over the last 24 hours": "Taux de réussite des requêtes échantillonné sur les dernières 24 heures",
"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",
......@@ -3544,7 +3558,6 @@
"Restore defaults": "Restaurer les paramètres par défaut",
"Restrict user model request frequency (may impact high concurrency performance)": "Restreindre la fréquence des requêtes du modèle utilisateur (peut impacter les performances en cas de forte concurrence)",
"Result": "Résultat",
"Request retry": "Relance des requêtes",
"Retain last N days": "Conserver les N derniers jours",
"Retain last N files": "Conserver les N derniers fichiers",
"Retention days": "Jours de rétention",
......@@ -3838,6 +3851,7 @@
"Show All": "Tout afficher",
"Show all providers including unbound": "Afficher tous les fournisseurs (y compris non liés)",
"Show only bound providers": "Afficher uniquement les fournisseurs liés",
"Show or hide flow columns": "Afficher ou masquer les colonnes du flux",
"Show prices in currency instead of quota.": "Afficher les prix en devise au lieu du quota.",
"Show setup guide": "Afficher le guide de configuration",
"Show token usage statistics in the UI": "Afficher les statistiques d'utilisation des jetons dans l'interface utilisateur",
......@@ -4106,9 +4120,11 @@
"The administrator has not configured a privacy policy yet.": "L'administrateur n'a pas encore configuré de politique de confidentialité.",
"The administrator has not configured a user agreement yet.": "L'administrateur n'a pas encore configuré d'accord utilisateur.",
"The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.": "L'administrateur n'a pas encore configuré de contenu 'À propos'. Vous pouvez le définir dans la page des paramètres, en supportant le HTML ou l'URL.",
"The API key used for the requests": "La clé API utilisée pour les requêtes",
"The binding will complete automatically after authorization": "La liaison se terminera automatiquement après l'autorisation",
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "Le produit associé alimente les recharges de portefeuille : lorsqu’un utilisateur saisit un montant, new-api lance le paiement sur ce produit Pancake unique et remplace le prix pour la session, sans devoir précréer des SKU de 1 $, 5 $ ou 10 $.",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "La boutique associée est le conteneur parent de tous les produits Pancake que new-api crée depuis cette administration, y compris le produit de recharge de portefeuille et les produits de forfaits d’abonnement. Une seule boutique suffit ; choisissez-en une autre uniquement si vous gérez réellement des catalogues Pancake séparés.",
"The deployment node that handled the requests": "Le nœud de déploiement ayant traité les requêtes",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Le domaine effectif pour l'enregistrement de la clé d'accès. Doit correspondre au domaine actuel ou être son domaine parent.",
"The entered text does not match the required text.": "Le texte saisi ne correspond pas au texte requis.",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "L’environnement (test ou production) est déterminé par la clé collée ici : utilisez la clé de test pendant l’intégration, puis remplacez-la par la clé de production lors de la mise en ligne.",
......@@ -4116,6 +4132,7 @@
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "Les modèles suivants présentent des conflits de type de facturation (prix fixe vs facturation au ratio). Confirmez pour procéder aux changements.",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "Les modèles suivants dans la redirection du modèle n'ont pas été ajoutés à la liste \"Modèles\" et peuvent échouer lors de l'invocation en raison de modèles disponibles manquants :",
"The mapped upstream model(s)": "Le(s) modèle(s) amont mappé(s)",
"The model that was requested": "Le modèle qui a été demandé",
"The model you're looking for doesn't exist.": "Le modèle que vous recherchez n'existe pas.",
"The name displayed across the application": "Le nom affiché dans l'application",
"The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "L'URL publique de votre serveur, utilisée pour les rappels OAuth, les webhooks et autres intégrations externes",
......@@ -4128,7 +4145,10 @@
"The token group that will have a custom ratio": "Le groupe de jetons qui aura un ratio personnalisé",
"The unique identifier for this model": "L'identifiant unique de ce modèle",
"The unique name for this vendor": "Le nom unique de ce fournisseur",
"The upstream channel that served the requests": "Le canal en amont ayant servi les requêtes",
"The URL for this chat client.": "L'URL de ce client de discussion.",
"The user group applied to the requests": "Le groupe d'utilisateurs appliqué aux requêtes",
"The user who made the requests": "L'utilisateur à l'origine des requêtes",
"Theme": "Thème",
"Theme preset": "Préréglage du thème",
"Theme Settings": "Paramètres du thème",
......
......@@ -259,9 +259,10 @@
"AIGC2D": "AIGC2D",
"AILS": "AILS",
"AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SKモード: AccessKey | SecretAccessKey | Regionを使用",
"Ali": "Alibaba Bailian",
"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.": "すべての編集は上書き操作です。現在の値を変更しないままにするには、フィールドを空のままにしてください。",
......@@ -277,6 +278,7 @@
"All Tags": "すべてのタグ",
"All Types": "すべてのタイプ",
"All upstream data is trusted": "すべてのアップストリームデータは信頼されています",
"All users": "すべてのユーザー",
"All Vendors": "すべてのベンダー",
"All-time": "全期間",
"Allocated Memory": "割り当て済みメモリ",
......@@ -684,13 +686,13 @@
"Channel Affinity": "チャネルアフィニティ",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "チャネルアフィニティは、リクエストコンテキストまたは JSON Body から抽出したキーに基づいて、前回成功したチャネルを優先的に再利用します。",
"Channel Affinity: Upstream Cache Hit": "チャネルアフィニティ:上流キャッシュヒット",
"Channel health checks": "チャネルヘルスチェック",
"Channel copied successfully": "チャネルが正常にコピーされました",
"Channel created successfully": "チャネルが正常に作成されました",
"Channel deleted successfully": "チャネルが正常に削除されました",
"Channel disabled successfully": "チャネルが正常に無効化されました",
"Channel enabled successfully": "チャネルが正常に有効化されました",
"Channel Extra Settings": "チャネル詳細設定",
"Channel health checks": "チャネルヘルスチェック",
"Channel ID": "チャネルID",
"Channel ID is required": "チャネル ID が必要です",
"Channel key": "チャネルキー",
......@@ -786,6 +788,7 @@
"Cleared log files": "ログファイルをクリアしました",
"Click \"Create Plan\" to create your first subscription plan": "「プラン作成」をクリックして最初のプランを作成してください",
"Click \"Generate\" to create a token": "「生成」をクリックしてトークンを作成",
"Click a stage to show or hide that column": "ステージをクリックすると、その列を表示または非表示にできます",
"Click any category to drill into its models, apps, and trends": "カテゴリをクリックすると、そのモデル・アプリ・トレンドを掘り下げて確認できます",
"Click for details": "クリックして詳細を表示",
"Click save when you're done.": "完了したら「保存」をクリックしてください。",
......@@ -1216,6 +1219,7 @@
"Delete selected channels": "選択したチャネルを削除",
"Delete selected models": "選択したモデルを削除",
"Deleted": "削除済み",
"Deleted ({{id}})": "削除済み ({{id}})",
"Deleted {{count}} failed models": "失敗したモデルを {{count}} 個削除しました",
"Deleted a custom OAuth provider": "カスタム OAuth プロバイダーを削除しました",
"Deleted a deployment": "デプロイメントを削除しました",
......@@ -1845,6 +1849,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": "フィルター有効",
......@@ -1861,6 +1866,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.": "初回ログイン前に、ガイド付きの手順に従ってワークスペースを準備してください。",
......@@ -2393,10 +2400,10 @@
"Max Disk Cache Size (MB)": "ディスクキャッシュ最大容量 (MB)",
"Max Entries": "最大エントリ数",
"Max output": "最大出力",
"Max Retries": "最大再試行",
"Max Requests (incl. failures)": "最大リクエスト数(失敗を含む)",
"Max Requests (including failures)": "最大リクエスト数(失敗を含む)",
"Max requests per period": "期間あたりの最大リクエスト数",
"Max Retries": "最大再試行",
"Max Success": "最大成功数",
"Max successful requests": "最大成功リクエスト数",
"Max Successful Requests": "最大成功リクエスト数",
......@@ -2420,8 +2427,6 @@
"Merchant ID is required": "マーチャント ID は必須です",
"Message Priority": "メッセージの優先度",
"Metadata": "メタデータ",
"MjProxy": "MjProxy",
"MjProxyPlus": "MjProxyPlus",
"min downtime": "分のダウンタイム",
"Min Top-up": "最低チャージ額",
"Min Top-up:": "最小チャージ額:",
......@@ -2446,6 +2451,8 @@
"Missing Models": "不足しているモデル",
"Missing user data from Passkey login response": "パスキーログイン応答からユーザーデータが欠落しています",
"Mistral": "Mistral",
"MjProxy": "MjProxy",
"MjProxyPlus": "MjProxyPlus",
"Modalities": "モダリティ",
"Modality": "モダリティ",
"Mode": "モード",
......@@ -2636,6 +2643,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 Auth": "認証なし",
......@@ -2675,6 +2683,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": "検索に一致するグループがありません",
......@@ -2694,6 +2703,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.": "モデルが見つかりません。",
......@@ -2768,10 +2778,12 @@
"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": "ベンダーデータがありません",
"No X Found": "X が見つかりません",
"Node": "ノード",
"Node Name": "ノード名",
"Non-stream": "非ストリーミング",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "0 以外の招待報酬には、支払いゲートウェイ設定でのコンプライアンス確認が必要です。",
......@@ -3481,11 +3493,13 @@
"Request Model": "リクエストモデル",
"Request Model:": "リクエストモデル:",
"Request overrides, routing behavior, and upstream model automation": "リクエスト上書き、ルーティング動作、上流モデル自動化",
"Request retry": "リクエスト再試行",
"Request rule pricing": "リクエストルールの課金",
"Request success rate sampled over the last 24 hours": "過去 24 時間にサンプリングされたリクエスト成功率",
"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分あたりのリクエスト数",
......@@ -3544,7 +3558,6 @@
"Restore defaults": "既定に戻す",
"Restrict user model request frequency (may impact high concurrency performance)": "ユーザーモデルのリクエスト頻度を制限する(高並行性パフォーマンスに影響を与える可能性があります)",
"Result": "結果",
"Request retry": "リクエスト再試行",
"Retain last N days": "最新N日間を保持",
"Retain last N files": "最新N個のファイルを保持",
"Retention days": "保持日数",
......@@ -3838,6 +3851,7 @@
"Show All": "すべて表示",
"Show all providers including unbound": "未バインドを含むすべてのプロバイダーを表示",
"Show only bound providers": "バインド済みのプロバイダーのみ表示",
"Show or hide flow columns": "フロー列の表示・非表示",
"Show prices in currency instead of quota.": "クォータではなく通貨で価格を表示。",
"Show setup guide": "セットアップガイドを表示",
"Show token usage statistics in the UI": "UIでトークン使用統計を表示",
......@@ -4106,9 +4120,11 @@
"The administrator has not configured a privacy policy yet.": "管理者がまだプライバシーポリシーを設定していません。",
"The administrator has not configured a user agreement yet.": "管理者がまだ利用規約を設定していません。",
"The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.": "管理者はまだ「概要」コンテンツを設定していません。設定ページでHTMLまたはURLをサポートして設定できます。",
"The API key used for the requests": "リクエストに使用された API キー",
"The binding will complete automatically after authorization": "認証後、バインディングは自動的に完了します",
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "紐付け済み商品はウォレットチャージに使用されます。ユーザーが任意の金額を入力すると、new-api はこの単一の Pancake 商品でチェックアウトを実行し、セッションごとに価格を上書きします。$1 / $5 / $10 の SKU を事前作成する必要はありません。",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "紐付け済みストアは、この管理画面から new-api が作成するすべての Pancake 商品の親コンテナです。ウォレットチャージ商品とサブスクリプションプラン商品が含まれます。通常は 1 つのストアで十分です。別々の Pancake カタログを本当に運用する場合のみ別のストアを固定してください。",
"The deployment node that handled the requests": "リクエストを処理したデプロイノード",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Passkey登録のための有効なドメイン。現在のドメインまたはその親ドメインと一致する必要があります。",
"The entered text does not match the required text.": "入力したテキストが必要なテキストと一致しません。",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "環境(テスト/本番)はここに貼り付けるキーで決まります。統合中はテストキーを使用し、本番公開時に本番キーへ切り替えてください。",
......@@ -4116,6 +4132,7 @@
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "以下のモデルには請求タイプ(固定価格 vs 比率請求)の競合があります。変更を続行するには確認してください。",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "モデルリダイレクト内の以下のモデルは\"モデル\"リストに追加されていないため、利用可能なモデルが不足して呼び出しが失敗する可能性があります:",
"The mapped upstream model(s)": "マッピングされたアップストリームモデル",
"The model that was requested": "リクエストされたモデル",
"The model you're looking for doesn't exist.": "お探しのモデルは存在しません。",
"The name displayed across the application": "アプリケーション全体に表示される名前",
"The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "OAuthコールバック、Webhook、その他の外部統合に使用されるサーバーの公開URL",
......@@ -4128,7 +4145,10 @@
"The token group that will have a custom ratio": "カスタム比率を持つトークングループ",
"The unique identifier for this model": "このモデルの一意の識別子",
"The unique name for this vendor": "このベンダーの一意の名前",
"The upstream channel that served the requests": "リクエストを処理した上流チャネル",
"The URL for this chat client.": "このチャットクライアントのURL。",
"The user group applied to the requests": "リクエストに適用されたユーザーグループ",
"The user who made the requests": "リクエストを行ったユーザー",
"Theme": "テーマ",
"Theme preset": "テーマプリセット",
"Theme Settings": "テーマ設定",
......
......@@ -259,9 +259,10 @@
"AIGC2D": "AIGC2D",
"AILS": "AILS",
"AK/SK mode: use AccessKey|SecretAccessKey|Region": "Режим AK/SK: используйте AccessKey|SecretAccessKey|Region",
"Ali": "Alibaba Bailian",
"Ali": "Alibaba Байлянь",
"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.": "Все изменения являются операциями перезаписи. Оставьте поля пустыми, чтобы сохранить текущие значения без изменений.",
......@@ -277,6 +278,7 @@
"All Tags": "Все теги",
"All Types": "Все типы",
"All upstream data is trusted": "Все вышестоящие данные являются доверенными",
"All users": "Все пользователи",
"All Vendors": "Все поставщики",
"All-time": "За всё время",
"Allocated Memory": "Выделенная память",
......@@ -684,13 +686,13 @@
"Channel Affinity": "Привязка к каналу",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Привязка к каналу повторно использует последний успешный канал на основе ключей, извлечённых из контекста запроса или тела JSON.",
"Channel Affinity: Upstream Cache Hit": "Привязка к каналу: попадание в кэш upstream",
"Channel health checks": "Проверки состояния каналов",
"Channel copied successfully": "Канал успешно скопирован",
"Channel created successfully": "Канал успешно создан",
"Channel deleted successfully": "Канал успешно удалён",
"Channel disabled successfully": "Канал успешно отключён",
"Channel enabled successfully": "Канал успешно включён",
"Channel Extra Settings": "Дополнительные настройки канала",
"Channel health checks": "Проверки состояния каналов",
"Channel ID": "ID канала",
"Channel ID is required": "Требуется ID канала",
"Channel key": "Ключ канала",
......@@ -786,6 +788,7 @@
"Cleared log files": "Файлы журналов очищены",
"Click \"Create Plan\" to create your first subscription plan": "Нажмите «Создать план», чтобы создать первый план подписки",
"Click \"Generate\" to create a token": "Нажмите \"Сгенерировать\", чтобы создать токен",
"Click a stage to show or hide that column": "Нажмите на этап, чтобы показать или скрыть этот столбец",
"Click any category to drill into its models, apps, and trends": "Нажмите на категорию, чтобы изучить её модели, приложения и тренды",
"Click for details": "Нажмите для подробностей",
"Click save when you're done.": "Нажмите «Сохранить», когда закончите.",
......@@ -1216,6 +1219,7 @@
"Delete selected channels": "Удалить выбранные каналы",
"Delete selected models": "Удалить выбранные модели",
"Deleted": "Удалён",
"Deleted ({{id}})": "Удалён ({{id}})",
"Deleted {{count}} failed models": "Удалено неуспешных моделей: {{count}}",
"Deleted a custom OAuth provider": "Удалён пользовательский провайдер OAuth",
"Deleted a deployment": "Удалено развёртывание",
......@@ -1845,6 +1849,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": "Фильтры активны",
......@@ -1861,6 +1866,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.": "Следуйте пошаговым инструкциям, чтобы подготовить рабочее пространство перед первым входом.",
......@@ -2393,10 +2400,10 @@
"Max Disk Cache Size (MB)": "Макс. размер дискового кэша (МБ)",
"Max Entries": "Макс. записей",
"Max output": "Макс. вывод",
"Max Retries": "Макс. повторов",
"Max Requests (incl. failures)": "Макс. запросов (вкл. сбои)",
"Max Requests (including failures)": "Макс. запросов (включая сбои)",
"Max requests per period": "Макс. запросов за период",
"Max Retries": "Макс. повторов",
"Max Success": "Макс. успешных",
"Max successful requests": "Макс. успешных запросов",
"Max Successful Requests": "Макс. успешных запросов",
......@@ -2420,8 +2427,6 @@
"Merchant ID is required": "Требуется ID мерчанта",
"Message Priority": "Приоритет сообщения",
"Metadata": "Метаданные",
"MjProxy": "MjProxy",
"MjProxyPlus": "MjProxyPlus",
"min downtime": "мин простоя",
"Min Top-up": "Мин. пополнение",
"Min Top-up:": "Мин. пополнение:",
......@@ -2446,6 +2451,8 @@
"Missing Models": "Отсутствующие модели",
"Missing user data from Passkey login response": "В ответе Passkey для входа отсутствуют данные пользователя",
"Mistral": "Mistral",
"MjProxy": "MjProxy",
"MjProxyPlus": "MjProxyPlus",
"Modalities": "Модальности",
"Modality": "Модальность",
"Mode": "Режим",
......@@ -2636,6 +2643,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 Auth": "Без auth",
......@@ -2675,6 +2683,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": "Нет групп, соответствующих вашему поиску",
......@@ -2694,6 +2703,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.": "Модель не найдена.",
......@@ -2768,10 +2778,12 @@
"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": "Данных по поставщикам нет",
"No X Found": "X не найдено",
"Node": "Узел",
"Node Name": "Имя узла",
"Non-stream": "Не потоковый",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Ненулевые награды за приглашения требуют подтверждения соответствия в настройках платежного шлюза.",
......@@ -3481,11 +3493,13 @@
"Request Model": "Запрошенная модель",
"Request Model:": "Модель запроса:",
"Request overrides, routing behavior, and upstream model automation": "Переопределения запросов, маршрутизация и автоматизация upstream-моделей",
"Request retry": "Повтор запросов",
"Request rule pricing": "Правила ценообразования по запросу",
"Request success rate sampled over the last 24 hours": "Доля успешных запросов по выборкам за последние 24 часа",
"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": "Запросов в минуту",
......@@ -3544,7 +3558,6 @@
"Restore defaults": "Сбросить к значениям по умолчанию",
"Restrict user model request frequency (may impact high concurrency performance)": "Ограничить частоту запросов пользовательских моделей (может повлиять на производительность при высокой конкуренции)",
"Result": "Результат",
"Request retry": "Повтор запросов",
"Retain last N days": "Хранить последние N дней",
"Retain last N files": "Хранить последние N файлов",
"Retention days": "Дней хранения",
......@@ -3838,6 +3851,7 @@
"Show All": "Показать все",
"Show all providers including unbound": "Показать всех провайдеров (включая непривязанные)",
"Show only bound providers": "Показать только привязанных провайдеров",
"Show or hide flow columns": "Показать или скрыть столбцы потока",
"Show prices in currency instead of quota.": "Показывать цены в валюте вместо квоты.",
"Show setup guide": "Показать руководство по настройке",
"Show token usage statistics in the UI": "Показывать статистику использования токенов в пользовательском интерфейсе",
......@@ -4106,9 +4120,11 @@
"The administrator has not configured a privacy policy yet.": "Администратор ещё не настроил политику конфиденциальности.",
"The administrator has not configured a user agreement yet.": "Администратор ещё не настроил пользовательское соглашение.",
"The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.": "Администратор еще не настроил содержимое раздела «О программе». Вы можете установить его на странице настроек, поддерживается HTML или URL.",
"The API key used for the requests": "API-ключ, использованный для запросов",
"The binding will complete automatically after authorization": "Привязка завершится автоматически после авторизации",
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "Привязанный продукт используется для пополнения кошелька: когда пользователь вводит любую сумму, new-api запускает оплату через этот единственный продукт Pancake и переопределяет цену для каждой сессии — не нужно заранее создавать SKU на $1 / $5 / $10.",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "Привязанный магазин является родительским контейнером для всех продуктов Pancake, которые new-api создает из этой админки: как продукта пополнения кошелька, так и продуктов планов подписки. Одного магазина достаточно; выбирайте другой только если действительно ведете отдельные каталоги Pancake.",
"The deployment node that handled the requests": "Узел развёртывания, обработавший запросы",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Действующий домен для регистрации Passkey. Должен совпадать с текущим доменом или быть его родительским доменом.",
"The entered text does not match the required text.": "Введенный текст не совпадает с требуемым.",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "Окружение (тестовое или рабочее) определяется ключом, который вы вставляете здесь: используйте тестовый ключ при интеграции, затем замените его на рабочий при запуске.",
......@@ -4116,6 +4132,7 @@
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "Следующие модели имеют конфликты типов тарификации (фиксированная цена против тарификации по соотношению). Подтвердите, чтобы продолжить изменения.",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "Следующие модели в перенаправлении модели не были добавлены в список \"Модели\" и могут не работать при вызове из-за отсутствия доступных моделей:",
"The mapped upstream model(s)": "Сопоставленные upstream модель(и)",
"The model that was requested": "Запрошенная модель",
"The model you're looking for doesn't exist.": "Модель, которую вы ищете, не существует.",
"The name displayed across the application": "Имя, отображаемое в приложении",
"The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "Публичный URL вашего сервера, используемый для OAuth-перенаправлений, вебхуков и других внешних интеграций",
......@@ -4128,7 +4145,10 @@
"The token group that will have a custom ratio": "Группа токенов, которая будет иметь пользовательское соотношение",
"The unique identifier for this model": "Уникальный идентификатор для этой модели",
"The unique name for this vendor": "Уникальное имя для этого поставщика",
"The upstream channel that served the requests": "Вышестоящий канал, обслуживший запросы",
"The URL for this chat client.": "URL для этого чат-клиента.",
"The user group applied to the requests": "Группа пользователей, применённая к запросам",
"The user who made the requests": "Пользователь, отправивший запросы",
"Theme": "Тема",
"Theme preset": "Пресет темы",
"Theme Settings": "Настройки темы",
......
......@@ -262,6 +262,7 @@
"Ali": "Alibaba Bailian",
"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.",
......@@ -277,6 +278,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",
......@@ -684,13 +686,13 @@
"Channel Affinity": "Ưu tiên kênh",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Ưu tiên kênh sẽ sử dụng lại kênh thành công gần nhất dựa trên các khóa được trích xuất từ ngữ cảnh yêu cầu hoặc JSON body.",
"Channel Affinity: Upstream Cache Hit": "Ưu tiên kênh: Cache hit từ upstream",
"Channel health checks": "Kiểm tra tình trạng kênh",
"Channel copied successfully": "Sao chép kênh thành công",
"Channel created successfully": "Tạo kênh thành công",
"Channel deleted successfully": "Xóa kênh thành công",
"Channel disabled successfully": "Kênh đã bị vô hiệu hóa thành công",
"Channel enabled successfully": "Kênh đã được bật thành công",
"Channel Extra Settings": "Cài đặt thêm kênh",
"Channel health checks": "Kiểm tra tình trạng kênh",
"Channel ID": "Mã kênh",
"Channel ID is required": "Cần có ID kênh",
"Channel key": "Khóa kênh",
......@@ -786,6 +788,7 @@
"Cleared log files": "Đã xóa tệp nhật ký",
"Click \"Create Plan\" to create your first subscription plan": "Nhấp \"Tạo gói\" để tạo gói đăng ký đầu tiên",
"Click \"Generate\" to create a token": "Nhấp \"Tạo\" để tạo một token",
"Click a stage to show or hide that column": "Nhấp vào một giai đoạn để hiện hoặc ẩn cột đó",
"Click any category to drill into its models, apps, and trends": "Bấm vào danh mục để xem chi tiết mô hình, ứng dụng và xu hướng",
"Click for details": "Nhấp để xem chi tiết",
"Click save when you're done.": "Nhấn lưu khi bạn hoàn tất.",
......@@ -1216,6 +1219,7 @@
"Delete selected channels": "Xóa các kênh đã chọn",
"Delete selected models": "Xóa các mô hình đã chọn",
"Deleted": "Đã xóa",
"Deleted ({{id}})": "Đã xóa ({{id}})",
"Deleted {{count}} failed models": "Đã xóa {{count}} mô hình thất bại",
"Deleted a custom OAuth provider": "Đã xóa nhà cung cấp OAuth tùy chỉnh",
"Deleted a deployment": "Đã xóa một triển khai",
......@@ -1845,6 +1849,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",
......@@ -1861,6 +1866,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.",
......@@ -2393,10 +2400,10 @@
"Max Disk Cache Size (MB)": "Dung lượng tối đa bộ nhớ đệm đĩa (MB)",
"Max Entries": "Số mục tối đa",
"Max output": "Đầu ra tối đa",
"Max Retries": "Số lần thử lại tối đa",
"Max Requests (incl. failures)": "Maximum number of requests (including errors)",
"Max Requests (including failures)": "Số yêu cầu tối đa (bao gồm cả các lỗi)",
"Max requests per period": "Yêu cầu tối đa mỗi khoảng thời gian",
"Max Retries": "Số lần thử lại tối đa",
"Max Success": "Thành công tối đa",
"Max successful requests": "Số yêu cầu thành công tối đa",
"Max Successful Requests": "Yêu cầu thành công tối đa",
......@@ -2420,8 +2427,6 @@
"Merchant ID is required": "Bắt buộc nhập Merchant ID",
"Message Priority": "Ưu tiên tin nhắn",
"Metadata": "Siêu dữ liệu",
"MjProxy": "MjProxy",
"MjProxyPlus": "MjProxyPlus",
"min downtime": "phút gián đoạn",
"Min Top-up": "Nạp tối thiểu",
"Min Top-up:": "Nạp tối thiểu:",
......@@ -2446,6 +2451,8 @@
"Missing Models": "Thiếu Mô hình",
"Missing user data from Passkey login response": "Thiếu dữ liệu người dùng từ phản hồi đăng nhập Passkey",
"Mistral": "Mistral",
"MjProxy": "MjProxy",
"MjProxyPlus": "MjProxyPlus",
"Modalities": "Phương thức",
"Modality": "Phương thức",
"Mode": "Chế độ",
......@@ -2636,6 +2643,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 Auth": "Không xác thực",
......@@ -2675,6 +2683,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",
......@@ -2694,6 +2703,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.",
......@@ -2768,10 +2778,12 @@
"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",
"No X Found": "Không tìm thấy X",
"Node": "Nút",
"Node Name": "Tên nút",
"Non-stream": "Không phát trực tuyến",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Phần thưởng mời khác 0 yêu cầu xác nhận tuân thủ trong cài đặt Cổng thanh toán.",
......@@ -3481,11 +3493,13 @@
"Request Model": "Mô hình yêu cầu",
"Request Model:": "Mô hình yêu cầu:",
"Request overrides, routing behavior, and upstream model automation": "Ghi đè yêu cầu, hành vi định tuyến và tự động hóa mô hình upstream",
"Request retry": "Thử lại yêu cầu",
"Request rule pricing": "Quy tắc tính giá theo request",
"Request success rate sampled over the last 24 hours": "Tỷ lệ yêu cầu thành công được lấy mẫu trong 24 giờ qua",
"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",
......@@ -3544,7 +3558,6 @@
"Restore defaults": "Khôi phục mặc định",
"Restrict user model request frequency (may impact high concurrency performance)": "Hạn chế tần suất yêu cầu mô hình người dùng (có thể ảnh hưởng đến hiệu suất khi có độ đồng thời cao)",
"Result": "Kết quả",
"Request retry": "Thử lại yêu cầu",
"Retain last N days": "Giữ lại N ngày gần nhất",
"Retain last N files": "Giữ lại N tệp gần nhất",
"Retention days": "Số ngày lưu giữ",
......@@ -3838,6 +3851,7 @@
"Show All": "Hiển thị tất cả",
"Show all providers including unbound": "Hiển thị tất cả nhà cung cấp (bao gồm chưa liên kết)",
"Show only bound providers": "Chỉ hiển thị nhà cung cấp đã liên kết",
"Show or hide flow columns": "Hiện hoặc ẩn các cột luồng",
"Show prices in currency instead of quota.": "Hiển thị giá bằng tiền tệ thay vì hạn ngạch.",
"Show setup guide": "Hiển thị hướng dẫn thiết lập",
"Show token usage statistics in the UI": "Hiển thị thống kê sử dụng token trong giao diện người dùng",
......@@ -4106,9 +4120,11 @@
"The administrator has not configured a privacy policy yet.": "Quản trị viên chưa cấu hình chính sách bảo mật.",
"The administrator has not configured a user agreement yet.": "Quản trị viên chưa cấu hình thỏa thuận người dùng.",
"The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.": "Quản trị viên chưa cấu hình bất kỳ nội dung giới thiệu nào. Bạn có thể thiết lập nó trong trang cài đặt, hỗ trợ HTML hoặc URL.",
"The API key used for the requests": "Khóa API được dùng cho các yêu cầu",
"The binding will complete automatically after authorization": "Việc liên kết sẽ hoàn tất tự động sau khi ủy quyền",
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "Sản phẩm đã liên kết dùng cho nạp ví: khi người dùng nhập bất kỳ số tiền nào, new-api chạy thanh toán trên một sản phẩm Pancake duy nhất này và ghi đè giá theo từng phiên — không cần tạo trước SKU $1 / $5 / $10.",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "Cửa hàng đã liên kết là vùng chứa cha cho mọi sản phẩm Pancake mà new-api tạo từ trang quản trị này — bao gồm sản phẩm nạp ví và mọi sản phẩm gói đăng ký. Một cửa hàng là đủ; chỉ ghim cửa hàng khác nếu bạn thực sự vận hành các catalog Pancake riêng.",
"The deployment node that handled the requests": "Nút triển khai đã xử lý các yêu cầu",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Mi",
"The entered text does not match the required text.": "Văn bản đã nhập không khớp với văn bản yêu cầu.",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "Môi trường (test hay production) được quyết định bởi khóa bạn dán tại đây — dùng khóa Test khi tích hợp, sau đó đổi sang khóa Production khi chạy chính thức.",
......@@ -4116,6 +4132,7 @@
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "Các mô hình sau có xung đột loại thanh toán (giá cố định so với thanh toán theo tỷ lệ). Xác nhận để tiếp tục với các thay đổi.",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "Các mô hình sau trong chuyển hướng mô hình chưa được thêm vào danh sách \"Mô hình\" và có thể gọi thất bại do thiếu các mô hình có sẵn:",
"The mapped upstream model(s)": "Mô hình(s) thượng nguồn được ánh xạ",
"The model that was requested": "Mô hình đã được yêu cầu",
"The model you're looking for doesn't exist.": "Mô hình bạn đang tìm kiếm không tồn tại.",
"The name displayed across the application": "Tên hiển thị trên ứng dụng",
"The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "URL công khai của máy chủ, dùng cho callback OAuth, webhook và các tích hợp bên ngoài khác",
......@@ -4128,7 +4145,10 @@
"The token group that will have a custom ratio": "The token group will have a custom ratio.",
"The unique identifier for this model": "Mã định danh duy nhất cho mô hình này",
"The unique name for this vendor": "Tên duy nhất cho nhà cung cấp này",
"The upstream channel that served the requests": "Kênh thượng nguồn đã phục vụ các yêu cầu",
"The URL for this chat client.": "URL của ứng dụng chat này.",
"The user group applied to the requests": "Nhóm người dùng được áp dụng cho các yêu cầu",
"The user who made the requests": "Người dùng đã thực hiện các yêu cầu",
"Theme": "Chủ đề",
"Theme preset": "Tùy chỉnh chủ đề",
"Theme Settings": "Cài đặt chủ đề",
......
......@@ -262,6 +262,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.": "所有编辑都是覆盖操作。留空字段将保持当前值不变。",
......@@ -277,6 +278,7 @@
"All Tags": "所有标签",
"All Types": "所有类型",
"All upstream data is trusted": "所有上游数据均受信任",
"All users": "全部用户",
"All Vendors": "所有供应商",
"All-time": "全部时间",
"Allocated Memory": "已分配内存",
......@@ -684,13 +686,13 @@
"Channel Affinity": "渠道亲和性",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "渠道亲和性会基于从请求上下文或 JSON Body 提取的 Key,优先复用上一次成功的渠道。",
"Channel Affinity: Upstream Cache Hit": "渠道亲和性:上游缓存命中",
"Channel health checks": "渠道健康检查",
"Channel copied successfully": "渠道复制成功",
"Channel created successfully": "渠道创建成功",
"Channel deleted successfully": "渠道删除成功",
"Channel disabled successfully": "渠道禁用成功",
"Channel enabled successfully": "渠道启用成功",
"Channel Extra Settings": "渠道额外设置",
"Channel health checks": "渠道健康检查",
"Channel ID": "渠道 ID",
"Channel ID is required": "缺少渠道 ID",
"Channel key": "渠道密钥",
......@@ -786,6 +788,7 @@
"Cleared log files": "清理日志文件",
"Click \"Create Plan\" to create your first subscription plan": "点击「新建套餐」创建您的第一个订阅套餐",
"Click \"Generate\" to create a token": "点击“生成”以创建令牌",
"Click a stage to show or hide that column": "点击某个阶段可显示或隐藏对应的列",
"Click any category to drill into its models, apps, and trends": "点击任意分类可下钻查看其模型、应用与趋势",
"Click for details": "点击查看详情",
"Click save when you're done.": "完成後點擊保存。",
......@@ -1216,6 +1219,7 @@
"Delete selected channels": "删除所选渠道",
"Delete selected models": "删除选定的模型",
"Deleted": "已注销",
"Deleted ({{id}})": "已删除({{id}})",
"Deleted {{count}} failed models": "已删除 {{count}} 个失败模型",
"Deleted a custom OAuth provider": "删除了一个自定义 OAuth 提供方",
"Deleted a deployment": "删除了一个部署",
......@@ -1845,6 +1849,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": "筛选已启用",
......@@ -1861,6 +1866,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.": "请按照引导步骤在首次登录前准备您的工作区。",
......@@ -2393,10 +2400,10 @@
"Max Disk Cache Size (MB)": "磁盘缓存最大总量 (MB)",
"Max Entries": "最大条目数",
"Max output": "最大输出",
"Max Retries": "最大重试",
"Max Requests (incl. failures)": "最大请求数(包括失败)",
"Max Requests (including failures)": "最大请求数(包括失败)",
"Max requests per period": "每周期最大请求数",
"Max Retries": "最大重试",
"Max Success": "最大成功数",
"Max successful requests": "最大成功请求数",
"Max Successful Requests": "最大成功请求数",
......@@ -2420,8 +2427,6 @@
"Merchant ID is required": "商户 ID 为必填项",
"Message Priority": "消息优先级",
"Metadata": "元信息",
"MjProxy": "MjProxy",
"MjProxyPlus": "MjProxyPlus",
"min downtime": "分钟停机",
"Min Top-up": "最低充值",
"Min Top-up:": "最低充值:",
......@@ -2446,6 +2451,8 @@
"Missing Models": "缺失的模型",
"Missing user data from Passkey login response": "Passkey 登录响应中缺少用户数据",
"Mistral": "Mistral",
"MjProxy": "MjProxy",
"MjProxyPlus": "MjProxyPlus",
"Modalities": "模态",
"Modality": "模态",
"Mode": "模式",
......@@ -2636,6 +2643,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 Auth": "无认证",
......@@ -2675,6 +2683,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": "没有组匹配您的搜索",
......@@ -2694,6 +2703,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.": "未找到模型。",
......@@ -2768,10 +2778,12 @@
"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": "暂无厂商数据",
"No X Found": "未找到 X",
"Node": "节点",
"Node Name": "节点名称",
"Non-stream": "非流式",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "非零邀请奖励需要先在支付网关设置中确认合规条款。",
......@@ -3481,11 +3493,13 @@
"Request Model": "请求模型",
"Request Model:": "请求模型:",
"Request overrides, routing behavior, and upstream model automation": "请求覆盖、路由行为和上游模型自动化",
"Request retry": "请求重试",
"Request rule pricing": "请求规则计费",
"Request success rate sampled over the last 24 hours": "最近 24 小时按时间桶采样的请求成功率",
"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": "每分钟请求数",
......@@ -3544,7 +3558,6 @@
"Restore defaults": "恢复默认",
"Restrict user model request frequency (may impact high concurrency performance)": "限制用户模型请求频率(可能会影响高并发性能)",
"Result": "结果",
"Request retry": "请求重试",
"Retain last N days": "保留最近N天",
"Retain last N files": "保留最近 N 个文件",
"Retention days": "保留天数",
......@@ -3838,6 +3851,7 @@
"Show All": "显示全部",
"Show all providers including unbound": "显示所有提供商(包括未绑定)",
"Show only bound providers": "仅显示已绑定的提供商",
"Show or hide flow columns": "显示或隐藏分流列",
"Show prices in currency instead of quota.": "以货币而非配额显示价格。",
"Show setup guide": "显示设置引导",
"Show token usage statistics in the UI": "在用户界面中显示令牌使用统计信息",
......@@ -4106,9 +4120,11 @@
"The administrator has not configured a privacy policy yet.": "管理员尚未配置隐私政策。",
"The administrator has not configured a user agreement yet.": "管理员尚未配置用户协议。",
"The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.": "管理员尚未配置任何关于内容。您可以在设置页面中进行设置,支持 HTML 或 URL。",
"The API key used for the requests": "请求所使用的 API 密钥",
"The binding will complete automatically after authorization": "授权后绑定将自动完成",
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "已绑定产品用于钱包充值:当用户输入任意金额时,new-api 会基于这个单一 Pancake 产品发起结账,并按会话覆盖价格,无需预先创建 $1 / $5 / $10 的 SKU。",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "已绑定店铺是 new-api 从此管理端创建的所有 Pancake 产品的父容器,包括钱包充值产品和订阅套餐产品。一个店铺通常足够;只有在确实运营多个 Pancake 目录时才需要绑定不同店铺。",
"The deployment node that handled the requests": "处理请求的部署节点",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "用于 Passkey 注册的有效域。必须与当前域匹配或为其父域。",
"The entered text does not match the required text.": "输入文本与要求文本不匹配。",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "环境(测试或生产)由你在此粘贴的密钥决定。集成期间使用测试密钥,上线时再切换为生产密钥。",
......@@ -4116,6 +4132,7 @@
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "以下模型存在计费类型冲突(固定价格 vs 比例计费)。确认以继续更改。",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "模型重定向里的下列模型尚未添加到\"模型\"列表,调用时会因为缺少可用模型而失败:",
"The mapped upstream model(s)": "映射的上游模型",
"The model that was requested": "被请求的模型",
"The model you're looking for doesn't exist.": "您查找的模型不存在。",
"The name displayed across the application": "在整个应用程序中显示的名称",
"The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "服务器的公开URL,用于OAuth回调、Webhook和其他外部集成",
......@@ -4128,7 +4145,10 @@
"The token group that will have a custom ratio": "将具有自定义比例的令牌分组",
"The unique identifier for this model": "此模型的唯一标识符",
"The unique name for this vendor": "此供应商的唯一名称",
"The upstream channel that served the requests": "处理请求的上游渠道",
"The URL for this chat client.": "此聊天客户端的 URL。",
"The user group applied to the requests": "请求所应用的用户分组",
"The user who made the requests": "发起请求的用户",
"Theme": "主题",
"Theme preset": "主题预设",
"Theme Settings": "主题设置",
......
......@@ -435,6 +435,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',
......@@ -490,6 +494,20 @@ export const STATIC_I18N_KEYS = [
'Batch detection failed',
'Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed',
// Dashboard flow stages (labels/descriptions passed to t at runtime)
'User',
'Node',
'Token',
'Group',
'Model',
'Channel',
'The user who made the requests',
'The deployment node that handled the requests',
'The API key used for the requests',
'The user group applied to the requests',
'The model that was requested',
'The upstream channel that served the requests',
// Misc
'Cancel',
'Status',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment