feat: association dounut effect

This commit is contained in:
Yuxing Deng
2026-05-20 11:29:43 +08:00
committed by jialin
parent c38fcc494a
commit 15cf4d89b7
7 changed files with 203 additions and 35 deletions
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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': '活跃部署',
+12 -2
View File
@@ -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={
<span className="font-700">
{intl.formatMessage({ id: 'dashboard.usage' })}
{intl.formatMessage(
{ id: 'dashboard.usage.title' },
{ days: USAGE_LOOKBACK_DAYS }
)}
</span>
}
/>
@@ -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<string, string> = {
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<UsageByModelProps> = ({
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<UsageByModelProps> = ({
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<string>();
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<string>();
[...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<UsageByModelProps> = ({
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<UsageByModelProps> = ({
}
},
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<UsageByModelProps> = ({
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<UsageByModelProps> = ({
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<UsageByModelProps> = ({
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 (
<UsageChartCard
@@ -222,7 +365,12 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
</div>
) : (
<>
<Chart options={options as any} height={height} width="100%" />
<Chart
ref={chartRef as any}
options={options as any}
height={height}
width="100%"
/>
<DonutCenter
left="20%"
top="50%"