From d16d36b3b37872a622782fb65f4a3e6bc33a9d99 Mon Sep 17 00:00:00 2001 From: SHLE1 Date: Tue, 19 May 2026 10:38:02 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20API=20Key=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=BB=9F=E8=AE=A1=E5=8A=9F=E8=83=BD=E5=8F=8A?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E5=9B=BE=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/log.go | 33 +++ model/log_token_stat.go | 50 ++++ router/api-router.go | 2 + web/default/src/features/dashboard/api.ts | 23 +- .../dashboard/components/keys/key-charts.tsx | 260 ++++++++++++++++++ web/default/src/features/dashboard/index.tsx | 17 ++ .../src/features/dashboard/lib/charts.ts | 258 +++++++++++++++++ .../src/features/dashboard/lib/index.ts | 6 +- .../features/dashboard/section-registry.tsx | 6 + web/default/src/features/dashboard/types.ts | 14 + web/default/src/i18n/locales/en.json | 5 + web/default/src/i18n/locales/fr.json | 5 + web/default/src/i18n/locales/ja.json | 5 + web/default/src/i18n/locales/ru.json | 5 + web/default/src/i18n/locales/vi.json | 5 + web/default/src/i18n/locales/zh.json | 5 + 16 files changed, 697 insertions(+), 2 deletions(-) create mode 100644 model/log_token_stat.go create mode 100644 web/default/src/features/dashboard/components/keys/key-charts.tsx diff --git a/controller/log.go b/controller/log.go index a43f7b75227..4e0c93e561b 100644 --- a/controller/log.go +++ b/controller/log.go @@ -150,6 +150,39 @@ func GetLogsSelfStat(c *gin.Context) { return } +// GetLogStatsByToken returns API key usage statistics for admins. +func GetLogStatsByToken(c *gin.Context) { + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + data, err := model.GetLogStatsByToken(0, startTimestamp, endTimestamp) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": data, + }) +} + +// GetUserLogStatsByToken returns API key usage statistics for the current user. +func GetUserLogStatsByToken(c *gin.Context) { + userId := c.GetInt("id") + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + data, err := model.GetLogStatsByToken(userId, startTimestamp, endTimestamp) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": data, + }) +} + func DeleteHistoryLogs(c *gin.Context) { targetTimestamp, _ := strconv.ParseInt(c.Query("target_timestamp"), 10, 64) if targetTimestamp == 0 { diff --git a/model/log_token_stat.go b/model/log_token_stat.go new file mode 100644 index 00000000000..03c16af718a --- /dev/null +++ b/model/log_token_stat.go @@ -0,0 +1,50 @@ +package model + +import ( + "github.com/QuantumNous/new-api/common" +) + +// TokenQuotaData is hourly aggregated usage data by API key. +type TokenQuotaData struct { + TokenId int `json:"token_id"` + TokenName string `json:"token_name"` + CreatedAt int64 `json:"created_at"` + Count int `json:"count"` + Quota int `json:"quota"` + TokenUsed int `json:"token_used"` +} + +// GetLogStatsByToken aggregates consume logs by API key and hour. +// userId = 0 returns all users' data; userId > 0 limits data to that user. +func GetLogStatsByToken(userId int, startTime, endTime int64) ([]*TokenQuotaData, error) { + var results []*TokenQuotaData + + query := LOG_DB.Table("logs"). + Select(`token_id, + token_name, + (created_at - created_at % 3600) AS created_at, + COUNT(*) AS count, + COALESCE(SUM(quota), 0) AS quota, + COALESCE(SUM(prompt_tokens), 0) + COALESCE(SUM(completion_tokens), 0) AS token_used`). + Where("type = ?", LogTypeConsume). + Where("token_id != 0"). + Where("token_name != ''"). + Group("token_id, token_name, (created_at - created_at % 3600)"). + Order("(created_at - created_at % 3600)") + + if userId > 0 { + query = query.Where("user_id = ?", userId) + } + if startTime != 0 { + query = query.Where("created_at >= ?", startTime) + } + if endTime != 0 { + query = query.Where("created_at <= ?", endTime) + } + + if err := query.Scan(&results).Error; err != nil { + common.SysError("failed to query token quota data: " + err.Error()) + return nil, err + } + return results, nil +} diff --git a/router/api-router.go b/router/api-router.go index da026ed92f4..9ad4f9a683b 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -297,7 +297,9 @@ func SetApiRouter(router *gin.Engine) { logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs) logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs) logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat) + logRoute.GET("/stat/tokens", middleware.AdminAuth(), controller.GetLogStatsByToken) logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat) + logRoute.GET("/self/stat/tokens", middleware.UserAuth(), controller.GetUserLogStatsByToken) logRoute.GET("/channel_affinity_usage_cache", middleware.AdminAuth(), controller.GetChannelAffinityUsageCacheStats) logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs) logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs) diff --git a/web/default/src/features/dashboard/api.ts b/web/default/src/features/dashboard/api.ts index 29cbba92b17..9a1bfcd298a 100644 --- a/web/default/src/features/dashboard/api.ts +++ b/web/default/src/features/dashboard/api.ts @@ -17,7 +17,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { api } from '@/lib/api' -import type { QuotaDataItem, UptimeGroupResult } from './types' +import type { + QuotaDataItem, + TokenQuotaDataItem, + UptimeGroupResult, +} from './types' // ============================================================================ // Dashboard APIs @@ -68,3 +72,20 @@ export async function getUptimeStatus() { ) return res.data } + +export async function getTokenQuotaData( + params: { + start_timestamp: number + end_timestamp: number + }, + isAdmin = false +) { + const endpoint = isAdmin + ? '/api/log/stat/tokens' + : '/api/log/self/stat/tokens' + const res = await api.get<{ success: boolean; data: TokenQuotaDataItem[] }>( + endpoint, + { params } + ) + return res.data +} diff --git a/web/default/src/features/dashboard/components/keys/key-charts.tsx b/web/default/src/features/dashboard/components/keys/key-charts.tsx new file mode 100644 index 00000000000..0b67adb5dcf --- /dev/null +++ b/web/default/src/features/dashboard/components/keys/key-charts.tsx @@ -0,0 +1,260 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { VChart } from '@visactor/react-vchart' +import { KeyRound, Loader2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { useAuthStore } from '@/stores/auth-store' +import { ROLE } from '@/lib/roles' +import { getRollingDateRange, type TimeGranularity } from '@/lib/time' +import { VCHART_OPTION } from '@/lib/vchart' +import { useThemeCustomization } from '@/context/theme-customization-provider' +import { useTheme } from '@/context/theme-provider' +import { Skeleton } from '@/components/ui/skeleton' +import { getTokenQuotaData } from '@/features/dashboard/api' +import { + TIME_GRANULARITY_OPTIONS, + TIME_RANGE_PRESETS, +} from '@/features/dashboard/constants' +import { + getDefaultDays, + getSavedGranularity, + processTokenChartData, + saveGranularity, +} from '@/features/dashboard/lib' +import type { ProcessedTokenChartData } from '@/features/dashboard/types' + +let themeManagerPromise: Promise< + (typeof import('@visactor/vchart'))['ThemeManager'] +> | null = null + +const KEY_CHARTS: { + value: string + labelKey: string + specKey: keyof ProcessedTokenChartData +}[] = [ + { + value: 'rank', + labelKey: 'API Key Consumption Ranking', + specKey: 'spec_token_rank', + }, + { + value: 'trend', + labelKey: 'API Key Consumption Trend', + specKey: 'spec_token_trend', + }, +] + +const TOP_KEY_LIMIT_OPTIONS = [5, 10, 20] + +export function KeyCharts() { + const { t } = useTranslation() + const { resolvedTheme } = useTheme() + const { customization } = useThemeCustomization() + const [themeReady, setThemeReady] = useState(false) + const themeManagerRef = useRef< + (typeof import('@visactor/vchart'))['ThemeManager'] | null + >(null) + + const userRole = useAuthStore((state) => state.auth.user?.role) + const isAdmin = Boolean(userRole && userRole >= ROLE.ADMIN) + + const [timeGranularity, setTimeGranularity] = useState(() => + getSavedGranularity() + ) + const [selectedRange, setSelectedRange] = useState(() => + getDefaultDays(timeGranularity) + ) + const [topKeyLimit, setTopKeyLimit] = useState(10) + const [timeRange, setTimeRange] = useState(() => { + const days = getDefaultDays(timeGranularity) + const { start, end } = getRollingDateRange(days) + return { + start_timestamp: Math.floor(start.getTime() / 1000), + end_timestamp: Math.floor(end.getTime() / 1000), + } + }) + + const handleRangeChange = useCallback((days: number) => { + setSelectedRange(days) + const { start, end } = getRollingDateRange(days) + setTimeRange({ + start_timestamp: Math.floor(start.getTime() / 1000), + end_timestamp: Math.floor(end.getTime() / 1000), + }) + }, []) + + const handleGranularityChange = useCallback( + (granularity: TimeGranularity) => { + setTimeGranularity(granularity) + saveGranularity(granularity) + const days = getDefaultDays(granularity) + if (days !== selectedRange) { + handleRangeChange(days) + } + }, + [selectedRange, handleRangeChange] + ) + + useEffect(() => { + const updateTheme = async () => { + setThemeReady(false) + if (!themeManagerPromise) { + themeManagerPromise = import('@visactor/vchart').then( + (m) => m.ThemeManager + ) + } + const ThemeManager = await themeManagerPromise + themeManagerRef.current = ThemeManager + ThemeManager.setCurrentTheme(resolvedTheme === 'dark' ? 'dark' : 'light') + setThemeReady(true) + } + void updateTheme() + }, [resolvedTheme]) + + const { data: tokenData, isLoading } = useQuery({ + queryKey: ['dashboard', 'token-quota', timeRange, isAdmin], + queryFn: () => getTokenQuotaData(timeRange, isAdmin), + select: (res) => (res.success ? res.data : []), + staleTime: 60_000, + }) + + const chartData = useMemo( + () => + processTokenChartData( + isLoading ? [] : (tokenData ?? []), + timeGranularity, + t, + topKeyLimit, + customization.preset + ), + [ + tokenData, + isLoading, + timeGranularity, + t, + topKeyLimit, + customization.preset, + ] + ) + + return ( +
+
+
+ {TIME_RANGE_PRESETS.map((preset) => ( + + ))} +
+ +
+ {TIME_GRANULARITY_OPTIONS.map((opt) => ( + + ))} +
+ +
+ + {t('Top Keys')} + + {TOP_KEY_LIMIT_OPTIONS.map((limit) => ( + + ))} +
+ + {isLoading && ( + + )} +
+ +
+ {KEY_CHARTS.map((chart) => { + const spec = chartData[chart.specKey] + + return ( +
+
+ +
{t(chart.labelKey)}
+
+ +
+ {isLoading ? ( + + ) : ( + themeReady && + spec && ( + + ) + )} +
+
+ ) + })} +
+
+ ) +} diff --git a/web/default/src/features/dashboard/index.tsx b/web/default/src/features/dashboard/index.tsx index 399221b0239..1d590f17ef2 100644 --- a/web/default/src/features/dashboard/index.tsx +++ b/web/default/src/features/dashboard/index.tsx @@ -77,6 +77,12 @@ const LazyUserCharts = lazy(() => })) ) +const LazyKeyCharts = lazy(() => + import('./components/keys/key-charts').then((m) => ({ + default: m.KeyCharts, + })) +) + function LogStatCardsFallback() { return (
@@ -146,6 +152,10 @@ const SECTION_META: Record< titleKey: 'User Analytics', descriptionKey: 'View user consumption statistics and charts', }, + keys: { + titleKey: 'API Key Analytics', + descriptionKey: 'View API key consumption statistics and charts', + }, } export function Dashboard() { @@ -307,6 +317,13 @@ export function Dashboard() { )} + {activeSection === 'keys' && ( + + }> + + + + )}
diff --git a/web/default/src/features/dashboard/lib/charts.ts b/web/default/src/features/dashboard/lib/charts.ts index 51b33ef76aa..2deb3368486 100644 --- a/web/default/src/features/dashboard/lib/charts.ts +++ b/web/default/src/features/dashboard/lib/charts.ts @@ -23,7 +23,9 @@ import { MAX_CHART_TREND_POINTS } from '@/features/dashboard/constants' import type { QuotaDataItem, ProcessedChartData, + ProcessedTokenChartData, ProcessedUserChartData, + TokenQuotaDataItem, } from '@/features/dashboard/types' type TFunction = (key: string) => string @@ -992,3 +994,259 @@ export function processUserChartData( }, } } + +export function processTokenChartData( + data: TokenQuotaDataItem[], + timeGranularity: TimeGranularity = 'day', + t?: TFunction, + limit = 10, + themeKey?: string +): ProcessedTokenChartData { + const tt: TFunction = t ?? ((x) => x) + const { config } = getCurrencyDisplay() + const quotaPerUnit = config.quotaPerUnit + const themeKeyColors = getThemeChartColors(themeKey) + const keyColorRange = + themeKeyColors.length > 0 + ? Array.from( + { length: Math.max(limit, themeKeyColors.length) }, + (_, index) => themeKeyColors[index % themeKeyColors.length] + ) + : USER_COLOR_FALLBACKS + + const formatVal = (raw: number) => renderQuotaCompat(raw, 2) + + const emptyResult: ProcessedTokenChartData = { + spec_token_rank: { + type: 'bar', + data: [{ id: 'tokenRankData', values: [] }], + xField: 'rawQuota', + yField: 'Key', + seriesField: 'Key', + direction: 'horizontal', + title: { + visible: true, + text: tt('API Key Consumption Ranking'), + subtext: tt('No data available'), + }, + legends: { visible: false }, + color: { type: 'ordinal', range: keyColorRange }, + background: { fill: 'transparent' }, + }, + spec_token_trend: { + type: 'area', + data: [{ id: 'tokenTrendData', values: [] }], + xField: 'Time', + yField: 'rawQuota', + seriesField: 'Key', + title: { + visible: true, + text: tt('API Key Consumption Trend'), + subtext: tt('No data available'), + }, + legends: { visible: true, selectMode: 'single' }, + color: { type: 'ordinal', range: keyColorRange }, + point: { visible: false }, + background: { fill: 'transparent' }, + }, + } + + if (!data || data.length === 0) return emptyResult + + const keyQuotaTotal = new Map() + data.forEach((item) => { + const keyName = item.token_name || `key#${item.token_id ?? 'unknown'}` + const prev = keyQuotaTotal.get(keyName) || 0 + keyQuotaTotal.set(keyName, prev + (Number(item.quota) || 0)) + }) + + const sorted = Array.from(keyQuotaTotal.entries()).sort((a, b) => b[1] - a[1]) + const topKeys = sorted.slice(0, limit).map(([key]) => key) + const topKeySet = new Set(topKeys) + const totalQuota = sorted.slice(0, limit).reduce((sum, [, q]) => sum + q, 0) + + const rankValues = sorted.slice(0, limit).map(([keyName, quota]) => ({ + Key: keyName, + rawQuota: quota, + Usage: Number((quota / quotaPerUnit).toFixed(4)), + })) + + const keyColorMap = topKeys.reduce>((acc, key, i) => { + acc[key] = keyColorRange[i % keyColorRange.length] + return acc + }, {}) + + const timeKeyMap = new Map>() + const allTimePoints = new Set() + + data.forEach((item) => { + const ts = Number(item.created_at) + const timeKey = formatChartTime(ts, timeGranularity) + allTimePoints.add(timeKey) + const keyName = item.token_name || `key#${item.token_id ?? 'unknown'}` + if (!topKeySet.has(keyName)) return + if (!timeKeyMap.has(timeKey)) timeKeyMap.set(timeKey, new Map()) + const map = timeKeyMap.get(timeKey)! + map.set(keyName, (map.get(keyName) || 0) + (Number(item.quota) || 0)) + }) + + const sortedTimePoints = Array.from(allTimePoints).sort() + const trendValues: Array<{ + Time: string + Key: string + rawQuota: number + Usage: number + }> = [] + + sortedTimePoints.forEach((time) => { + topKeys.forEach((key) => { + const quota = timeKeyMap.get(time)?.get(key) || 0 + trendValues.push({ + Time: time, + Key: key, + rawQuota: quota, + Usage: Number((quota / quotaPerUnit).toFixed(4)), + }) + }) + }) + + return { + spec_token_rank: { + type: 'bar', + data: [{ id: 'tokenRankData', values: rankValues }], + xField: 'rawQuota', + yField: 'Key', + seriesField: 'Key', + direction: 'horizontal', + title: { + visible: true, + text: tt('API Key Consumption Ranking'), + subtext: `${tt('Total:')} ${formatVal(totalQuota)}`, + }, + legends: { visible: false }, + bar: { + state: { hover: { stroke: '#000', lineWidth: 1 } }, + }, + label: { + visible: true, + position: 'outside', + formatMethod: (value: number) => formatVal(value), + style: { fontSize: 11 }, + }, + axes: [ + { orient: 'left', type: 'band' }, + { orient: 'bottom', type: 'linear', visible: false }, + ], + tooltip: { + mark: { + content: [ + { + key: (datum: Record) => datum?.Key, + value: (datum: Record) => + formatVal(Number(datum?.rawQuota) || 0), + }, + ], + updateContent: ( + array: Array<{ + key: string + value: string | number + datum?: Record + }> + ) => { + for (let i = 0; i < array.length; i++) { + const rawQuota = array[i].datum?.rawQuota + const value = + rawQuota === undefined ? array[i].value : Number(rawQuota) + array[i].value = formatVal(Number(value) || 0) + } + return array + }, + }, + }, + color: { specified: keyColorMap }, + background: { fill: 'transparent' }, + animation: true, + }, + spec_token_trend: { + type: 'area', + data: [{ id: 'tokenTrendData', values: trendValues }], + xField: 'Time', + yField: 'rawQuota', + seriesField: 'Key', + stack: false, + title: { + visible: true, + text: tt('API Key Consumption Trend'), + subtext: `${tt('Total:')} ${formatVal(totalQuota)}`, + }, + legends: { visible: true, selectMode: 'single' }, + axes: [ + { orient: 'bottom', type: 'band' }, + { + orient: 'left', + type: 'linear', + label: { + formatMethod: (value: number) => formatVal(value), + }, + }, + ], + tooltip: { + mark: { + content: [ + { + key: (datum: Record) => datum?.Key, + value: (datum: Record) => + formatVal(Number(datum?.rawQuota) || 0), + }, + ], + }, + dimension: { + content: [ + { + key: (datum: Record) => datum?.Key, + value: (datum: Record) => + Number(datum?.rawQuota) || 0, + }, + ], + updateContent: ( + array: Array<{ + key: string + value: string | number + }> + ) => { + array.sort( + (a, b) => (Number(b.value) || 0) - (Number(a.value) || 0) + ) + let sum = 0 + for (let i = 0; i < array.length; i++) { + const value = Number(array[i].value) || 0 + sum += value + array[i].value = formatVal(value) + } + array.unshift({ + key: tt('Total:'), + value: formatVal(sum), + }) + return array + }, + }, + }, + area: { + style: { + fillOpacity: 0.15, + curveType: 'monotone', + }, + }, + line: { + style: { + lineWidth: 2, + curveType: 'monotone', + }, + }, + point: { visible: false }, + color: { specified: keyColorMap }, + background: { fill: 'transparent' }, + animation: true, + }, + } +} diff --git a/web/default/src/features/dashboard/lib/index.ts b/web/default/src/features/dashboard/lib/index.ts index c0a65299365..5fc2d8abbe4 100644 --- a/web/default/src/features/dashboard/lib/index.ts +++ b/web/default/src/features/dashboard/lib/index.ts @@ -32,6 +32,10 @@ export { openExternalSpeedTest, getDefaultPingStatus, } from './api-info' -export { processChartData, processUserChartData } from './charts' +export { + processChartData, + processUserChartData, + processTokenChartData, +} from './charts' export { safeDivide, calculateDashboardStats } from './stats' export { getPreviewText } from './text' diff --git a/web/default/src/features/dashboard/section-registry.tsx b/web/default/src/features/dashboard/section-registry.tsx index d258dde66ff..07574aeff3b 100644 --- a/web/default/src/features/dashboard/section-registry.tsx +++ b/web/default/src/features/dashboard/section-registry.tsx @@ -42,6 +42,12 @@ const DASHBOARD_SECTIONS = [ adminOnly: true, build: () => null, }, + { + id: 'keys', + titleKey: 'API Key Analytics', + descriptionKey: 'View API key consumption statistics and charts', + build: () => null, + }, ] as const export type DashboardSectionId = (typeof DASHBOARD_SECTIONS)[number]['id'] diff --git a/web/default/src/features/dashboard/types.ts b/web/default/src/features/dashboard/types.ts index ad002e3c304..ef82f52c569 100644 --- a/web/default/src/features/dashboard/types.ts +++ b/web/default/src/features/dashboard/types.ts @@ -33,6 +33,15 @@ export interface QuotaDataItem { quota?: number } +export interface TokenQuotaDataItem { + token_id?: number + token_name?: string + created_at: number + count?: number + quota?: number + token_used?: number +} + // ============================================================================ // Uptime Monitoring Types // ============================================================================ @@ -112,6 +121,11 @@ export interface ProcessedUserChartData { spec_user_trend: VChartSpec } +export interface ProcessedTokenChartData { + spec_token_rank: VChartSpec + spec_token_trend: VChartSpec +} + // ============================================================================ // Announcement Types // ============================================================================ diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 4f8ae557694..0659551fb9f 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -339,6 +339,9 @@ "API Key (Production)": "API Key (Production)", "API Key (Sandbox)": "API Key (Sandbox)", "API Key *": "API Key *", + "API Key Analytics": "API Key Analytics", + "API Key Consumption Ranking": "API Key Consumption Ranking", + "API Key Consumption Trend": "API Key Consumption Trend", "API Key created successfully": "API Key created successfully", "API Key deleted successfully": "API Key deleted successfully", "API Key disabled successfully": "API Key disabled successfully", @@ -4025,6 +4028,7 @@ "Top apps": "Top apps", "Top Apps": "Top Apps", "Top integrations using this model": "Top integrations using this model", + "Top Keys": "Top Keys", "Top model": "Top model", "Top models": "Top models", "Top Models": "Top Models", @@ -4317,6 +4321,7 @@ "View and manage your API usage logs": "View and manage your API usage logs", "View and manage your drawing logs": "View and manage your drawing logs", "View and manage your task logs": "View and manage your task logs", + "View API key consumption statistics and charts": "View API key consumption statistics and charts", "View dashboard overview and statistics": "View dashboard overview and statistics", "View detailed information about this user including balance, usage statistics, and invitation details.": "View detailed information about this user including balance, usage statistics, and invitation details.", "View details": "View details", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 850f83621e4..a9f8fff2050 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -339,6 +339,9 @@ "API Key (Production)": "Clé API (Production)", "API Key (Sandbox)": "Clé API (Sandbox)", "API Key *": "Clé API *", + "API Key Analytics": "Analyse des clés API", + "API Key Consumption Ranking": "Classement de consommation des clés API", + "API Key Consumption Trend": "Tendance de consommation des clés API", "API Key created successfully": "Clé API créée avec succès", "API Key deleted successfully": "Clé API supprimée avec succès", "API Key disabled successfully": "Clé API désactivée avec succès", @@ -4025,6 +4028,7 @@ "Top apps": "Top applications", "Top Apps": "Meilleures applications", "Top integrations using this model": "Principales intégrations utilisant ce modèle", + "Top Keys": "Meilleures clés", "Top model": "Modèle principal", "Top models": "Top modèles", "Top Models": "Top Modèles", @@ -4317,6 +4321,7 @@ "View and manage your API usage logs": "Afficher et gérer vos journaux d'utilisation de l'API", "View and manage your drawing logs": "Afficher et gérer vos journaux de dessin", "View and manage your task logs": "Afficher et gérer vos journaux de tâches", + "View API key consumption statistics and charts": "Voir les statistiques de consommation des clés API", "View dashboard overview and statistics": "Afficher le tableau de bord, aperçu et statistiques", "View detailed information about this user including balance, usage statistics, and invitation details.": "Afficher des informations détaillées sur cet utilisateur, y compris le solde, les statistiques d'utilisation et les détails d'invitation.", "View details": "Voir les détails", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index cb5743b0fab..3e6b61172d2 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -339,6 +339,9 @@ "API Key (Production)": "APIキー(本番)", "API Key (Sandbox)": "APIキー(サンドボックス)", "API Key *": "APIキー *", + "API Key Analytics": "APIキー分析", + "API Key Consumption Ranking": "APIキー消費ランキング", + "API Key Consumption Trend": "APIキー消費トレンド", "API Key created successfully": "APIキーが正常に作成されました", "API Key deleted successfully": "APIキーが正常に削除されました", "API Key disabled successfully": "APIキーが正常に無効化されました", @@ -4025,6 +4028,7 @@ "Top apps": "人気アプリ", "Top Apps": "人気アプリ", "Top integrations using this model": "このモデルを利用する主要な連携", + "Top Keys": "トップキー", "Top model": "トップモデル", "Top models": "人気モデル", "Top Models": "トップモデル", @@ -4317,6 +4321,7 @@ "View and manage your API usage logs": "API使用ログの表示と管理", "View and manage your drawing logs": "描画ログの表示と管理", "View and manage your task logs": "タスクログの表示と管理", + "View API key consumption statistics and charts": "APIキーの消費統計とチャートを表示", "View dashboard overview and statistics": "ダッシュボードの概要と統計を表示", "View detailed information about this user including balance, usage statistics, and invitation details.": "残高、使用統計、招待の詳細など、このユーザーに関する詳細情報を表示します。", "View details": "詳細を表示", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 91b6d6da780..41e03485009 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -339,6 +339,9 @@ "API Key (Production)": "API-ключ (Продакшн)", "API Key (Sandbox)": "API-ключ (Песочница)", "API Key *": "Ключ API *", + "API Key Analytics": "Аналитика API-ключей", + "API Key Consumption Ranking": "Рейтинг потребления API-ключей", + "API Key Consumption Trend": "Тренд потребления API-ключей", "API Key created successfully": "API ключ успешно создан", "API Key deleted successfully": "API ключ успешно удален", "API Key disabled successfully": "API ключ успешно отключен", @@ -4025,6 +4028,7 @@ "Top apps": "Топ приложений", "Top Apps": "Топ приложений", "Top integrations using this model": "Основные интеграции, использующие эту модель", + "Top Keys": "Топ ключей", "Top model": "Лидирующая модель", "Top models": "Топ моделей", "Top Models": "Лучшие модели", @@ -4317,6 +4321,7 @@ "View and manage your API usage logs": "Просмотр и управление журналами использования API", "View and manage your drawing logs": "Просмотр и управление журналами рисования", "View and manage your task logs": "Просмотр и управление журналами задач", + "View API key consumption statistics and charts": "Просмотр статистики потребления API-ключей", "View dashboard overview and statistics": "Просмотр обзора и статистики панели управления", "View detailed information about this user including balance, usage statistics, and invitation details.": "Просмотр подробной информации об этом пользователе, включая баланс, статистику использования и данные приглашения.", "View details": "Просмотреть детали", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index ed2764d292c..9b76a8632ea 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -339,6 +339,9 @@ "API Key (Production)": "API Key (Sản xuất)", "API Key (Sandbox)": "Khóa API (Sandbox)", "API Key *": "Khóa API *", + "API Key Analytics": "Phân tích API Key", + "API Key Consumption Ranking": "Xếp hạng tiêu thụ API Key", + "API Key Consumption Trend": "Xu hướng tiêu thụ API Key", "API Key created successfully": "Tạo khóa API thành công", "API Key deleted successfully": "Xóa khóa API thành công", "API Key disabled successfully": "Vô hiệu hóa khóa API thành công", @@ -4025,6 +4028,7 @@ "Top apps": "Ứng dụng hàng đầu", "Top Apps": "Ứng dụng hàng đầu", "Top integrations using this model": "Tích hợp hàng đầu sử dụng mô hình này", + "Top Keys": "Top Keys", "Top model": "Mô hình dẫn đầu", "Top models": "Mô hình hàng đầu", "Top Models": "Người mẫu hàng đầu", @@ -4317,6 +4321,7 @@ "View and manage your API usage logs": "Xem và quản lý nhật ký sử dụng API của bạn", "View and manage your drawing logs": "Xem và quản lý nhật ký vẽ của bạn", "View and manage your task logs": "Xem và quản lý nhật ký tác vụ của bạn", + "View API key consumption statistics and charts": "Xem thống kê tiêu thụ API key và biểu đồ", "View dashboard overview and statistics": "Xem tổng quan và thống kê bảng điều khiển", "View detailed information about this user including balance, usage statistics, and invitation details.": "Xem thông tin chi tiết về người dùng này bao gồm số dư, thống kê sử dụng và chi tiết lời mời.", "View details": "Xem chi tiết", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index fbfd6d733c8..1076f5e6a1f 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -339,6 +339,9 @@ "API Key (Production)": "API 密钥(生产)", "API Key (Sandbox)": "API 密钥(沙盒)", "API Key *": "API 密钥 *", + "API Key Analytics": "API Key 统计", + "API Key Consumption Ranking": "API Key 消耗排行", + "API Key Consumption Trend": "API Key 消耗趋势", "API Key created successfully": "API 密钥创建成功", "API Key deleted successfully": "API 密钥删除成功", "API Key disabled successfully": "API 密钥禁用成功", @@ -4025,6 +4028,7 @@ "Top apps": "热门应用", "Top Apps": "热门应用", "Top integrations using this model": "使用此模型的主要集成", + "Top Keys": "热门 Key", "Top model": "领头模型", "Top models": "热门模型", "Top Models": "热门模型", @@ -4317,6 +4321,7 @@ "View and manage your API usage logs": "查看和管理您的 API 使用日志", "View and manage your drawing logs": "查看和管理您的绘图日志", "View and manage your task logs": "查看和管理您的任务日志", + "View API key consumption statistics and charts": "查看 API Key 消耗统计和图表", "View dashboard overview and statistics": "查看仪表板概览和统计信息", "View detailed information about this user including balance, usage statistics, and invitation details.": "查看此用户的详细信息,包括余额、使用统计和邀请详情。", "View details": "查看详情", From 119f0fe9c830c1ebc441ab805be3859d8afb7e00 Mon Sep 17 00:00:00 2001 From: SHLE1 Date: Tue, 19 May 2026 10:51:59 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=E8=8F=9C=E5=8D=95=E7=9A=84=E9=97=B4=E8=B7=9D=E9=98=B2?= =?UTF-8?q?=E6=AD=A2=E7=B2=98=E8=BF=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/default/src/components/ui/sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/default/src/components/ui/sidebar.tsx b/web/default/src/components/ui/sidebar.tsx index 5c16eece5d8..129443fb238 100644 --- a/web/default/src/components/ui/sidebar.tsx +++ b/web/default/src/components/ui/sidebar.tsx @@ -475,7 +475,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
    ) From 83eb895855bade208fe4c91754732e01f748a6a2 Mon Sep 17 00:00:00 2001 From: SHLE1 Date: Tue, 19 May 2026 13:42:37 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E4=BB=85=E5=9C=A8=E8=87=AA?= =?UTF-8?q?=E7=94=A8=E6=A8=A1=E5=BC=8F=E4=B8=8B=E6=98=BE=E7=A4=BA=20API=20?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E7=BB=9F=E8=AE=A1=E5=8A=9F=E8=83=BD=E5=B9=B6?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/constants.go | 1 + controller/misc.go | 1 + model/option.go | 3 + web/default/src/features/auth/types.ts | 2 + .../dashboard/components/keys/key-charts.tsx | 260 ++++++++++-------- .../components/models/model-charts.tsx | 3 +- web/default/src/features/dashboard/index.tsx | 12 +- .../content/dashboard-section.tsx | 31 +++ .../system-settings/content/index.tsx | 1 + .../content/section-registry.tsx | 1 + .../hooks/use-update-option.ts | 1 + .../src/features/system-settings/types.ts | 1 + web/default/src/i18n/locales/en.json | 2 + web/default/src/i18n/locales/fr.json | 4 +- web/default/src/i18n/locales/ja.json | 4 +- web/default/src/i18n/locales/ru.json | 4 +- web/default/src/i18n/locales/vi.json | 4 +- web/default/src/i18n/locales/zh.json | 14 +- 18 files changed, 223 insertions(+), 126 deletions(-) diff --git a/common/constants.go b/common/constants.go index c7d5637c8e9..126e69a549f 100644 --- a/common/constants.go +++ b/common/constants.go @@ -68,6 +68,7 @@ var TaskEnabled = true var DataExportEnabled = true var DataExportInterval = 5 // unit: minute var DataExportDefaultTime = "hour" // unit: minute +var ApiKeyStatsEnabled = false var DefaultCollapseSidebar = false // default value of collapse sidebar // Any options with "Secret", "Token" in its key won't be return by GetOptions diff --git a/controller/misc.go b/controller/misc.go index 29b3a5c5e18..880786071ec 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -87,6 +87,7 @@ func GetStatus(c *gin.Context) { "chats": setting.Chats, "demo_site_enabled": operation_setting.DemoSiteEnabled, "self_use_mode_enabled": operation_setting.SelfUseModeEnabled, + "api_key_stats_enabled": common.ApiKeyStatsEnabled && operation_setting.SelfUseModeEnabled, "default_use_auto_group": setting.DefaultUseAutoGroup, "usd_exchange_rate": operation_setting.USDExchangeRate, diff --git a/model/option.go b/model/option.go index e0a3048d34f..43b9d10c6c3 100644 --- a/model/option.go +++ b/model/option.go @@ -52,6 +52,7 @@ func InitOptionMap() { common.OptionMap["DrawingEnabled"] = strconv.FormatBool(common.DrawingEnabled) common.OptionMap["TaskEnabled"] = strconv.FormatBool(common.TaskEnabled) common.OptionMap["DataExportEnabled"] = strconv.FormatBool(common.DataExportEnabled) + common.OptionMap["ApiKeyStatsEnabled"] = strconv.FormatBool(common.ApiKeyStatsEnabled) common.OptionMap["ChannelDisableThreshold"] = strconv.FormatFloat(common.ChannelDisableThreshold, 'f', -1, 64) common.OptionMap["EmailDomainRestrictionEnabled"] = strconv.FormatBool(common.EmailDomainRestrictionEnabled) common.OptionMap["EmailAliasRestrictionEnabled"] = strconv.FormatBool(common.EmailAliasRestrictionEnabled) @@ -295,6 +296,8 @@ func updateOptionMap(key string, value string) (err error) { common.TaskEnabled = boolValue case "DataExportEnabled": common.DataExportEnabled = boolValue + case "ApiKeyStatsEnabled": + common.ApiKeyStatsEnabled = boolValue case "DefaultCollapseSidebar": common.DefaultCollapseSidebar = boolValue case "MjNotifyEnabled": diff --git a/web/default/src/features/auth/types.ts b/web/default/src/features/auth/types.ts index b429e20c250..60870091892 100644 --- a/web/default/src/features/auth/types.ts +++ b/web/default/src/features/auth/types.ts @@ -114,6 +114,7 @@ export interface SystemStatus { turnstile_site_key?: string email_verification?: boolean self_use_mode_enabled?: boolean + api_key_stats_enabled?: boolean display_in_currency?: boolean display_token_stat_enabled?: boolean quota_per_unit?: number @@ -156,6 +157,7 @@ export interface SystemStatus { turnstile_site_key?: string email_verification?: boolean self_use_mode_enabled?: boolean + api_key_stats_enabled?: boolean display_in_currency?: boolean display_token_stat_enabled?: boolean quota_per_unit?: number diff --git a/web/default/src/features/dashboard/components/keys/key-charts.tsx b/web/default/src/features/dashboard/components/keys/key-charts.tsx index 0b67adb5dcf..6ae49a82fc4 100644 --- a/web/default/src/features/dashboard/components/keys/key-charts.tsx +++ b/web/default/src/features/dashboard/components/keys/key-charts.tsx @@ -16,17 +16,14 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { VChart } from '@visactor/react-vchart' -import { KeyRound, Loader2 } from 'lucide-react' +import { Hash, Coins, Layers, Gauge, Zap, Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { getRollingDateRange, type TimeGranularity } from '@/lib/time' +import { formatNumber, formatQuota } from '@/lib/format' import { useAuthStore } from '@/stores/auth-store' import { ROLE } from '@/lib/roles' -import { getRollingDateRange, type TimeGranularity } from '@/lib/time' -import { VCHART_OPTION } from '@/lib/vchart' -import { useThemeCustomization } from '@/context/theme-customization-provider' -import { useTheme } from '@/context/theme-provider' import { Skeleton } from '@/components/ui/skeleton' import { getTokenQuotaData } from '@/features/dashboard/api' import { @@ -36,42 +33,29 @@ import { import { getDefaultDays, getSavedGranularity, - processTokenChartData, saveGranularity, + calculateDashboardStats, + safeDivide, } from '@/features/dashboard/lib' -import type { ProcessedTokenChartData } from '@/features/dashboard/types' - -let themeManagerPromise: Promise< - (typeof import('@visactor/vchart'))['ThemeManager'] -> | null = null +import type { TokenQuotaDataItem, QuotaDataItem } from '@/features/dashboard/types' +import { ConsumptionDistributionChart } from '../models/consumption-distribution-chart' +import { ModelCharts } from '../models/model-charts' -const KEY_CHARTS: { - value: string - labelKey: string - specKey: keyof ProcessedTokenChartData -}[] = [ - { - value: 'rank', - labelKey: 'API Key Consumption Ranking', - specKey: 'spec_token_rank', - }, - { - value: 'trend', - labelKey: 'API Key Consumption Trend', - specKey: 'spec_token_trend', - }, -] +/** Map TokenQuotaDataItem → QuotaDataItem so existing chart functions can be reused */ +function mapToQuotaData(items: TokenQuotaDataItem[]): QuotaDataItem[] { + return items.map((item) => ({ + model_name: item.token_name || `key-${item.token_id ?? 'unknown'}`, + created_at: item.created_at, + count: item.count, + quota: item.quota, + token_used: item.token_used, + })) +} -const TOP_KEY_LIMIT_OPTIONS = [5, 10, 20] +const TOP_KEY_LIMIT_OPTIONS = [5, 10, 20, 50] export function KeyCharts() { const { t } = useTranslation() - const { resolvedTheme } = useTheme() - const { customization } = useThemeCustomization() - const [themeReady, setThemeReady] = useState(false) - const themeManagerRef = useRef< - (typeof import('@visactor/vchart'))['ThemeManager'] | null - >(null) const userRole = useAuthStore((state) => state.auth.user?.role) const isAdmin = Boolean(userRole && userRole >= ROLE.ADMIN) @@ -102,62 +86,96 @@ export function KeyCharts() { }, []) const handleGranularityChange = useCallback( - (granularity: TimeGranularity) => { - setTimeGranularity(granularity) - saveGranularity(granularity) - const days = getDefaultDays(granularity) - if (days !== selectedRange) { - handleRangeChange(days) - } + (g: TimeGranularity) => { + setTimeGranularity(g) + saveGranularity(g) + const days = getDefaultDays(g) + if (days !== selectedRange) handleRangeChange(days) }, [selectedRange, handleRangeChange] ) - useEffect(() => { - const updateTheme = async () => { - setThemeReady(false) - if (!themeManagerPromise) { - themeManagerPromise = import('@visactor/vchart').then( - (m) => m.ThemeManager - ) - } - const ThemeManager = await themeManagerPromise - themeManagerRef.current = ThemeManager - ThemeManager.setCurrentTheme(resolvedTheme === 'dark' ? 'dark' : 'light') - setThemeReady(true) - } - void updateTheme() - }, [resolvedTheme]) - - const { data: tokenData, isLoading } = useQuery({ + const { data: rawData, isLoading } = useQuery({ queryKey: ['dashboard', 'token-quota', timeRange, isAdmin], queryFn: () => getTokenQuotaData(timeRange, isAdmin), select: (res) => (res.success ? res.data : []), staleTime: 60_000, }) - const chartData = useMemo( - () => - processTokenChartData( - isLoading ? [] : (tokenData ?? []), - timeGranularity, - t, - topKeyLimit, - customization.preset - ), - [ - tokenData, - isLoading, - timeGranularity, - t, - topKeyLimit, - customization.preset, - ] + const tokenData: TokenQuotaDataItem[] = isLoading ? [] : (rawData ?? []) + const mappedData: QuotaDataItem[] = useMemo( + () => mapToQuotaData(tokenData), + [tokenData] ) + const topNKeySet = useMemo(() => { + const totals = new Map() + tokenData.forEach((item) => { + const key = item.token_name || `key-${item.token_id ?? 'unknown'}` + totals.set(key, (totals.get(key) ?? 0) + (item.quota ?? 0)) + }) + + return new Set( + Array.from(totals.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, topKeyLimit) + .map(([key]) => key) + ) + }, [tokenData, topKeyLimit]) + const filteredMappedData = useMemo( + () => mappedData.filter((item) => topNKeySet.has(item.model_name ?? '')), + [mappedData, topNKeySet] + ) + + // Aggregate stats for the stat cards row + const stats = useMemo(() => calculateDashboardStats(tokenData), [tokenData]) + const timeRangeMinutes = useMemo( + () => (timeRange.end_timestamp - timeRange.start_timestamp) / 60, + [timeRange] + ) + + const statCards = [ + { + key: 'count', + title: t('Total Count'), + desc: t('Statistical count'), + icon: Hash, + value: formatNumber(stats.totalCount), + }, + { + key: 'quota', + title: t('Total Quota'), + desc: t('Statistical quota'), + icon: Coins, + value: formatQuota(stats.totalQuota), + }, + { + key: 'tokens', + title: t('Total Tokens'), + desc: t('Statistical tokens'), + icon: Layers, + value: formatNumber(stats.totalTokens), + }, + { + key: 'avgRpm', + title: t('Average RPM'), + desc: t('Requests per minute'), + icon: Gauge, + value: formatNumber(safeDivide(stats.totalCount, timeRangeMinutes)), + }, + { + key: 'avgTpm', + title: t('Average TPM'), + desc: t('Tokens per minute'), + icon: Zap, + value: formatNumber(safeDivide(stats.totalTokens, timeRangeMinutes)), + }, + ] return ( -
    +
    + {/* Filter bar */}
    + {/* Time range presets */}
    {TIME_RANGE_PRESETS.map((preset) => (
    -
    - {KEY_CHARTS.map((chart) => { - const spec = chartData[chart.specKey] - - return ( -
    -
    - -
    {t(chart.labelKey)}
    -
    - -
    + {/* Stat cards */} +
    +
    + {statCards.map((card, idx) => { + const Icon = card.icon + return ( +
    +
    + +
    + {card.title} +
    +
    {isLoading ? ( - +
    + + +
    ) : ( - themeReady && - spec && ( - - ) + <> +
    + {card.value} +
    +
    + {card.desc} +
    + )}
    -
    - ) - })} + ) + })} +
    + + {/* Quota distribution over time (bar / area) */} + + + {/* Key analytics: trend / proportion / top */} +
    ) } diff --git a/web/default/src/features/dashboard/components/models/model-charts.tsx b/web/default/src/features/dashboard/components/models/model-charts.tsx index 59be21251f6..a163f361cdb 100644 --- a/web/default/src/features/dashboard/components/models/model-charts.tsx +++ b/web/default/src/features/dashboard/components/models/model-charts.tsx @@ -52,6 +52,7 @@ interface ModelChartsProps { loading?: boolean timeGranularity?: TimeGranularity defaultChartTab?: ModelAnalyticsChartTab + title?: string } export function ModelCharts(props: ModelChartsProps) { @@ -130,7 +131,7 @@ export function ModelCharts(props: ModelChartsProps) {
    - {t('Model Call Analytics')} + {props.title ?? t('Model Call Analytics')}
    {t('Total:')} {chartData.totalCountDisplay} diff --git a/web/default/src/features/dashboard/index.tsx b/web/default/src/features/dashboard/index.tsx index 1d590f17ef2..18f666f765b 100644 --- a/web/default/src/features/dashboard/index.tsx +++ b/web/default/src/features/dashboard/index.tsx @@ -24,6 +24,7 @@ import { ROLE } from '@/lib/roles' import { Skeleton } from '@/components/ui/skeleton' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { SectionPageLayout } from '@/components/layout' +import { useStatus } from '@/hooks/use-status' import { FadeIn } from '@/components/page-transition' import { ModelsChartPreferences } from './components/models/models-chart-preferences' import { ModelsFilter } from './components/models/models-filter-dialog' @@ -163,6 +164,7 @@ export function Dashboard() { const navigate = useNavigate() const params = route.useParams() const userRole = useAuthStore((state) => state.auth.user?.role) + const { status } = useStatus() const activeSection = (params.section ?? DASHBOARD_DEFAULT_SECTION) as DashboardSectionId @@ -201,12 +203,16 @@ export function Dashboard() { const meta = SECTION_META[activeSection] ?? SECTION_META.overview const isAdmin = Boolean(userRole && userRole >= ROLE.ADMIN) + const apiKeyStatsEnabled = status?.api_key_stats_enabled ?? false const visibleSections = useMemo( () => DASHBOARD_SECTION_IDS.filter( - (section) => section !== 'overview' && (section !== 'users' || isAdmin) + (section) => + section !== 'overview' && + (section !== 'users' || isAdmin) && + (section !== 'keys' || apiKeyStatsEnabled) ), - [isAdmin] + [isAdmin, apiKeyStatsEnabled] ) const handleSectionChange = useCallback( (section: string) => { @@ -317,7 +323,7 @@ export function Dashboard() { )} - {activeSection === 'keys' && ( + {activeSection === 'keys' && apiKeyStatsEnabled && ( }> diff --git a/web/default/src/features/system-settings/content/dashboard-section.tsx b/web/default/src/features/system-settings/content/dashboard-section.tsx index 0d80685d91d..75885434429 100644 --- a/web/default/src/features/system-settings/content/dashboard-section.tsx +++ b/web/default/src/features/system-settings/content/dashboard-section.tsx @@ -41,6 +41,7 @@ import { SelectValue, } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' +import { useStatus } from '@/hooks/use-status' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' @@ -48,6 +49,7 @@ const dataDashboardSchema = z.object({ DataExportEnabled: z.boolean(), DataExportInterval: z.number().int().min(1).max(1440), DataExportDefaultTime: z.enum(['hour', 'day', 'week']), + ApiKeyStatsEnabled: z.boolean(), }) type DataDashboardFormValues = z.infer @@ -64,7 +66,9 @@ const granularityOptions = [ export function DashboardSection({ defaultValues }: DashboardSectionProps) { const { t } = useTranslation() + const { status } = useStatus() const updateOption = useUpdateOption() + const isSelfUseMode = status?.self_use_mode_enabled ?? false const form = useForm({ resolver: zodResolver(dataDashboardSchema), @@ -115,6 +119,33 @@ export function DashboardSection({ defaultValues }: DashboardSectionProps) { )} /> + {isSelfUseMode && ( + ( + +
    + + {t('Enable API Key Statistics')} + + + {t( + 'Show API key consumption statistics tab in the dashboard. Only available in self-use mode.' + )} + +
    + + + +
    + )} + /> + )} +
    ), diff --git a/web/default/src/features/system-settings/hooks/use-update-option.ts b/web/default/src/features/system-settings/hooks/use-update-option.ts index f01bf5da8b5..b7885e6b324 100644 --- a/web/default/src/features/system-settings/hooks/use-update-option.ts +++ b/web/default/src/features/system-settings/hooks/use-update-option.ts @@ -33,6 +33,7 @@ const STATUS_RELATED_KEYS = [ 'USDExchangeRate', 'DisplayInCurrencyEnabled', 'DisplayTokenStatEnabled', + 'ApiKeyStatsEnabled', 'general_setting.quota_display_type', 'general_setting.custom_currency_symbol', 'general_setting.custom_currency_exchange_rate', diff --git a/web/default/src/features/system-settings/types.ts b/web/default/src/features/system-settings/types.ts index f6addabb709..f91e2140a4d 100644 --- a/web/default/src/features/system-settings/types.ts +++ b/web/default/src/features/system-settings/types.ts @@ -125,6 +125,7 @@ export type ContentSettings = { 'console_setting.faq_enabled': boolean 'console_setting.uptime_kuma_enabled': boolean DataExportEnabled: boolean + ApiKeyStatsEnabled: boolean DataExportDefaultTime: string DataExportInterval: number Chats: string diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 0659551fb9f..ce0aa4e0b0a 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -1371,6 +1371,7 @@ "Enable 2FA": "Enable 2FA", "Enable All": "Enable All", "Enable check-in feature": "Enable check-in feature", + "Enable API Key Statistics": "Enable API Key Statistics", "Enable Data Dashboard": "Enable Data Dashboard", "Enable demo mode with limited functionality": "Enable demo mode with limited functionality", "Enable Discord OAuth": "Enable Discord OAuth", @@ -3628,6 +3629,7 @@ "Show only bound providers": "Show only bound providers", "Show prices in currency instead of quota.": "Show prices in currency instead of quota.", "Show setup guide": "Show setup guide", + "Show API key consumption statistics tab in the dashboard. Only available in self-use mode.": "Show API key consumption statistics tab in the dashboard. Only available in self-use mode.", "Show token usage statistics in the UI": "Show token usage statistics in the UI", "Showcase core capabilities with demo credentials and limited access.": "Showcase core capabilities with demo credentials and limited access.", "Showing": "Showing", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index a9f8fff2050..254c0b684e8 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -1371,6 +1371,7 @@ "Enable 2FA": "Activer 2FA", "Enable All": "Tout activer", "Enable check-in feature": "Activer la fonction de connexion", + "Enable API Key Statistics": "Activer les statistiques des clés API", "Enable Data Dashboard": "Activer le tableau de bord des données", "Enable demo mode with limited functionality": "Activer le mode démo avec des fonctionnalités limitées", "Enable Discord OAuth": "Activer OAuth Discord", @@ -1779,7 +1780,7 @@ "footer.columns.related.links.oneApi": "One API", "footer.columns.related.title": "Projets liés", "footer.defaultCopyright": "Tous droits réservés.", - "footer.new\u0061pi.projectAttributionSuffix": "Tous droits réservés. Conçu et développé par les contributeurs du projet.", + "footer.newapi.projectAttributionSuffix": "Tous droits réservés. Conçu et développé par les contributeurs du projet.", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "Pour les canaux ajoutés après le 10 mai 2025, pas besoin de supprimer \".\" des noms de modèles lors du déploiement", "For private deployments, format: https://fastgpt.run/api/openapi": "Pour les déploiements privés, format : https://fastgpt.run/api/openapi", "Force a syntactically valid JSON response": "Imposer une réponse JSON syntaxiquement valide", @@ -3628,6 +3629,7 @@ "Show only bound providers": "Afficher uniquement les fournisseurs liés", "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 API key consumption statistics tab in the dashboard. Only available in self-use mode.": "Afficher l’onglet des statistiques de consommation des clés API dans le tableau de bord. Disponible uniquement en mode usage personnel.", "Show token usage statistics in the UI": "Afficher les statistiques d'utilisation des jetons dans l'interface utilisateur", "Showcase core capabilities with demo credentials and limited access.": "Présenter les fonctionnalités principales avec des identifiants de démonstration et un accès limité.", "Showing": "Affichage de", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 3e6b61172d2..dc7ed9852ec 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -1371,6 +1371,7 @@ "Enable 2FA": "2FA を有効にする", "Enable All": "すべて有効にする", "Enable check-in feature": "チェックイン機能を有効にする", + "Enable API Key Statistics": "APIキー統計を有効にする", "Enable Data Dashboard": "データダッシュボードを有効にする", "Enable demo mode with limited functionality": "機能が制限されたデモモードを有効にする", "Enable Discord OAuth": "Discord OAuthを有効にする", @@ -1779,7 +1780,7 @@ "footer.columns.related.links.oneApi": "1つのAPI", "footer.columns.related.title": "関連プロジェクト", "footer.defaultCopyright": "すべての権利を留保します。", - "footer.new\u0061pi.projectAttributionSuffix": "すべての権利を留保します。プロジェクトコントリビューターにより設計・開発されています。", + "footer.newapi.projectAttributionSuffix": "すべての権利を留保します。プロジェクトコントリビューターにより設計・開発されています。", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "2025 年 5 月 10 日以降に追加されたチャネルの場合、デプロイ時にモデル名から「.」を削除する必要はありません", "For private deployments, format: https://fastgpt.run/api/openapi": "プライベートデプロイメントの場合、形式: https://fastgpt.run/api/openapi", "Force a syntactically valid JSON response": "構文的に有効な JSON 応答を強制", @@ -3628,6 +3629,7 @@ "Show only bound providers": "バインド済みのプロバイダーのみ表示", "Show prices in currency instead of quota.": "クォータではなく通貨で価格を表示。", "Show setup guide": "セットアップガイドを表示", + "Show API key consumption statistics tab in the dashboard. Only available in self-use mode.": "ダッシュボードにAPIキー消費統計タブを表示します。自用モードでのみ利用できます。", "Show token usage statistics in the UI": "UIでトークン使用統計を表示", "Showcase core capabilities with demo credentials and limited access.": "デモ用の認証情報と制限付きアクセスでコア機能を紹介します。", "Showing": "表示", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 41e03485009..bff3967b591 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -1371,6 +1371,7 @@ "Enable 2FA": "Включить 2FA", "Enable All": "Включить все", "Enable check-in feature": "Включить функцию прибытия", + "Enable API Key Statistics": "Включить статистику API-ключей", "Enable Data Dashboard": "Включить панель данных", "Enable demo mode with limited functionality": "Включить демонстрационный режим с ограниченной функциональностью", "Enable Discord OAuth": "Включить Discord OAuth", @@ -1779,7 +1780,7 @@ "footer.columns.related.links.oneApi": "Один API", "footer.columns.related.title": "Связанные проекты", "footer.defaultCopyright": "Все права защищены.", - "footer.new\u0061pi.projectAttributionSuffix": "Все права защищены. Разработано участниками проекта.", + "footer.newapi.projectAttributionSuffix": "Все права защищены. Разработано участниками проекта.", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "Для каналов, добавленных после 10 мая 2025 г., не нужно удалять \".\" из имён моделей при развёртывании", "For private deployments, format: https://fastgpt.run/api/openapi": "Для частных развертываний, формат: https://fastgpt.run/api/openapi", "Force a syntactically valid JSON response": "Принудительно возвращать синтаксически корректный JSON", @@ -3628,6 +3629,7 @@ "Show only bound providers": "Показать только привязанных провайдеров", "Show prices in currency instead of quota.": "Показывать цены в валюте вместо квоты.", "Show setup guide": "Показать руководство по настройке", + "Show API key consumption statistics tab in the dashboard. Only available in self-use mode.": "Показывать вкладку статистики потребления API-ключей на панели. Доступно только в режиме личного использования.", "Show token usage statistics in the UI": "Показывать статистику использования токенов в пользовательском интерфейсе", "Showcase core capabilities with demo credentials and limited access.": "Демонстрация основных возможностей с демо-учётными данными и ограниченным доступом.", "Showing": "Отображать", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 9b76a8632ea..193458d20bf 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -1371,6 +1371,7 @@ "Enable 2FA": "Bật 2FA", "Enable All": "Bật tất cả", "Enable check-in feature": "Bật tính năng điểm danh", + "Enable API Key Statistics": "Bật thống kê API Key", "Enable Data Dashboard": "Kích hoạt Trang tổng quan Dữ liệu", "Enable demo mode with limited functionality": "Bật chế độ demo với chức năng hạn chế", "Enable Discord OAuth": "Bật Discord OAuth", @@ -1779,7 +1780,7 @@ "footer.columns.related.links.oneApi": "One API", "footer.columns.related.title": "Các Dự Án Liên Quan", "footer.defaultCopyright": "Bản quyền được bảo lưu.", - "footer.new\u0061pi.projectAttributionSuffix": "Bản quyền được bảo lưu. Được thiết kế và phát triển bởi các cộng tác viên dự án.", + "footer.newapi.projectAttributionSuffix": "Bản quyền được bảo lưu. Được thiết kế và phát triển bởi các cộng tác viên dự án.", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "Đối với các kênh được thêm sau ngày 10 tháng 5 năm 2025, không cần loại bỏ \".\" khỏi tên mô hình trong quá trình triển khai", "For private deployments, format: https://fastgpt.run/api/openapi": "Đối với các triển khai riêng tư, định dạng: https://fastgpt.run/api/openapi", "Force a syntactically valid JSON response": "Buộc phản hồi JSON hợp lệ về cú pháp", @@ -3628,6 +3629,7 @@ "Show only bound providers": "Chỉ hiển thị nhà cung cấp đã liên kết", "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 API key consumption statistics tab in the dashboard. Only available in self-use mode.": "Hiển thị thẻ thống kê mức tiêu thụ API key trong bảng điều khiển. Chỉ khả dụng ở chế độ tự sử dụng.", "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", "Showcase core capabilities with demo credentials and limited access.": "Trình diễn các tính năng cốt lõi với thông tin đăng nhập demo và quyền truy cập hạn chế.", "Showing": "Đang hiển thị", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 1076f5e6a1f..513f9e0c2b2 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -339,9 +339,9 @@ "API Key (Production)": "API 密钥(生产)", "API Key (Sandbox)": "API 密钥(沙盒)", "API Key *": "API 密钥 *", - "API Key Analytics": "API Key 统计", - "API Key Consumption Ranking": "API Key 消耗排行", - "API Key Consumption Trend": "API Key 消耗趋势", + "API Key Analytics": "API 密钥统计", + "API Key Consumption Ranking": "API 密钥消耗排行", + "API Key Consumption Trend": "API 密钥消耗趋势", "API Key created successfully": "API 密钥创建成功", "API Key deleted successfully": "API 密钥删除成功", "API Key disabled successfully": "API 密钥禁用成功", @@ -1371,6 +1371,7 @@ "Enable 2FA": "启用 2FA", "Enable All": "启用全部", "Enable check-in feature": "启用签到功能", + "Enable API Key Statistics": "启用 API 密钥统计", "Enable Data Dashboard": "启用数据仪表板", "Enable demo mode with limited functionality": "启用功能受限的演示模式", "Enable Discord OAuth": "启用 Discord OAuth", @@ -1779,7 +1780,7 @@ "footer.columns.related.links.oneApi": "One API", "footer.columns.related.title": "相关项目", "footer.defaultCopyright": "版权所有。", - "footer.new\u0061pi.projectAttributionSuffix": "版权所有,由项目贡献者设计与开发。", + "footer.newapi.projectAttributionSuffix": "版权所有,由项目贡献者设计与开发。", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "对于 2025 年 5 月 10 日之后添加的渠道,在部署时无需从模型名称中移除 \".\"", "For private deployments, format: https://fastgpt.run/api/openapi": "对于私有部署,格式为:https://fastgpt.run/api/openapi", "Force a syntactically valid JSON response": "强制返回语法合法的 JSON", @@ -3628,6 +3629,7 @@ "Show only bound providers": "仅显示已绑定的提供商", "Show prices in currency instead of quota.": "以货币而非配额显示价格。", "Show setup guide": "显示设置引导", + "Show API key consumption statistics tab in the dashboard. Only available in self-use mode.": "在仪表盘中显示 API 密钥消耗统计选项卡,仅在自用模式下可用。", "Show token usage statistics in the UI": "在用户界面中显示令牌使用统计信息", "Showcase core capabilities with demo credentials and limited access.": "使用演示凭据和有限访问权限展示核心功能。", "Showing": "显示第", @@ -4028,7 +4030,7 @@ "Top apps": "热门应用", "Top Apps": "热门应用", "Top integrations using this model": "使用此模型的主要集成", - "Top Keys": "热门 Key", + "Top Keys": "热门密钥", "Top model": "领头模型", "Top models": "热门模型", "Top Models": "热门模型", @@ -4321,7 +4323,7 @@ "View and manage your API usage logs": "查看和管理您的 API 使用日志", "View and manage your drawing logs": "查看和管理您的绘图日志", "View and manage your task logs": "查看和管理您的任务日志", - "View API key consumption statistics and charts": "查看 API Key 消耗统计和图表", + "View API key consumption statistics and charts": "查看 API 密钥消耗统计和图表", "View dashboard overview and statistics": "查看仪表板概览和统计信息", "View detailed information about this user including balance, usage statistics, and invitation details.": "查看此用户的详细信息,包括余额、使用统计和邀请详情。", "View details": "查看详情", From c1142dde4222b14f170fd5ab70067c4556581dea Mon Sep 17 00:00:00 2001 From: SHLE1 Date: Tue, 19 May 2026 14:37:04 +0800 Subject: [PATCH 4/4] fix: address api key stats review feedback --- controller/log.go | 20 ++++++ controller/log_token_stat_test.go | 67 +++++++++++++++++++ model/log_token_stat.go | 32 +++++++++ model/log_token_stat_test.go | 38 +++++++++++ .../dashboard/components/keys/key-charts.tsx | 9 ++- .../src/features/dashboard/lib/charts.ts | 9 ++- web/default/src/i18n/locales/ja.json | 2 +- web/default/src/i18n/locales/ru.json | 2 +- web/default/src/i18n/locales/vi.json | 2 +- 9 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 controller/log_token_stat_test.go create mode 100644 model/log_token_stat_test.go diff --git a/controller/log.go b/controller/log.go index 4e0c93e561b..f0284bf9f94 100644 --- a/controller/log.go +++ b/controller/log.go @@ -6,6 +6,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/gin-gonic/gin" ) @@ -152,6 +153,10 @@ func GetLogsSelfStat(c *gin.Context) { // GetLogStatsByToken returns API key usage statistics for admins. func GetLogStatsByToken(c *gin.Context) { + if !isApiKeyStatsEnabled(c) { + return + } + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) data, err := model.GetLogStatsByToken(0, startTimestamp, endTimestamp) @@ -168,6 +173,10 @@ func GetLogStatsByToken(c *gin.Context) { // GetUserLogStatsByToken returns API key usage statistics for the current user. func GetUserLogStatsByToken(c *gin.Context) { + if !isApiKeyStatsEnabled(c) { + return + } + userId := c.GetInt("id") startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) @@ -183,6 +192,17 @@ func GetUserLogStatsByToken(c *gin.Context) { }) } +func isApiKeyStatsEnabled(c *gin.Context) bool { + if operation_setting.SelfUseModeEnabled && common.ApiKeyStatsEnabled { + return true + } + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "api key statistics is disabled", + }) + return false +} + func DeleteHistoryLogs(c *gin.Context) { targetTimestamp, _ := strconv.ParseInt(c.Query("target_timestamp"), 10, 64) if targetTimestamp == 0 { diff --git a/controller/log_token_stat_test.go b/controller/log_token_stat_test.go new file mode 100644 index 00000000000..75c660db405 --- /dev/null +++ b/controller/log_token_stat_test.go @@ -0,0 +1,67 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func withApiKeyStatsSettings(t *testing.T, selfUseModeEnabled, apiKeyStatsEnabled bool) { + t.Helper() + + originalSelfUseModeEnabled := operation_setting.SelfUseModeEnabled + originalApiKeyStatsEnabled := common.ApiKeyStatsEnabled + operation_setting.SelfUseModeEnabled = selfUseModeEnabled + common.ApiKeyStatsEnabled = apiKeyStatsEnabled + t.Cleanup(func() { + operation_setting.SelfUseModeEnabled = originalSelfUseModeEnabled + common.ApiKeyStatsEnabled = originalApiKeyStatsEnabled + }) +} + +func performTokenStatsRequest(handler gin.HandlerFunc) *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodGet, "/api/log/stat/tokens", nil) + ctx.Request = req + handler(ctx) + return recorder +} + +func TestGetLogStatsByTokenRejectsWhenApiKeyStatsDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + withApiKeyStatsSettings(t, true, false) + + recorder := performTokenStatsRequest(GetLogStatsByToken) + + require.Equal(t, http.StatusForbidden, recorder.Code) + require.Contains(t, recorder.Body.String(), "api key statistics is disabled") +} + +func TestGetLogStatsByTokenRejectsWhenSelfUseModeDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + withApiKeyStatsSettings(t, false, true) + + recorder := performTokenStatsRequest(GetLogStatsByToken) + + require.Equal(t, http.StatusForbidden, recorder.Code) + require.Contains(t, recorder.Body.String(), "api key statistics is disabled") +} + +func TestGetLogStatsByTokenAllowsWhenEnabled(t *testing.T) { + gin.SetMode(gin.TestMode) + withApiKeyStatsSettings(t, true, true) + db := setupModelListControllerTestDB(t) + require.NoError(t, db.AutoMigrate(&model.Log{})) + + recorder := performTokenStatsRequest(GetLogStatsByToken) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Contains(t, recorder.Body.String(), `"success":true`) +} diff --git a/model/log_token_stat.go b/model/log_token_stat.go index 03c16af718a..c6e2aff4baa 100644 --- a/model/log_token_stat.go +++ b/model/log_token_stat.go @@ -1,9 +1,17 @@ package model import ( + "fmt" + "time" + "github.com/QuantumNous/new-api/common" ) +const ( + tokenStatsDefaultRangeSeconds = 24 * 60 * 60 + tokenStatsMaxRangeSeconds = 30 * 24 * 60 * 60 +) + // TokenQuotaData is hourly aggregated usage data by API key. type TokenQuotaData struct { TokenId int `json:"token_id"` @@ -19,6 +27,11 @@ type TokenQuotaData struct { func GetLogStatsByToken(userId int, startTime, endTime int64) ([]*TokenQuotaData, error) { var results []*TokenQuotaData + startTime, endTime, err := normalizeTokenStatsTimeRange(startTime, endTime, time.Now().Unix()) + if err != nil { + return nil, err + } + query := LOG_DB.Table("logs"). Select(`token_id, token_name, @@ -48,3 +61,22 @@ func GetLogStatsByToken(userId int, startTime, endTime int64) ([]*TokenQuotaData } return results, nil } + +func normalizeTokenStatsTimeRange(startTime, endTime, now int64) (int64, int64, error) { + if startTime == 0 && endTime == 0 { + endTime = now + startTime = endTime - tokenStatsDefaultRangeSeconds + } else if endTime == 0 { + endTime = now + } else if startTime == 0 { + startTime = endTime - tokenStatsMaxRangeSeconds + } + + if endTime < startTime { + return 0, 0, fmt.Errorf("invalid time range: end_timestamp < start_timestamp") + } + if endTime-startTime > tokenStatsMaxRangeSeconds { + startTime = endTime - tokenStatsMaxRangeSeconds + } + return startTime, endTime, nil +} diff --git a/model/log_token_stat_test.go b/model/log_token_stat_test.go new file mode 100644 index 00000000000..bd3debbb575 --- /dev/null +++ b/model/log_token_stat_test.go @@ -0,0 +1,38 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeTokenStatsTimeRangeDefaultsToRecentDay(t *testing.T) { + startTime, endTime, err := normalizeTokenStatsTimeRange(0, 0, 1_700_000_000) + require.NoError(t, err) + require.Equal(t, int64(1_700_000_000), endTime) + require.Equal(t, int64(1_700_000_000-tokenStatsDefaultRangeSeconds), startTime) +} + +func TestNormalizeTokenStatsTimeRangeFillsMissingBoundary(t *testing.T) { + startTime, endTime, err := normalizeTokenStatsTimeRange(1_699_999_000, 0, 1_700_000_000) + require.NoError(t, err) + require.Equal(t, int64(1_699_999_000), startTime) + require.Equal(t, int64(1_700_000_000), endTime) + + startTime, endTime, err = normalizeTokenStatsTimeRange(0, 1_700_000_000, 1_700_000_100) + require.NoError(t, err) + require.Equal(t, int64(1_700_000_000-tokenStatsMaxRangeSeconds), startTime) + require.Equal(t, int64(1_700_000_000), endTime) +} + +func TestNormalizeTokenStatsTimeRangeCapsRange(t *testing.T) { + startTime, endTime, err := normalizeTokenStatsTimeRange(1_600_000_000, 1_700_000_000, 1_700_000_000) + require.NoError(t, err) + require.Equal(t, int64(1_700_000_000-tokenStatsMaxRangeSeconds), startTime) + require.Equal(t, int64(1_700_000_000), endTime) +} + +func TestNormalizeTokenStatsTimeRangeRejectsInvalidRange(t *testing.T) { + _, _, err := normalizeTokenStatsTimeRange(1_700_000_001, 1_700_000_000, 1_700_000_000) + require.Error(t, err) +} diff --git a/web/default/src/features/dashboard/components/keys/key-charts.tsx b/web/default/src/features/dashboard/components/keys/key-charts.tsx index 6ae49a82fc4..989f0d6b028 100644 --- a/web/default/src/features/dashboard/components/keys/key-charts.tsx +++ b/web/default/src/features/dashboard/components/keys/key-charts.tsx @@ -42,9 +42,14 @@ import { ConsumptionDistributionChart } from '../models/consumption-distribution import { ModelCharts } from '../models/model-charts' /** Map TokenQuotaDataItem → QuotaDataItem so existing chart functions can be reused */ +function getTokenDisplayKey(item: TokenQuotaDataItem): string { + if (item.token_id != null) return `${item.token_name || 'key'}#${item.token_id}` + return item.token_name || 'key-unknown' +} + function mapToQuotaData(items: TokenQuotaDataItem[]): QuotaDataItem[] { return items.map((item) => ({ - model_name: item.token_name || `key-${item.token_id ?? 'unknown'}`, + model_name: getTokenDisplayKey(item), created_at: item.created_at, count: item.count, quota: item.quota, @@ -110,7 +115,7 @@ export function KeyCharts() { const topNKeySet = useMemo(() => { const totals = new Map() tokenData.forEach((item) => { - const key = item.token_name || `key-${item.token_id ?? 'unknown'}` + const key = getTokenDisplayKey(item) totals.set(key, (totals.get(key) ?? 0) + (item.quota ?? 0)) }) diff --git a/web/default/src/features/dashboard/lib/charts.ts b/web/default/src/features/dashboard/lib/charts.ts index 2deb3368486..eb68a33ae08 100644 --- a/web/default/src/features/dashboard/lib/charts.ts +++ b/web/default/src/features/dashboard/lib/charts.ts @@ -93,6 +93,11 @@ function renderQuotaCompat(rawQuota: number, digits = 4): string { return symbol + fixed } +function getTokenDisplayKey(item: TokenQuotaDataItem): string { + if (item.token_id != null) return `${item.token_name || 'key'}#${item.token_id}` + return item.token_name || 'key-unknown' +} + /** * Process and aggregate chart data */ @@ -1055,7 +1060,7 @@ export function processTokenChartData( const keyQuotaTotal = new Map() data.forEach((item) => { - const keyName = item.token_name || `key#${item.token_id ?? 'unknown'}` + const keyName = getTokenDisplayKey(item) const prev = keyQuotaTotal.get(keyName) || 0 keyQuotaTotal.set(keyName, prev + (Number(item.quota) || 0)) }) @@ -1083,7 +1088,7 @@ export function processTokenChartData( const ts = Number(item.created_at) const timeKey = formatChartTime(ts, timeGranularity) allTimePoints.add(timeKey) - const keyName = item.token_name || `key#${item.token_id ?? 'unknown'}` + const keyName = getTokenDisplayKey(item) if (!topKeySet.has(keyName)) return if (!timeKeyMap.has(timeKey)) timeKeyMap.set(timeKey, new Map()) const map = timeKeyMap.get(timeKey)! diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index dc7ed9852ec..932a4e03d49 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -3629,7 +3629,7 @@ "Show only bound providers": "バインド済みのプロバイダーのみ表示", "Show prices in currency instead of quota.": "クォータではなく通貨で価格を表示。", "Show setup guide": "セットアップガイドを表示", - "Show API key consumption statistics tab in the dashboard. Only available in self-use mode.": "ダッシュボードにAPIキー消費統計タブを表示します。自用モードでのみ利用できます。", + "Show API key consumption statistics tab in the dashboard. Only available in self-use mode.": "ダッシュボードにAPIキー消費統計タブを表示します。セルフユースモードでのみ利用できます。", "Show token usage statistics in the UI": "UIでトークン使用統計を表示", "Showcase core capabilities with demo credentials and limited access.": "デモ用の認証情報と制限付きアクセスでコア機能を紹介します。", "Showing": "表示", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index bff3967b591..95774388f30 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -4323,7 +4323,7 @@ "View and manage your API usage logs": "Просмотр и управление журналами использования API", "View and manage your drawing logs": "Просмотр и управление журналами рисования", "View and manage your task logs": "Просмотр и управление журналами задач", - "View API key consumption statistics and charts": "Просмотр статистики потребления API-ключей", + "View API key consumption statistics and charts": "Просмотр статистики и графиков потребления API-ключей", "View dashboard overview and statistics": "Просмотр обзора и статистики панели управления", "View detailed information about this user including balance, usage statistics, and invitation details.": "Просмотр подробной информации об этом пользователе, включая баланс, статистику использования и данные приглашения.", "View details": "Просмотреть детали", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 193458d20bf..16f5aeec71b 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -4030,7 +4030,7 @@ "Top apps": "Ứng dụng hàng đầu", "Top Apps": "Ứng dụng hàng đầu", "Top integrations using this model": "Tích hợp hàng đầu sử dụng mô hình này", - "Top Keys": "Top Keys", + "Top Keys": "Các khóa hàng đầu", "Top model": "Mô hình dẫn đầu", "Top models": "Mô hình hàng đầu", "Top Models": "Người mẫu hàng đầu",