From 15cf4d89b75e24a797a58dbdb2717fc9975d9654 Mon Sep 17 00:00:00 2001 From: Yuxing Deng Date: Tue, 19 May 2026 17:36:43 +0800 Subject: [PATCH] feat: association dounut effect --- src/locales/en-US/dashboard.ts | 2 + src/locales/ja-JP/dashboard.ts | 2 + src/locales/ru-RU/dashboard.ts | 2 + src/locales/tr-TR/dashboard.ts | 2 + src/locales/zh-CN/dashboard.ts | 2 + src/pages/dashboard/components/new-usage.tsx | 14 +- .../usage-charts/usage-by-model.tsx | 214 +++++++++++++++--- 7 files changed, 203 insertions(+), 35 deletions(-) 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 ( = ({ ) : ( <> - +