diff --git a/src/locales/en-US/dashboard.ts b/src/locales/en-US/dashboard.ts
index 8e6c7b1b..df9e4e87 100644
--- a/src/locales/en-US/dashboard.ts
+++ b/src/locales/en-US/dashboard.ts
@@ -11,6 +11,8 @@ export default {
'dashboard.vramutilization': 'Average VRAM Utilization',
'dashboard.gpuutilization': 'Average GPU Utilization',
'dashboard.usage': 'Usage',
+ 'dashboard.usage.title': 'Last {days} days usage',
+ 'dashboard.usage.others': 'Others',
'dashboard.tokens': 'Token Usage',
'dashboard.topusers': 'Top Users',
'dashboard.activeDeployments': 'Active Deployments',
diff --git a/src/locales/ja-JP/dashboard.ts b/src/locales/ja-JP/dashboard.ts
index c947ab5d..86d6385e 100644
--- a/src/locales/ja-JP/dashboard.ts
+++ b/src/locales/ja-JP/dashboard.ts
@@ -10,6 +10,8 @@ export default {
'dashboard.vramutilization': '平均VRAM使用率',
'dashboard.gpuutilization': '平均GPU使用率',
'dashboard.usage': '使用状況',
+ 'dashboard.usage.title': '過去 {days} 日間の使用状況',
+ 'dashboard.usage.others': 'その他',
'dashboard.tokens': 'トークン使用量',
'dashboard.topusers': 'トップユーザー',
'dashboard.activeDeployments': 'Active Deployments',
diff --git a/src/locales/ru-RU/dashboard.ts b/src/locales/ru-RU/dashboard.ts
index dcb9dfd4..0e3d8729 100644
--- a/src/locales/ru-RU/dashboard.ts
+++ b/src/locales/ru-RU/dashboard.ts
@@ -10,6 +10,8 @@ export default {
'dashboard.vramutilization': 'Средняя загрузка видеопамяти',
'dashboard.gpuutilization': 'Средняя загрузка GPU',
'dashboard.usage': 'Использование',
+ 'dashboard.usage.title': 'Использование за последние {days} дн.',
+ 'dashboard.usage.others': 'Прочее',
'dashboard.tokens': 'Использование токенов',
'dashboard.topusers': 'Топ пользователей',
'dashboard.activeDeployments': 'Active Deployments',
diff --git a/src/locales/tr-TR/dashboard.ts b/src/locales/tr-TR/dashboard.ts
index 3400883f..59db41f9 100644
--- a/src/locales/tr-TR/dashboard.ts
+++ b/src/locales/tr-TR/dashboard.ts
@@ -11,6 +11,8 @@ export default {
'dashboard.vramutilization': 'Ortalama VRAM Kullanımı',
'dashboard.gpuutilization': 'Ortalama GPU Kullanımı',
'dashboard.usage': 'Kullanım',
+ 'dashboard.usage.title': 'Son {days} günün kullanımı',
+ 'dashboard.usage.others': 'Diğer',
'dashboard.tokens': 'Token Kullanımı',
'dashboard.topusers': 'En Aktif Kullanıcılar',
'dashboard.activeDeployments': 'Active Deployments',
diff --git a/src/locales/zh-CN/dashboard.ts b/src/locales/zh-CN/dashboard.ts
index 595a862a..b9052ecf 100644
--- a/src/locales/zh-CN/dashboard.ts
+++ b/src/locales/zh-CN/dashboard.ts
@@ -11,6 +11,8 @@ export default {
'dashboard.vramutilization': '平均显存利用率',
'dashboard.gpuutilization': '平均 GPU 利用率',
'dashboard.usage': '使用量',
+ 'dashboard.usage.title': '最近 {days} 天使用量',
+ 'dashboard.usage.others': '其他',
'dashboard.tokens': 'Token 使用量',
'dashboard.topusers': '用户排行',
'dashboard.activeDeployments': '活跃部署',
diff --git a/src/pages/dashboard/components/new-usage.tsx b/src/pages/dashboard/components/new-usage.tsx
index af017b34..ea4de1f9 100644
--- a/src/pages/dashboard/components/new-usage.tsx
+++ b/src/pages/dashboard/components/new-usage.tsx
@@ -17,12 +17,19 @@ const Section = styled.div`
margin-top: 16px;
`;
+// Rolling lookback window applied to every chart in the usage section.
+// Keep the constant and the date math wired together so the label and the
+// API request can't drift apart.
+const USAGE_LOOKBACK_DAYS = 30;
+
const NewUsage = () => {
const intl = useIntl();
const dateRange = useMemo(
() => ({
- start_date: dayjs().subtract(29, 'days').format('YYYY-MM-DD'),
+ start_date: dayjs()
+ .subtract(USAGE_LOOKBACK_DAYS - 1, 'days')
+ .format('YYYY-MM-DD'),
end_date: dayjs().format('YYYY-MM-DD')
}),
[]
@@ -50,7 +57,10 @@ const NewUsage = () => {
style={{ margin: '24px 0 0' }}
left={
- {intl.formatMessage({ id: 'dashboard.usage' })}
+ {intl.formatMessage(
+ { id: 'dashboard.usage.title' },
+ { days: USAGE_LOOKBACK_DAYS }
+ )}
}
/>
diff --git a/src/pages/dashboard/components/usage-charts/usage-by-model.tsx b/src/pages/dashboard/components/usage-charts/usage-by-model.tsx
index 77f78de7..546dbdf2 100644
--- a/src/pages/dashboard/components/usage-charts/usage-by-model.tsx
+++ b/src/pages/dashboard/components/usage-charts/usage-by-model.tsx
@@ -5,7 +5,7 @@ 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 { useEffect, useMemo, useRef } from 'react';
import {
DashboardUsageCommonParams,
getUsageSummary,
@@ -29,6 +29,12 @@ const HTML_ESCAPES: Record = {
const escapeHtml = (value: unknown): string =>
String(value ?? '').replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] ?? c);
+// Cap individual entries in the shared legend. Anything beyond this rolls up
+// into a single "Others" bucket. The cap is filled by alternating top picks
+// from the API-request and token rankings (request-priority first), so both
+// metrics get fair representation even when leaders heavily overlap.
+const MAX_LEGEND_NAMED_ITEMS = 10;
+
interface UsageByModelProps {
commonParams: DashboardUsageCommonParams;
height?: number;
@@ -41,6 +47,7 @@ const UsageByModel: React.FC = ({
const intl = useIntl();
const { token } = theme.useToken();
const generateCoolColors = useCoolColors();
+ const chartRef = useRef<{ chart: any } | null>(null);
const tokenQuery = useQueryTimeSeriesData({ key: 'tokenUsageByModelData' });
const apiQuery = useQueryTimeSeriesData({ key: 'apiRequestsByModelData' });
@@ -87,13 +94,84 @@ const UsageByModel: React.FC = ({
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 tokenLabel = intl.formatMessage({ id: 'usage.filter.totalTokens' });
+ const apiLabel = intl.formatMessage({ id: 'usage.filter.apiRequests' });
+ const othersLabel = intl.formatMessage({ id: 'dashboard.usage.others' });
+
+ // Pick legend entries by alternating between the two metrics' ranked
+ // lists — API request first (request-priority), then tokens, repeat.
+ // Each turn advances its own cursor past entries already picked from the
+ // other side, so overlapping leaders don't burn the alternation slot
+ // (and both metrics get fair representation when sets diverge).
+ // Everything beyond the cap collapses into a single Others bucket.
+ const pickedNames = useMemo(() => {
+ const apiSorted = [...apiData]
+ .sort((a, b) => b.value - a.value)
+ .map((d) => d.name);
+ const tokenSorted = [...tokenData]
+ .sort((a, b) => b.value - a.value)
+ .map((d) => d.name);
+ const picked = new Set();
+ let apiIdx = 0;
+ let tokenIdx = 0;
+
+ const takeFromApi = (): boolean => {
+ while (apiIdx < apiSorted.length && picked.has(apiSorted[apiIdx]))
+ apiIdx++;
+ if (apiIdx >= apiSorted.length) return false;
+ picked.add(apiSorted[apiIdx++]);
+ return true;
+ };
+
+ const takeFromToken = (): boolean => {
+ while (tokenIdx < tokenSorted.length && picked.has(tokenSorted[tokenIdx]))
+ tokenIdx++;
+ if (tokenIdx >= tokenSorted.length) return false;
+ picked.add(tokenSorted[tokenIdx++]);
+ return true;
+ };
+
+ while (picked.size < MAX_LEGEND_NAMED_ITEMS) {
+ const apiOk = takeFromApi();
+ if (picked.size >= MAX_LEGEND_NAMED_ITEMS) break;
+ const tokenOk = takeFromToken();
+ if (!apiOk && !tokenOk) break;
+ }
+
+ return picked;
+ }, [tokenData, apiData]);
+
+ // Project raw data into (pickedRoutes..., Others). Others is only appended
+ // when at least one route was rolled up.
+ const project = (rows: UsageChartDatum[]): UsageChartDatum[] => {
+ const kept: UsageChartDatum[] = [];
+ let othersValue = 0;
+ for (const row of rows) {
+ if (pickedNames.has(row.name)) kept.push(row);
+ else othersValue += row.value;
+ }
+ if (othersValue > 0) kept.push({ name: othersLabel, value: othersValue });
+ return kept;
+ };
+
+ const projectedTokenData = useMemo(
+ () => project(tokenData),
+ [tokenData, pickedNames, othersLabel]
+ );
+ const projectedApiData = useMemo(
+ () => project(apiData),
+ [apiData, pickedNames, othersLabel]
+ );
+
+ // Build a shared name->color map from the projected data so legend toggles
+ // both donuts together and matching slices read the same color. Others
+ // sticks to the end and uses a muted token so it visually reads as "the
+ // long tail" rather than a real route.
const { legendNames, colorMap } = useMemo(() => {
const names: string[] = [];
const seen = new Set();
- [...tokenData, ...apiData].forEach((d) => {
- if (!seen.has(d.name)) {
+ [...projectedTokenData, ...projectedApiData].forEach((d) => {
+ if (!seen.has(d.name) && d.name !== othersLabel) {
seen.add(d.name);
names.push(d.name);
}
@@ -103,13 +181,24 @@ const UsageByModel: React.FC = ({
names.forEach((name, i) => {
map[name] = colors[i];
});
+ const hasOthers = [...projectedTokenData, ...projectedApiData].some(
+ (d) => d.name === othersLabel
+ );
+ if (hasOthers) {
+ names.push(othersLabel);
+ map[othersLabel] = token.colorTextQuaternary;
+ }
return { legendNames: names, colorMap: map };
- }, [tokenData, apiData, generateCoolColors]);
+ }, [
+ projectedTokenData,
+ projectedApiData,
+ generateCoolColors,
+ othersLabel,
+ token
+ ]);
- 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 isEmpty =
+ projectedTokenData.length === 0 && projectedApiData.length === 0;
const isLoading = tokenQuery.loading || apiQuery.loading;
const options = useMemo(() => {
@@ -141,8 +230,16 @@ const UsageByModel: React.FC = ({
}
},
legend: {
- type: 'scroll',
orient: 'vertical',
+ // Anchor the legend column's LEFT edge at 85% of the chart canvas
+ // (right edge fills to canvas right). ECharts' legend.align only
+ // controls intra-item marker/text ordering — not item-in-container
+ // alignment — so a right-anchored + fixed-width legend always
+ // packs items flush right. Anchoring from the left instead makes
+ // items naturally start at the container's left edge, so a short
+ // label like `qwen3-0.6b` stays close to the donut instead of
+ // floating in a void at the canvas edge.
+ left: '85%',
right: 0,
top: 18,
bottom: 18,
@@ -152,11 +249,10 @@ const UsageByModel: React.FC = ({
textStyle: {
color: token.colorTextTertiary,
overflow: 'truncate',
- width: 140
+ // 15% of typical xl card canvas (~680-900px) ≈ 102-135px column;
+ // marker(8) + gap(5) + 90 = 103 fits even at the narrow end.
+ width: 90
},
- pageTextStyle: { color: token.colorTextTertiary },
- pageIconColor: token.colorTextTertiary,
- pageIconInactiveColor: token.colorTextDisabled,
data: legendNames,
show: legendNames.length > 0
},
@@ -170,15 +266,9 @@ const UsageByModel: React.FC = ({
label: { show: false },
labelLine: { show: false },
emphasis: {
- label: {
- show: true,
- formatter: (params: any) => `${params.percent}%`,
- fontSize: 14,
- fontWeight: 600,
- color: token.colorText
- }
+ label: { show: false }
},
- data: paintData(tokenData)
+ data: paintData(projectedTokenData)
},
{
name: apiLabel,
@@ -189,19 +279,72 @@ const UsageByModel: React.FC = ({
label: { show: false },
labelLine: { show: false },
emphasis: {
- label: {
- show: true,
- formatter: (params: any) => `${params.percent}%`,
- fontSize: 14,
- fontWeight: 600,
- color: token.colorText
- }
+ label: { show: false }
},
- data: paintData(apiData)
+ data: paintData(projectedApiData)
}
]
};
- }, [tokenData, apiData, colorMap, legendNames, token, tokenLabel, apiLabel]);
+ }, [
+ projectedTokenData,
+ projectedApiData,
+ colorMap,
+ legendNames,
+ token,
+ tokenLabel,
+ apiLabel
+ ]);
+
+ // Association: hovering a slice in one donut highlights the same route in
+ // the other donut. The Chart wrapper's imperative handle is committed
+ // before its own init effect runs, so chartRef can be { chart: undefined }
+ // on the first useEffect tick — poll via rAF until the instance shows up,
+ // then bind once. ECharts' clear() preserves user listeners, so we don't
+ // need to re-bind on every options change.
+ useEffect(() => {
+ let raf: number | undefined;
+ let detach: (() => void) | undefined;
+
+ const tryBind = () => {
+ const inst = chartRef.current?.chart;
+ if (!inst) {
+ raf = requestAnimationFrame(tryBind);
+ return;
+ }
+
+ const dispatchFor = (params: any, type: 'highlight' | 'downplay') => {
+ if (
+ params.componentType !== 'series' ||
+ typeof params.seriesIndex !== 'number'
+ )
+ return;
+ const otherIndex = params.seriesIndex === 0 ? 1 : 0;
+ inst.dispatchAction({
+ type,
+ seriesIndex: otherIndex,
+ name: params.name
+ });
+ };
+
+ const handleOver = (params: any) => dispatchFor(params, 'highlight');
+ const handleOut = (params: any) => dispatchFor(params, 'downplay');
+
+ inst.on('mouseover', handleOver);
+ inst.on('mouseout', handleOut);
+
+ detach = () => {
+ inst.off('mouseover', handleOver);
+ inst.off('mouseout', handleOut);
+ };
+ };
+
+ tryBind();
+
+ return () => {
+ if (raf !== undefined) cancelAnimationFrame(raf);
+ detach?.();
+ };
+ }, []);
return (
= ({
) : (
<>
-
+