feat(dashboard): show cache usage alongside total tokens (#7773)

* feat(dashboard): show cache usage alongside total tokens

* fix(dashboard): align cache stat descriptions and fix typecheck
This commit is contained in:
Seefs authored and GitHub committed 2026-10-20 15:42:31 +08:00
1 parent 32e587406c
commit 603a5ceb55
14 files changed
+147 -68

No files matched your search

+19 -10
View File
@@ -386,17 +386,26 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
logger.LogError(c, common.LogText("failed to record log: %s", err.Error()))
}
if common.DataExportEnabled {
var cacheReadTokens, cacheCreationTokens int
if other != nil {
cacheReadTokens, _ = other.public["cache_tokens"].(int)
// The settlement path already normalizes the cache creation total.
// Do not add the 5m/1h breakdown again or infer tokens from prices.
cacheCreationTokens, _ = other.public["cache_write_tokens"].(int)
}
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,
UserID: userId,
Username: username,
ModelName: params.ModelName,
Quota: params.Quota,
CreatedAt: createdAt,
TokenUsed: params.PromptTokens + params.CompletionTokens,
CacheReadTokens: int64(cacheReadTokens),
CacheCreationTokens: int64(cacheCreationTokens),
UseGroup: params.Group,
TokenID: params.TokenId,
ChannelID: params.ChannelId,
NodeName: common.NodeName,
})
}
}
+50 -40
View File
@@ -11,31 +11,35 @@ import (
// QuotaData 柱状图数据
type QuotaData struct {
Id int `json:"id"`
UserID int `json:"user_id" gorm:"index"`
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"`
Id int `json:"id"`
UserID int `json:"user_id" gorm:"index"`
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"`
CacheReadTokens int64 `json:"cache_read_tokens" gorm:"default:0"`
CacheCreationTokens int64 `json:"cache_creation_tokens" 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
UserID int
Username string
ModelName string
Quota int
CreatedAt int64
TokenUsed int
CacheReadTokens int64
CacheCreationTokens int64
UseGroup string
TokenID int
ChannelID int
NodeName string
}
func UpdateQuotaData() {
@@ -70,6 +74,8 @@ func logQuotaDataCache(quotaData *QuotaData) {
cachedQuotaData.Count += count
cachedQuotaData.Quota += quota
cachedQuotaData.TokenUsed += tokenUsed
cachedQuotaData.CacheReadTokens += quotaData.CacheReadTokens
cachedQuotaData.CacheCreationTokens += quotaData.CacheCreationTokens
quotaData = cachedQuotaData
}
CacheQuotaData[key] = quotaData
@@ -79,17 +85,19 @@ func LogQuotaData(params QuotaDataLogParams) {
// 只精确到小时
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,
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,
CacheReadTokens: max(0, params.CacheReadTokens),
CacheCreationTokens: max(0, params.CacheCreationTokens),
}
CacheQuotaDataLock.Lock()
@@ -129,9 +137,11 @@ func increaseQuotaData(quotaData *QuotaData) {
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]any{
"count": gorm.Expr("count + ?", quotaData.Count),
"quota": gorm.Expr("quota + ?", quotaData.Quota),
"token_used": gorm.Expr("token_used + ?", quotaData.TokenUsed),
"count": gorm.Expr("count + ?", quotaData.Count),
"quota": gorm.Expr("quota + ?", quotaData.Quota),
"token_used": gorm.Expr("token_used + ?", quotaData.TokenUsed),
"cache_read_tokens": gorm.Expr("cache_read_tokens + ?", quotaData.CacheReadTokens),
"cache_creation_tokens": gorm.Expr("cache_creation_tokens + ?", quotaData.CacheCreationTokens),
}).Error
if err != nil {
common.SysLog(common.LogText("increaseQuotaData error: %s", err))
@@ -142,7 +152,7 @@ func GetQuotaDataByUsername(username string, startTime int64, endTime int64) (qu
var quotaDatas []*QuotaData
// 从quota_data表中查询数据
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").
Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, sum(cache_read_tokens) as cache_read_tokens, sum(cache_creation_tokens) as cache_creation_tokens").
Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime).
Group("user_id, username, model_name, created_at").
Find(&quotaDatas).Error
@@ -153,7 +163,7 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData
var quotaDatas []*QuotaData
// 从quota_data表中查询数据
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").
Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, sum(cache_read_tokens) as cache_read_tokens, sum(cache_creation_tokens) as cache_creation_tokens").
Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime).
Group("user_id, username, model_name, created_at").
Find(&quotaDatas).Error
@@ -163,7 +173,7 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData
func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
var quotaDatas []*QuotaData
err = DB.Table("quota_data").
Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, sum(cache_read_tokens) as cache_read_tokens, sum(cache_creation_tokens) as cache_creation_tokens").
Where("created_at >= ? and created_at <= ?", startTime, endTime).
Group("username, created_at").
Find(&quotaDatas).Error
@@ -178,6 +188,6 @@ func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaDat
// 从quota_data表中查询数据
// only select model_name, sum(count) as count, sum(quota) as quota, model_name, created_at from quota_data group by model_name, created_at;
//err = DB.Table("quota_data").Where("created_at >= ? and created_at <= ?", startTime, endTime).Find(&quotaDatas).Error
err = DB.Table("quota_data").Select("model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, created_at").Where("created_at >= ? and created_at <= ?", startTime, endTime).Group("model_name, created_at").Find(&quotaDatas).Error
err = DB.Table("quota_data").Select("model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, sum(cache_read_tokens) as cache_read_tokens, sum(cache_creation_tokens) as cache_creation_tokens, created_at").Where("created_at >= ? and created_at <= ?", startTime, endTime).Group("model_name, created_at").Find(&quotaDatas).Error
return quotaDatas, err
}
@@ -43,7 +43,7 @@ interface LogStatCardsProps {
onDataUpdate?: (data: QuotaDataItem[], loading: boolean) => void
}
const MAX_INLINE_STAT_CHARS = 9
const MAX_INLINE_STAT_CHARS = 7
function formatStatNumber(value: number, locale: Intl.LocalesArgument) {
const fullValue = formatNumber(value, locale)
@@ -63,11 +63,9 @@ export function LogStatCards(props: LogStatCardsProps) {
const statCardsConfig = useModelStatCardsConfig()
const user = useAuthStore((state) => state.auth.user)
const isAdmin = !!(user?.role && user.role >= 10)
const [stats, setStats] = useState<{
totalQuota: number
totalCount: number
totalTokens: number
} | null>(null)
const [stats, setStats] = useState<ReturnType<
typeof calculateDashboardStats
> | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
@@ -119,6 +117,8 @@ export function LogStatCards(props: LogStatCardsProps) {
rpm: stats?.totalCount ?? 0,
quota: stats?.totalQuota ?? 0,
tpm: stats?.totalTokens ?? 0,
cacheRead: stats?.totalCacheReadTokens ?? 0,
cacheCreation: stats?.totalCacheCreationTokens ?? 0,
}
const items = statCardsConfig.map((config) => {
@@ -133,6 +133,7 @@ export function LogStatCards(props: LogStatCardsProps) {
: formatStatNumber(rawValue, locale)
return {
key: config.key,
title: config.title,
value: formatted.displayValue,
fullValue: formatted.fullValue,
@@ -144,7 +145,7 @@ export function LogStatCards(props: LogStatCardsProps) {
return (
<div className='overflow-hidden rounded-lg border'>
<div className='divide-border/60 grid min-w-0 grid-cols-2 divide-x sm:grid-cols-3 lg:grid-cols-5'>
<div className='divide-border/60 grid min-w-0 grid-cols-2 divide-x sm:grid-cols-3 lg:grid-cols-7'>
{items.map((it, idx) => {
const Icon = it.icon
let valueContent
@@ -184,9 +185,12 @@ export function LogStatCards(props: LogStatCardsProps) {
return (
<div
key={it.title}
key={it.key}
role='group'
aria-label={it.title}
aria-busy={loading}
className={cn(
'min-w-0 px-2.5 py-1.5 sm:px-5 sm:py-4',
'min-w-0 px-2.5 py-1.5 sm:px-3 sm:py-4 xl:px-4',
idx === items.length - 1 &&
items.length % 2 !== 0 &&
'col-span-2 sm:col-span-1'
@@ -200,7 +204,10 @@ export function LogStatCards(props: LogStatCardsProps) {
>
<Icon />
</IconBadge>
<div className='text-muted-foreground truncate text-[11px] leading-4 font-medium tracking-wide uppercase sm:text-xs sm:tracking-wider'>
<div
className='text-muted-foreground truncate text-[11px] leading-4 font-medium tracking-wide uppercase sm:text-xs sm:tracking-wider'
title={it.title}
>
{it.title}
</div>
</div>
@@ -210,6 +217,11 @@ export function LogStatCards(props: LogStatCardsProps) {
)
})}
</div>
{/*<p className='text-muted-foreground border-t px-3 py-2 text-xs'>*/}
{/* {t(*/}
{/* 'Cache usage is recorded only after the upgrade; historical data is not backfilled.'*/}
{/* )}*/}
{/*</p>*/}
</div>
)
}
@@ -20,6 +20,8 @@ import {
Hash,
Coins,
Layers,
Download,
Upload,
Gauge,
Zap,
Flame,
@@ -69,6 +71,22 @@ export function useModelStatCardsConfig(): StatCardConfig[] {
iconTone: 'chart-4',
getValue: (stat) => stat?.tpm ?? 0,
},
{
key: 'cacheRead',
title: t('Cache Read'),
description: t('Statistical tokens'),
icon: Download,
iconTone: 'chart-3',
getValue: (stat) => stat?.cacheRead ?? 0,
},
{
key: 'cacheCreation',
title: t('Cache Write'),
description: t('Statistical tokens'),
icon: Upload,
iconTone: 'chart-5',
getValue: (stat) => stat?.cacheCreation ?? 0,
},
{
key: 'avgRpm',
title: t('Average RPM'),
+7 -2
View File
@@ -64,6 +64,8 @@ const LOG_STAT_CARD_FALLBACK_KEYS = [
'count',
'quota',
'tokens',
'cache-read',
'cache-creation',
'average-rpm',
'average-tpm',
] as const
@@ -116,12 +118,12 @@ const LazyFlowCharts = lazy(() =>
function LogStatCardsFallback() {
return (
<div className='overflow-hidden rounded-lg border'>
<div className='divide-border/60 grid grid-cols-2 divide-x sm:grid-cols-3 lg:grid-cols-5'>
<div className='divide-border/60 grid min-w-0 grid-cols-2 divide-x sm:grid-cols-3 lg:grid-cols-7'>
{LOG_STAT_CARD_FALLBACK_KEYS.map((key, index) => (
<div
key={key}
className={cn(
'px-2.5 py-1.5 sm:px-5 sm:py-4',
'min-w-0 px-2.5 py-1.5 sm:px-3 sm:py-4 xl:px-4',
index === LOG_STAT_CARD_FALLBACK_KEYS.length - 1 &&
'col-span-2 sm:col-span-1'
)}
@@ -135,6 +137,9 @@ function LogStatCardsFallback() {
</div>
))}
</div>
<div className='border-t px-3 py-2'>
<Skeleton className='h-4 w-full max-w-md' />
</div>
</div>
)
}
+22 -6
View File
@@ -37,11 +37,27 @@ export function safeDivide(
*/
export function calculateDashboardStats(data: QuotaDataItem[]) {
return data.reduce(
(acc, item) => ({
totalQuota: acc.totalQuota + (Number(item.quota) || 0),
totalCount: acc.totalCount + (Number(item.count) || 0),
totalTokens: acc.totalTokens + (Number(item.token_used) || 0),
}),
{ totalQuota: 0, totalCount: 0, totalTokens: 0 }
(acc, item) => {
const cacheRead = item.cache_read_tokens ?? 0
const cacheCreation = item.cache_creation_tokens ?? 0
return {
totalQuota: acc.totalQuota + (Number(item.quota) || 0),
totalCount: acc.totalCount + (Number(item.count) || 0),
totalTokens: acc.totalTokens + (Number(item.token_used) || 0),
totalCacheReadTokens:
acc.totalCacheReadTokens +
(Number.isFinite(cacheRead) ? Math.max(0, cacheRead) : 0),
totalCacheCreationTokens:
acc.totalCacheCreationTokens +
(Number.isFinite(cacheCreation) ? Math.max(0, cacheCreation) : 0),
}
},
{
totalQuota: 0,
totalCount: 0,
totalTokens: 0,
totalCacheReadTokens: 0,
totalCacheCreationTokens: 0,
}
)
}
+2
View File
@@ -28,6 +28,8 @@ export interface QuotaDataItem {
username?: string
model_name?: string
created_at: number
cache_read_tokens?: number
cache_creation_tokens?: number
token_used?: number
count?: number
quota?: number
+1
View File
@@ -3719,6 +3719,7 @@
"Model assignments": "Model assignments",
"model billing support": "model billing support",
"Model Call Analytics": "Model Call Analytics",
"Cache usage is recorded only after the upgrade; historical data is not backfilled.": "Cache usage is recorded only after the upgrade; historical data is not backfilled.",
"Model context usage": "Model context usage",
"Model deleted": "Model deleted",
"Model deleted successfully": "Model deleted successfully",
+1
View File
@@ -3719,6 +3719,7 @@
"Model assignments": "Attribution des modèles",
"model billing support": "prise en charge de la facturation des modèles",
"Model Call Analytics": "Analyse des appels de modèles",
"Cache usage is recorded only after the upgrade; historical data is not backfilled.": "L’utilisation du cache est enregistrée uniquement après la mise à niveau ; les données historiques ne sont pas complétées.",
"Model context usage": "Utilisation du contexte du modèle",
"Model deleted": "Modèle supprimé",
"Model deleted successfully": "Modèle supprimé avec succès",
+1
View File
@@ -3719,6 +3719,7 @@
"Model assignments": "モデルの割り当て",
"model billing support": "モデル課金対応",
"Model Call Analytics": "モデル呼び出し分析",
"Cache usage is recorded only after the upgrade; historical data is not backfilled.": "キャッシュ使用量はアップグレード後から記録され、過去のデータは補完されません。",
"Model context usage": "モデルのコンテキスト使用量",
"Model deleted": "モデルが削除されました",
"Model deleted successfully": "モデルが正常に削除されました",
+1
View File
@@ -3719,6 +3719,7 @@
"Model assignments": "Привязка моделей",
"model billing support": "поддержка биллинга моделей",
"Model Call Analytics": "Аналитика вызовов моделей",
"Cache usage is recorded only after the upgrade; historical data is not backfilled.": "Использование кэша учитывается только после обновления; исторические данные не заполняются.",
"Model context usage": "Использование контекста модели",
"Model deleted": "Модель удалена",
"Model deleted successfully": "Модель успешно удалена",
+1
View File
@@ -3719,6 +3719,7 @@
"Model assignments": "Gán mô hình",
"model billing support": "hỗ trợ tính phí mô hình",
"Model Call Analytics": "Phân tích lượt gọi mô hình",
"Cache usage is recorded only after the upgrade; historical data is not backfilled.": "Mức sử dụng bộ nhớ đệm chỉ được ghi nhận sau khi nâng cấp; dữ liệu lịch sử không được bổ sung.",
"Model context usage": "Sử dụng ngữ cảnh mô hình",
"Model deleted": "Đã xóa mô hình",
"Model deleted successfully": "Model đã được xóa thành công",
+1
View File
@@ -3719,6 +3719,7 @@
"Model assignments": "模型歸屬",
"model billing support": "模型收費支援",
"Model Call Analytics": "模型呼叫分析",
"Cache usage is recorded only after the upgrade; historical data is not backfilled.": "快取用量僅從升級後開始記錄,歷史資料不回填。",
"Model context usage": "模型上下文用量",
"Model deleted": "模型已刪除",
"Model deleted successfully": "模型刪除成功",
+1
View File
@@ -3719,6 +3719,7 @@
"Model assignments": "模型归属",
"model billing support": "模型计费支持",
"Model Call Analytics": "模型调用分析",
"Cache usage is recorded only after the upgrade; historical data is not backfilled.": "缓存用量仅从升级后开始记录,历史数据不回填。",
"Model context usage": "模型上下文用量",
"Model deleted": "模型已删除",
"Model deleted successfully": "模型删除成功",