feat: association dounut effect
This commit is contained in:
@@ -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%"
|
||||
|
||||
Reference in New Issue
Block a user