refactor(dashboard): collapse usage section into a single row

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.
This commit is contained in:
Yuxing Deng
2026-05-20 11:29:43 +08:00
committed by jialin
parent 44e5b78d29
commit c38fcc494a
11 changed files with 317 additions and 280 deletions
+1 -3
View File
@@ -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',
+1 -3
View File
@@ -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',
+1 -3
View File
@@ -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': 'Выбрать модели',
+1 -4
View File
@@ -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',
+1 -3
View File
@@ -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': '选择模型',
+7 -58
View File
@@ -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 = () => {
/>
<Section>
<Row gutter={[20, 20]}>
<Col xs={24} sm={24} md={24} lg={12}>
<TokenUsageByModel
data={tokenByModelQuery.detailData}
loading={tokenByModelQuery.loading}
/>
<Col xs={24} sm={24} md={24} lg={24} xl={16}>
<UsageByModel commonParams={commonParams} height={rankHeight} />
</Col>
<Col xs={24} sm={24} md={24} lg={12}>
<ApiRequestsByModel commonParams={commonParams} />
</Col>
</Row>
</Section>
<Section>
<Row gutter={[20, 20]}>
<Col xs={24} sm={24} md={24} lg={12}>
<Col xs={24} sm={24} md={24} lg={24} xl={8}>
<TopTokenUsageByUser
rankData={userUsage.rankData}
loading={userUsage.loading}
@@ -110,14 +67,6 @@ const NewUsage = () => {
maxItems={rankMaxItems}
/>
</Col>
<Col xs={24} sm={24} md={24} lg={12}>
<TopTokenUsageByApiKey
rankData={apiKeyUsage.rankData}
loading={apiKeyUsage.loading}
height={rankHeight}
maxItems={rankMaxItems}
/>
</Col>
</Row>
</Section>
</>
@@ -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<ApiRequestsByModelProps> = ({
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 (
<UsageChartCard
title={intl.formatMessage({ id: 'dashboard.apiRequestsByModel' })}
>
<PieChart
data={chartData}
height={usageChartHeight}
loading={query.loading}
colorOffset={5}
total={total}
/>
</UsageChartCard>
);
};
export default ApiRequestsByModel;
@@ -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<TokenUsageByModelProps> = ({
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 (
<UsageChartCard
title={intl.formatMessage({ id: 'dashboard.tokenUsageByModel' })}
>
<PieChart
data={chartData}
height={usageChartHeight}
loading={loading}
colorOffset={2}
total={total}
/>
</UsageChartCard>
);
};
export default TokenUsageByModel;
@@ -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<typeof useTopTokenUsageByApiKey>['rankData'];
interface TopTokenUsageByApiKeyProps {
rankData: RankData;
loading: boolean;
height?: number;
maxItems?: number;
}
const TopTokenUsageByApiKey: React.FC<TopTokenUsageByApiKeyProps> = ({
rankData,
loading,
height = usageChartHeight,
maxItems = usageRankMaxItems
}) => {
const intl = useIntl();
return (
<UsageChartCard
title={intl.formatMessage({ id: 'dashboard.topTokenUsageByApiKey' })}
height={height + 52}
>
{loading && rankData.names.length === 0 ? (
<ChartLoadingBox height={height} />
) : (
<HBarChart
seriesData={rankData.series}
xAxisData={rankData.names}
height={height}
maxItems={maxItems}
/>
)}
</UsageChartCard>
);
};
export default TopTokenUsageByApiKey;
@@ -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<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
};
// 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<UsageByModelProps> = ({
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<UsageChartDatum[]>(
() => toUsagePieData(tokenQuery.detailData, 'route', 'total_tokens'),
[tokenQuery.detailData]
);
const apiData = useMemo<UsageChartDatum[]>(
() => 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<string>();
[...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<string, string> = {};
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 `<div class="tooltip-wrapper">
<span class="tooltip-item">
<span class="tooltip-item-name">
${params.marker}
<span class="tooltip-item-title">${seriesName} · ${name}</span>:
</span>
<span class="tooltip-value">${value}</span>
</span>
</div>`;
}
},
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 (
<UsageChartCard
title={intl.formatMessage({ id: 'dashboard.usageByModel' })}
height={height + 52}
>
<div style={{ position: 'relative', width: '100%', height }}>
{isEmpty && !isLoading ? (
<div
style={{
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
) : (
<>
<Chart options={options as any} height={height} width="100%" />
<DonutCenter
left="20%"
top="50%"
total={tokenTotal}
label={tokenLabel}
token={token}
/>
<DonutCenter
left="60%"
top="50%"
total={apiTotal}
label={apiLabel}
token={token}
/>
{isLoading && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: token.colorBgContainer,
opacity: 0.6,
pointerEvents: 'none'
}}
>
<Spin size="middle" />
</div>
)}
</>
)}
</div>
</UsageChartCard>
);
};
const DonutCenter: React.FC<{
left: string;
top: string;
total: number;
label: string;
token: GlobalToken;
}> = ({ left, top, total, label, token }) => (
<div
style={{
position: 'absolute',
left,
top,
transform: 'translate(-50%, -50%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
pointerEvents: 'none',
textAlign: 'center'
}}
>
<span
style={{
fontSize: 18,
fontWeight: 600,
color: token.colorText,
lineHeight: 1.2
}}
>
{formatLargeNumber(total)}
</span>
<span
style={{
fontSize: 12,
color: token.colorTextTertiary,
marginTop: 4
}}
>
{label}
</span>
</div>
);
export default UsageByModel;
@@ -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
};
}