feat: association dounut effect
This commit is contained in:
@@ -11,6 +11,8 @@ export default {
|
|||||||
'dashboard.vramutilization': 'Average VRAM Utilization',
|
'dashboard.vramutilization': 'Average VRAM Utilization',
|
||||||
'dashboard.gpuutilization': 'Average GPU Utilization',
|
'dashboard.gpuutilization': 'Average GPU Utilization',
|
||||||
'dashboard.usage': 'Usage',
|
'dashboard.usage': 'Usage',
|
||||||
|
'dashboard.usage.title': 'Last {days} days usage',
|
||||||
|
'dashboard.usage.others': 'Others',
|
||||||
'dashboard.tokens': 'Token Usage',
|
'dashboard.tokens': 'Token Usage',
|
||||||
'dashboard.topusers': 'Top Users',
|
'dashboard.topusers': 'Top Users',
|
||||||
'dashboard.activeDeployments': 'Active Deployments',
|
'dashboard.activeDeployments': 'Active Deployments',
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export default {
|
|||||||
'dashboard.vramutilization': '平均VRAM使用率',
|
'dashboard.vramutilization': '平均VRAM使用率',
|
||||||
'dashboard.gpuutilization': '平均GPU使用率',
|
'dashboard.gpuutilization': '平均GPU使用率',
|
||||||
'dashboard.usage': '使用状況',
|
'dashboard.usage': '使用状況',
|
||||||
|
'dashboard.usage.title': '過去 {days} 日間の使用状況',
|
||||||
|
'dashboard.usage.others': 'その他',
|
||||||
'dashboard.tokens': 'トークン使用量',
|
'dashboard.tokens': 'トークン使用量',
|
||||||
'dashboard.topusers': 'トップユーザー',
|
'dashboard.topusers': 'トップユーザー',
|
||||||
'dashboard.activeDeployments': 'Active Deployments',
|
'dashboard.activeDeployments': 'Active Deployments',
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export default {
|
|||||||
'dashboard.vramutilization': 'Средняя загрузка видеопамяти',
|
'dashboard.vramutilization': 'Средняя загрузка видеопамяти',
|
||||||
'dashboard.gpuutilization': 'Средняя загрузка GPU',
|
'dashboard.gpuutilization': 'Средняя загрузка GPU',
|
||||||
'dashboard.usage': 'Использование',
|
'dashboard.usage': 'Использование',
|
||||||
|
'dashboard.usage.title': 'Использование за последние {days} дн.',
|
||||||
|
'dashboard.usage.others': 'Прочее',
|
||||||
'dashboard.tokens': 'Использование токенов',
|
'dashboard.tokens': 'Использование токенов',
|
||||||
'dashboard.topusers': 'Топ пользователей',
|
'dashboard.topusers': 'Топ пользователей',
|
||||||
'dashboard.activeDeployments': 'Active Deployments',
|
'dashboard.activeDeployments': 'Active Deployments',
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export default {
|
|||||||
'dashboard.vramutilization': 'Ortalama VRAM Kullanımı',
|
'dashboard.vramutilization': 'Ortalama VRAM Kullanımı',
|
||||||
'dashboard.gpuutilization': 'Ortalama GPU Kullanımı',
|
'dashboard.gpuutilization': 'Ortalama GPU Kullanımı',
|
||||||
'dashboard.usage': '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.tokens': 'Token Kullanımı',
|
||||||
'dashboard.topusers': 'En Aktif Kullanıcılar',
|
'dashboard.topusers': 'En Aktif Kullanıcılar',
|
||||||
'dashboard.activeDeployments': 'Active Deployments',
|
'dashboard.activeDeployments': 'Active Deployments',
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export default {
|
|||||||
'dashboard.vramutilization': '平均显存利用率',
|
'dashboard.vramutilization': '平均显存利用率',
|
||||||
'dashboard.gpuutilization': '平均 GPU 利用率',
|
'dashboard.gpuutilization': '平均 GPU 利用率',
|
||||||
'dashboard.usage': '使用量',
|
'dashboard.usage': '使用量',
|
||||||
|
'dashboard.usage.title': '最近 {days} 天使用量',
|
||||||
|
'dashboard.usage.others': '其他',
|
||||||
'dashboard.tokens': 'Token 使用量',
|
'dashboard.tokens': 'Token 使用量',
|
||||||
'dashboard.topusers': '用户排行',
|
'dashboard.topusers': '用户排行',
|
||||||
'dashboard.activeDeployments': '活跃部署',
|
'dashboard.activeDeployments': '活跃部署',
|
||||||
|
|||||||
@@ -17,12 +17,19 @@ const Section = styled.div`
|
|||||||
margin-top: 16px;
|
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 NewUsage = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const dateRange = useMemo(
|
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')
|
end_date: dayjs().format('YYYY-MM-DD')
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
@@ -50,7 +57,10 @@ const NewUsage = () => {
|
|||||||
style={{ margin: '24px 0 0' }}
|
style={{ margin: '24px 0 0' }}
|
||||||
left={
|
left={
|
||||||
<span className="font-700">
|
<span className="font-700">
|
||||||
{intl.formatMessage({ id: 'dashboard.usage' })}
|
{intl.formatMessage(
|
||||||
|
{ id: 'dashboard.usage.title' },
|
||||||
|
{ days: USAGE_LOOKBACK_DAYS }
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { formatLargeNumber } from '@gpustack/core-ui/utils';
|
|||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import type { GlobalToken } from 'antd';
|
import type { GlobalToken } from 'antd';
|
||||||
import { Empty, Spin, theme } from 'antd';
|
import { Empty, Spin, theme } from 'antd';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
DashboardUsageCommonParams,
|
DashboardUsageCommonParams,
|
||||||
getUsageSummary,
|
getUsageSummary,
|
||||||
@@ -29,6 +29,12 @@ const HTML_ESCAPES: Record<string, string> = {
|
|||||||
const escapeHtml = (value: unknown): string =>
|
const escapeHtml = (value: unknown): string =>
|
||||||
String(value ?? '').replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] ?? c);
|
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 {
|
interface UsageByModelProps {
|
||||||
commonParams: DashboardUsageCommonParams;
|
commonParams: DashboardUsageCommonParams;
|
||||||
height?: number;
|
height?: number;
|
||||||
@@ -41,6 +47,7 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const generateCoolColors = useCoolColors();
|
const generateCoolColors = useCoolColors();
|
||||||
|
const chartRef = useRef<{ chart: any } | null>(null);
|
||||||
|
|
||||||
const tokenQuery = useQueryTimeSeriesData({ key: 'tokenUsageByModelData' });
|
const tokenQuery = useQueryTimeSeriesData({ key: 'tokenUsageByModelData' });
|
||||||
const apiQuery = useQueryTimeSeriesData({ key: 'apiRequestsByModelData' });
|
const apiQuery = useQueryTimeSeriesData({ key: 'apiRequestsByModelData' });
|
||||||
@@ -87,13 +94,84 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
|
|||||||
return Number(summary?.api_requests ?? 0);
|
return Number(summary?.api_requests ?? 0);
|
||||||
}, [apiQuery.detailData]);
|
}, [apiQuery.detailData]);
|
||||||
|
|
||||||
// Build a shared name->color map so legend toggles both donuts and
|
const tokenLabel = intl.formatMessage({ id: 'usage.filter.totalTokens' });
|
||||||
// matching slices read the same color across the pair.
|
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 { legendNames, colorMap } = useMemo(() => {
|
||||||
const names: string[] = [];
|
const names: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
[...tokenData, ...apiData].forEach((d) => {
|
[...projectedTokenData, ...projectedApiData].forEach((d) => {
|
||||||
if (!seen.has(d.name)) {
|
if (!seen.has(d.name) && d.name !== othersLabel) {
|
||||||
seen.add(d.name);
|
seen.add(d.name);
|
||||||
names.push(d.name);
|
names.push(d.name);
|
||||||
}
|
}
|
||||||
@@ -103,13 +181,24 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
|
|||||||
names.forEach((name, i) => {
|
names.forEach((name, i) => {
|
||||||
map[name] = colors[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 };
|
return { legendNames: names, colorMap: map };
|
||||||
}, [tokenData, apiData, generateCoolColors]);
|
}, [
|
||||||
|
projectedTokenData,
|
||||||
|
projectedApiData,
|
||||||
|
generateCoolColors,
|
||||||
|
othersLabel,
|
||||||
|
token
|
||||||
|
]);
|
||||||
|
|
||||||
const tokenLabel = intl.formatMessage({ id: 'usage.filter.totalTokens' });
|
const isEmpty =
|
||||||
const apiLabel = intl.formatMessage({ id: 'usage.filter.apiRequests' });
|
projectedTokenData.length === 0 && projectedApiData.length === 0;
|
||||||
|
|
||||||
const isEmpty = tokenData.length === 0 && apiData.length === 0;
|
|
||||||
const isLoading = tokenQuery.loading || apiQuery.loading;
|
const isLoading = tokenQuery.loading || apiQuery.loading;
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
@@ -141,8 +230,16 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
type: 'scroll',
|
|
||||||
orient: 'vertical',
|
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,
|
right: 0,
|
||||||
top: 18,
|
top: 18,
|
||||||
bottom: 18,
|
bottom: 18,
|
||||||
@@ -152,11 +249,10 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
|
|||||||
textStyle: {
|
textStyle: {
|
||||||
color: token.colorTextTertiary,
|
color: token.colorTextTertiary,
|
||||||
overflow: 'truncate',
|
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,
|
data: legendNames,
|
||||||
show: legendNames.length > 0
|
show: legendNames.length > 0
|
||||||
},
|
},
|
||||||
@@ -170,15 +266,9 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
|
|||||||
label: { show: false },
|
label: { show: false },
|
||||||
labelLine: { show: false },
|
labelLine: { show: false },
|
||||||
emphasis: {
|
emphasis: {
|
||||||
label: {
|
label: { show: false }
|
||||||
show: true,
|
|
||||||
formatter: (params: any) => `${params.percent}%`,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: token.colorText
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
data: paintData(tokenData)
|
data: paintData(projectedTokenData)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: apiLabel,
|
name: apiLabel,
|
||||||
@@ -189,19 +279,72 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
|
|||||||
label: { show: false },
|
label: { show: false },
|
||||||
labelLine: { show: false },
|
labelLine: { show: false },
|
||||||
emphasis: {
|
emphasis: {
|
||||||
label: {
|
label: { show: false }
|
||||||
show: true,
|
|
||||||
formatter: (params: any) => `${params.percent}%`,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: token.colorText
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
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 (
|
return (
|
||||||
<UsageChartCard
|
<UsageChartCard
|
||||||
@@ -222,7 +365,12 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Chart options={options as any} height={height} width="100%" />
|
<Chart
|
||||||
|
ref={chartRef as any}
|
||||||
|
options={options as any}
|
||||||
|
height={height}
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
<DonutCenter
|
<DonutCenter
|
||||||
left="20%"
|
left="20%"
|
||||||
top="50%"
|
top="50%"
|
||||||
|
|||||||
Reference in New Issue
Block a user