From c38fcc494abc441c1578b2facd900aaa00f52bdf Mon Sep 17 00:00:00 2001 From: Yuxing Deng Date: Tue, 19 May 2026 16:58:06 +0800 Subject: [PATCH] refactor(dashboard): collapse usage section into a single row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge the two "by Model" pies (Token Usage + API Requests) into one dual-pie card with a shared right-side vertical legend and per-donut center totals, drop the standalone API-Key Top 10 chart, and put the combined chart side-by-side with the User Top 10 ranking in one row with the same 16/8 column ratio used by the System Load section above. * New component usage-charts/usage-by-model.tsx owns both metric queries (group_by=['date','route']), aggregates a shared route→color map so the legend toggles both donuts together, and renders two pies at centers ('20%','50%') and ('60%','50%') with radius ['50%','70%']. * new-usage.tsx no longer mounts ApiRequestsByModel / TokenUsageByModel / TopTokenUsageByApiKey; layout collapses to two columns sized (lg=24 xl=16) and (lg=24 xl=8). * Delete unused token-usage-by-model.tsx, api-requests-by-model.tsx, top-token-usage-by-api-key.tsx, and the use-top-token-usage-by-api-key hook. * Strip the now-unreferenced dashboard.tokenUsageByModel / apiRequestsByModel / topTokenUsageByApiKey locale keys across 5 locales, add the new dashboard.usageByModel title; locale parity check passes. --- src/locales/en-US/dashboard.ts | 4 +- src/locales/ja-JP/dashboard.ts | 4 +- src/locales/ru-RU/dashboard.ts | 4 +- src/locales/tr-TR/dashboard.ts | 5 +- src/locales/zh-CN/dashboard.ts | 4 +- src/pages/dashboard/components/new-usage.tsx | 65 +--- .../usage-charts/api-requests-by-model.tsx | 63 ---- .../usage-charts/token-usage-by-model.tsx | 48 --- .../top-token-usage-by-api-key.tsx | 43 --- .../usage-charts/usage-by-model.tsx | 305 ++++++++++++++++++ .../hooks/use-top-token-usage-by-api-key.ts | 52 --- 11 files changed, 317 insertions(+), 280 deletions(-) delete mode 100644 src/pages/dashboard/components/usage-charts/api-requests-by-model.tsx delete mode 100644 src/pages/dashboard/components/usage-charts/token-usage-by-model.tsx delete mode 100644 src/pages/dashboard/components/usage-charts/top-token-usage-by-api-key.tsx create mode 100644 src/pages/dashboard/components/usage-charts/usage-by-model.tsx delete mode 100644 src/pages/dashboard/hooks/use-top-token-usage-by-api-key.ts diff --git a/src/locales/en-US/dashboard.ts b/src/locales/en-US/dashboard.ts index 9b4fdb26..8e6c7b1b 100644 --- a/src/locales/en-US/dashboard.ts +++ b/src/locales/en-US/dashboard.ts @@ -14,10 +14,8 @@ export default { 'dashboard.tokens': 'Token Usage', 'dashboard.topusers': 'Top Users', 'dashboard.activeDeployments': 'Active Deployments', - 'dashboard.tokenUsageByModel': 'Token Usage by Model', - 'dashboard.apiRequestsByModel': 'API Requests by Model', + 'dashboard.usageByModel': 'Usage by Model', 'dashboard.topTokenUsageByUser': 'Top 10 Token Usage by User', - 'dashboard.topTokenUsageByApiKey': 'Top 10 Token Usage by API Key', 'dashboard.allocatevram': 'Allocated VRAM / RAM', 'dashboard.usage.selectuser': 'Select users', 'dashboard.usage.selectmodel': 'Select models', diff --git a/src/locales/ja-JP/dashboard.ts b/src/locales/ja-JP/dashboard.ts index 060ba8e4..c947ab5d 100644 --- a/src/locales/ja-JP/dashboard.ts +++ b/src/locales/ja-JP/dashboard.ts @@ -13,10 +13,8 @@ export default { 'dashboard.tokens': 'トークン使用量', 'dashboard.topusers': 'トップユーザー', 'dashboard.activeDeployments': 'Active Deployments', - 'dashboard.tokenUsageByModel': 'モデル別トークン使用量', - 'dashboard.apiRequestsByModel': 'モデル別APIリクエスト', + 'dashboard.usageByModel': 'モデル別使用量', 'dashboard.topTokenUsageByUser': 'ユーザー別トークン使用量トップ10', - 'dashboard.topTokenUsageByApiKey': 'APIキー別トークン使用量トップ10', 'dashboard.allocatevram': '割り当て済みVRAM / メモリ', 'dashboard.usage.selectuser': 'Select users', 'dashboard.usage.selectmodel': 'Select models', diff --git a/src/locales/ru-RU/dashboard.ts b/src/locales/ru-RU/dashboard.ts index 45a151cf..dcb9dfd4 100644 --- a/src/locales/ru-RU/dashboard.ts +++ b/src/locales/ru-RU/dashboard.ts @@ -13,10 +13,8 @@ export default { 'dashboard.tokens': 'Использование токенов', 'dashboard.topusers': 'Топ пользователей', 'dashboard.activeDeployments': 'Active Deployments', - 'dashboard.tokenUsageByModel': 'Использование токенов по моделям', - 'dashboard.apiRequestsByModel': 'API-запросы по моделям', + 'dashboard.usageByModel': 'Использование по моделям', 'dashboard.topTokenUsageByUser': 'Топ-10 пользователей по токенам', - 'dashboard.topTokenUsageByApiKey': 'Топ-10 API-ключей по токенам', 'dashboard.allocatevram': 'Выделено VRAM / ОЗУ', 'dashboard.usage.selectuser': 'Выбрать пользователей', 'dashboard.usage.selectmodel': 'Выбрать модели', diff --git a/src/locales/tr-TR/dashboard.ts b/src/locales/tr-TR/dashboard.ts index 3671cb7a..3400883f 100644 --- a/src/locales/tr-TR/dashboard.ts +++ b/src/locales/tr-TR/dashboard.ts @@ -14,11 +14,8 @@ export default { 'dashboard.tokens': 'Token Kullanımı', 'dashboard.topusers': 'En Aktif Kullanıcılar', 'dashboard.activeDeployments': 'Active Deployments', - 'dashboard.tokenUsageByModel': 'Modele Göre Token Kullanımı', - 'dashboard.apiRequestsByModel': 'Modele Göre API İstekleri', + 'dashboard.usageByModel': 'Modele Göre Kullanım', 'dashboard.topTokenUsageByUser': 'Kullanıcıya Göre İlk 10 Token Kullanımı', - 'dashboard.topTokenUsageByApiKey': - 'API Anahtarına Göre İlk 10 Token Kullanımı', 'dashboard.allocatevram': 'Ayrılan VRAM / RAM', 'dashboard.usage.selectuser': 'Kullanıcı seçin', 'dashboard.usage.selectmodel': 'Model seçin', diff --git a/src/locales/zh-CN/dashboard.ts b/src/locales/zh-CN/dashboard.ts index 189af09b..595a862a 100644 --- a/src/locales/zh-CN/dashboard.ts +++ b/src/locales/zh-CN/dashboard.ts @@ -14,10 +14,8 @@ export default { 'dashboard.tokens': 'Token 使用量', 'dashboard.topusers': '用户排行', 'dashboard.activeDeployments': '活跃部署', - 'dashboard.tokenUsageByModel': '按模型统计 Token 使用量', - 'dashboard.apiRequestsByModel': '按模型统计 API 请求数', + 'dashboard.usageByModel': '按模型统计使用量', 'dashboard.topTokenUsageByUser': '用户 Token 使用量 Top 10', - 'dashboard.topTokenUsageByApiKey': 'API 密钥 Token 使用量 Top 10', 'dashboard.allocatevram': '已分配显存 / 内存', 'dashboard.usage.selectuser': '选择用户', 'dashboard.usage.selectmodel': '选择模型', diff --git a/src/pages/dashboard/components/new-usage.tsx b/src/pages/dashboard/components/new-usage.tsx index b4493a7f..af017b34 100644 --- a/src/pages/dashboard/components/new-usage.tsx +++ b/src/pages/dashboard/components/new-usage.tsx @@ -1,21 +1,17 @@ -import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data'; import { PageTools } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; import { Col, Row } from 'antd'; import dayjs from 'dayjs'; -import { useEffect, useMemo } from 'react'; +import { useMemo } from 'react'; import styled from 'styled-components'; import { DashboardUsageCommonParams, getUsageRankHeight, getUsageRankSlotCount } from '../config'; -import useTopTokenUsageByApiKey from '../hooks/use-top-token-usage-by-api-key'; import useTopTokenUsageByUser from '../hooks/use-top-token-usage-by-user'; -import ApiRequestsByModel from './usage-charts/api-requests-by-model'; -import TokenUsageByModel from './usage-charts/token-usage-by-model'; -import TopTokenUsageByApiKey from './usage-charts/top-token-usage-by-api-key'; import TopTokenUsageByUser from './usage-charts/top-token-usage-by-user'; +import UsageByModel from './usage-charts/usage-by-model'; const Section = styled.div` margin-top: 16px; @@ -23,9 +19,6 @@ const Section = styled.div` const NewUsage = () => { const intl = useIntl(); - const tokenByModelQuery = useQueryTimeSeriesData({ - key: 'tokenUsageByModelData' - }); const dateRange = useMemo( () => ({ @@ -45,37 +38,11 @@ const NewUsage = () => { [dateRange] ); - useEffect(() => { - const fetchData = async () => { - await Promise.all([ - tokenByModelQuery.fetchData({ - ...commonParams, - metric: 'total_tokens', - group_by: ['date', 'route'], - sort_by: '-total_tokens' - }) - ]); - }; - - fetchData().catch(() => {}); - }, [commonParams, dateRange]); - const userUsage = useTopTokenUsageByUser(commonParams); - const apiKeyUsage = useTopTokenUsageByApiKey(commonParams); const userCount = userUsage.rankData.names.length; - const apiKeyCount = apiKeyUsage.rankData.names.length; - - const rankHeight = Math.max( - getUsageRankHeight(userCount), - getUsageRankHeight(apiKeyCount) - ); - - const rankMaxItems = Math.max( - getUsageRankSlotCount(rankHeight), - userCount, - apiKeyCount - ); + const rankHeight = getUsageRankHeight(userCount); + const rankMaxItems = Math.max(getUsageRankSlotCount(rankHeight), userCount); return ( <> @@ -89,20 +56,10 @@ const NewUsage = () => { />
- - + + - - - - -
-
- - + { maxItems={rankMaxItems} /> - - -
diff --git a/src/pages/dashboard/components/usage-charts/api-requests-by-model.tsx b/src/pages/dashboard/components/usage-charts/api-requests-by-model.tsx deleted file mode 100644 index 0c3a5941..00000000 --- a/src/pages/dashboard/components/usage-charts/api-requests-by-model.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import PieChart from '@/pages/_components/pie-chart'; -import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data'; -import { useIntl } from '@umijs/max'; -import { useEffect, useMemo } from 'react'; -import { - DashboardUsageCommonParams, - getUsageSummary, - toUsagePieData, - usageChartHeight -} from '../../config'; -import UsageChartCard from './shared'; - -interface ApiRequestsByModelProps { - commonParams: DashboardUsageCommonParams; -} - -const ApiRequestsByModel: React.FC = ({ - commonParams -}) => { - const intl = useIntl(); - const query = useQueryTimeSeriesData({ - key: 'apiRequestsByModelData' - }); - - useEffect(() => { - query - .fetchData({ - ...commonParams, - metric: 'api_requests', - group_by: ['date', 'route'], - page: 1, - perPage: 10, - sort_by: '-api_requests' - }) - .catch(() => {}); - }, [commonParams]); - - const chartData = useMemo( - () => toUsagePieData(query.detailData, 'route', 'api_requests'), - [query.detailData] - ); - - const total = useMemo(() => { - const summary = getUsageSummary(query.detailData); - return Number(summary?.api_requests ?? 0); - }, [query.detailData]); - - return ( - - - - ); -}; - -export default ApiRequestsByModel; diff --git a/src/pages/dashboard/components/usage-charts/token-usage-by-model.tsx b/src/pages/dashboard/components/usage-charts/token-usage-by-model.tsx deleted file mode 100644 index f6df1a35..00000000 --- a/src/pages/dashboard/components/usage-charts/token-usage-by-model.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import PieChart from '@/pages/_components/pie-chart'; -import { UsageBreakdownResponse } from '@/pages/usage/config/types'; -import { useIntl } from '@umijs/max'; -import { useMemo } from 'react'; -import { - getUsageSummary, - toUsagePieData, - usageChartHeight -} from '../../config'; -import UsageChartCard from './shared'; - -interface TokenUsageByModelProps { - data: UsageBreakdownResponse | null | undefined; - loading?: boolean; -} - -const TokenUsageByModel: React.FC = ({ - data, - loading = false -}) => { - const intl = useIntl(); - - const chartData = useMemo( - () => toUsagePieData(data, 'route', 'total_tokens'), - [data] - ); - - const total = useMemo(() => { - const summary = getUsageSummary(data); - return Number(summary?.total_tokens ?? 0); - }, [data]); - - return ( - - - - ); -}; - -export default TokenUsageByModel; diff --git a/src/pages/dashboard/components/usage-charts/top-token-usage-by-api-key.tsx b/src/pages/dashboard/components/usage-charts/top-token-usage-by-api-key.tsx deleted file mode 100644 index 95668f18..00000000 --- a/src/pages/dashboard/components/usage-charts/top-token-usage-by-api-key.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { HBarChart } from '@gpustack/core-ui'; -import { useIntl } from '@umijs/max'; -import { usageChartHeight, usageRankMaxItems } from '../../config'; -import type useTopTokenUsageByApiKey from '../../hooks/use-top-token-usage-by-api-key'; -import UsageChartCard, { ChartLoadingBox } from './shared'; - -type RankData = ReturnType['rankData']; - -interface TopTokenUsageByApiKeyProps { - rankData: RankData; - loading: boolean; - height?: number; - maxItems?: number; -} - -const TopTokenUsageByApiKey: React.FC = ({ - rankData, - loading, - height = usageChartHeight, - maxItems = usageRankMaxItems -}) => { - const intl = useIntl(); - - return ( - - {loading && rankData.names.length === 0 ? ( - - ) : ( - - )} - - ); -}; - -export default TopTokenUsageByApiKey; diff --git a/src/pages/dashboard/components/usage-charts/usage-by-model.tsx b/src/pages/dashboard/components/usage-charts/usage-by-model.tsx new file mode 100644 index 00000000..77f78de7 --- /dev/null +++ b/src/pages/dashboard/components/usage-charts/usage-by-model.tsx @@ -0,0 +1,305 @@ +import useCoolColors from '@/hooks/use-cool-colors'; +import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data'; +import { Chart } from '@gpustack/core-ui'; +import { formatLargeNumber } from '@gpustack/core-ui/utils'; +import { useIntl } from '@umijs/max'; +import type { GlobalToken } from 'antd'; +import { Empty, Spin, theme } from 'antd'; +import { useEffect, useMemo } from 'react'; +import { + DashboardUsageCommonParams, + getUsageSummary, + toUsagePieData, + UsageChartDatum, + usageChartHeight +} from '../../config'; +import UsageChartCard from './shared'; + +const HTML_ESCAPES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' +}; + +// Tooltip strings come from route names (user-controlled via the API) and +// translated metric labels, so they must be escaped before being interpolated +// into raw HTML for ECharts' tooltip formatter. +const escapeHtml = (value: unknown): string => + String(value ?? '').replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] ?? c); + +interface UsageByModelProps { + commonParams: DashboardUsageCommonParams; + height?: number; +} + +const UsageByModel: React.FC = ({ + commonParams, + height = usageChartHeight +}) => { + const intl = useIntl(); + const { token } = theme.useToken(); + const generateCoolColors = useCoolColors(); + + const tokenQuery = useQueryTimeSeriesData({ key: 'tokenUsageByModelData' }); + const apiQuery = useQueryTimeSeriesData({ key: 'apiRequestsByModelData' }); + + useEffect(() => { + tokenQuery + .fetchData({ + ...commonParams, + metric: 'total_tokens', + group_by: ['date', 'route'], + page: 1, + perPage: 10, + sort_by: '-total_tokens' + }) + .catch(() => {}); + apiQuery + .fetchData({ + ...commonParams, + metric: 'api_requests', + group_by: ['date', 'route'], + page: 1, + perPage: 10, + sort_by: '-api_requests' + }) + .catch(() => {}); + }, [commonParams]); + + const tokenData = useMemo( + () => toUsagePieData(tokenQuery.detailData, 'route', 'total_tokens'), + [tokenQuery.detailData] + ); + const apiData = useMemo( + () => toUsagePieData(apiQuery.detailData, 'route', 'api_requests'), + [apiQuery.detailData] + ); + + const tokenTotal = useMemo(() => { + const summary = getUsageSummary(tokenQuery.detailData); + return Number(summary?.total_tokens ?? 0); + }, [tokenQuery.detailData]); + + const apiTotal = useMemo(() => { + const summary = getUsageSummary(apiQuery.detailData); + return Number(summary?.api_requests ?? 0); + }, [apiQuery.detailData]); + + // Build a shared name->color map so legend toggles both donuts and + // matching slices read the same color across the pair. + const { legendNames, colorMap } = useMemo(() => { + const names: string[] = []; + const seen = new Set(); + [...tokenData, ...apiData].forEach((d) => { + if (!seen.has(d.name)) { + seen.add(d.name); + names.push(d.name); + } + }); + const colors = generateCoolColors(names.length); + const map: Record = {}; + names.forEach((name, i) => { + map[name] = colors[i]; + }); + return { legendNames: names, colorMap: map }; + }, [tokenData, apiData, generateCoolColors]); + + const tokenLabel = intl.formatMessage({ id: 'usage.filter.totalTokens' }); + const apiLabel = intl.formatMessage({ id: 'usage.filter.apiRequests' }); + + const isEmpty = tokenData.length === 0 && apiData.length === 0; + const isLoading = tokenQuery.loading || apiQuery.loading; + + const options = useMemo(() => { + const paintData = (rows: UsageChartDatum[]) => + rows.map((row) => ({ + name: row.name, + value: row.value, + itemStyle: { color: colorMap[row.name] } + })); + + return { + tooltip: { + trigger: 'item', + backgroundColor: token.colorBgElevated, + borderColor: 'transparent', + formatter: (params: any) => { + const seriesName = escapeHtml(params.seriesName); + const name = escapeHtml(params.name); + const value = escapeHtml(formatLargeNumber(params.value)); + return `
+ + + ${params.marker} + ${seriesName} · ${name}: + + ${value} + +
`; + } + }, + legend: { + type: 'scroll', + orient: 'vertical', + right: 0, + top: 18, + bottom: 18, + itemWidth: 8, + itemHeight: 8, + itemGap: 12, + textStyle: { + color: token.colorTextTertiary, + overflow: 'truncate', + width: 140 + }, + pageTextStyle: { color: token.colorTextTertiary }, + pageIconColor: token.colorTextTertiary, + pageIconInactiveColor: token.colorTextDisabled, + data: legendNames, + show: legendNames.length > 0 + }, + series: [ + { + name: tokenLabel, + type: 'pie', + radius: ['50%', '70%'], + center: ['20%', '50%'], + avoidLabelOverlap: true, + label: { show: false }, + labelLine: { show: false }, + emphasis: { + label: { + show: true, + formatter: (params: any) => `${params.percent}%`, + fontSize: 14, + fontWeight: 600, + color: token.colorText + } + }, + data: paintData(tokenData) + }, + { + name: apiLabel, + type: 'pie', + radius: ['50%', '70%'], + center: ['60%', '50%'], + avoidLabelOverlap: true, + label: { show: false }, + labelLine: { show: false }, + emphasis: { + label: { + show: true, + formatter: (params: any) => `${params.percent}%`, + fontSize: 14, + fontWeight: 600, + color: token.colorText + } + }, + data: paintData(apiData) + } + ] + }; + }, [tokenData, apiData, colorMap, legendNames, token, tokenLabel, apiLabel]); + + return ( + +
+ {isEmpty && !isLoading ? ( +
+ +
+ ) : ( + <> + + + + {isLoading && ( +
+ +
+ )} + + )} +
+
+ ); +}; + +const DonutCenter: React.FC<{ + left: string; + top: string; + total: number; + label: string; + token: GlobalToken; +}> = ({ left, top, total, label, token }) => ( +
+ + {formatLargeNumber(total)} + + + {label} + +
+); + +export default UsageByModel; diff --git a/src/pages/dashboard/hooks/use-top-token-usage-by-api-key.ts b/src/pages/dashboard/hooks/use-top-token-usage-by-api-key.ts deleted file mode 100644 index f001dd84..00000000 --- a/src/pages/dashboard/hooks/use-top-token-usage-by-api-key.ts +++ /dev/null @@ -1,52 +0,0 @@ -import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data'; -import { useIntl } from '@umijs/max'; -import { useEffect, useMemo } from 'react'; -import { - baseColorMap, - DashboardUsageCommonParams, - toUsageRankData -} from '../config'; - -export default function useTopTokenUsageByApiKey( - commonParams: DashboardUsageCommonParams -) { - const intl = useIntl(); - const query = useQueryTimeSeriesData({ - key: 'topTokenUsageByApiKeyData' - }); - const tokenUsageText = intl.formatMessage({ id: 'dashboard.tokens' }); - - useEffect(() => { - query - .fetchData({ - ...commonParams, - metric: 'total_tokens', - group_by: ['date', 'api_key'], - page: 1, - perPage: 10, - sort_by: '-total_tokens' - }) - .catch(() => {}); - }, [commonParams]); - - const rankData = useMemo(() => { - const data = toUsageRankData( - query.detailData, - 'api_key', - tokenUsageText, - baseColorMap.baseR3 - ); - return { - names: data.names.filter((name) => name !== '-'), - series: data.series.map((item) => ({ - ...item, - data: item.data.filter((point) => point.name !== '-') - })) - }; - }, [query.detailData, tokenUsageText]); - - return { - rankData, - loading: query.loading - }; -}