refactor: usage chart

This commit is contained in:
jialin
2026-04-29 09:29:04 +08:00
committed by jialin
parent e7aac14ef1
commit 26880b4612
23 changed files with 720 additions and 170 deletions
+1 -1
View File
@@ -22,7 +22,7 @@
"@ant-design/icons": "^6.1.0", "@ant-design/icons": "^6.1.0",
"@ant-design/pro-components": "3.1.0-0", "@ant-design/pro-components": "3.1.0-0",
"@braintree/sanitize-url": "^7.1.1", "@braintree/sanitize-url": "^7.1.1",
"@gpustack/core-ui": "^1.0.3", "@gpustack/core-ui": "^1.0.4",
"@huggingface/gguf": "^0.1.7", "@huggingface/gguf": "^0.1.7",
"@huggingface/hub": "^0.15.1", "@huggingface/hub": "^0.15.1",
"@huggingface/tasks": "^0.11.6", "@huggingface/tasks": "^0.11.6",
+5 -4
View File
@@ -18,8 +18,8 @@ dependencies:
specifier: ^7.1.1 specifier: ^7.1.1
version: 7.1.1 version: 7.1.1
'@gpustack/core-ui': '@gpustack/core-ui':
specifier: ^1.0.3 specifier: ^1.0.4
version: 1.0.3(@ant-design/icons@6.1.0)(@monaco-editor/react@4.6.0)(@types/react@18.3.27)(ahooks@3.9.6)(antd-style@3.7.1)(antd@6.3.3)(axios@1.13.2)(echarts@5.6.0)(file-saver@2.0.5)(monaco-editor@0.30.1)(monaco-yaml@4.0.0)(overlayscrollbars-react@0.5.6)(react-dom@18.3.1)(react@18.3.1)(styled-components@6.2.0) version: 1.0.4(@ant-design/icons@6.1.0)(@monaco-editor/react@4.6.0)(@types/react@18.3.27)(ahooks@3.9.6)(antd-style@3.7.1)(antd@6.3.3)(axios@1.13.2)(echarts@5.6.0)(file-saver@2.0.5)(monaco-editor@0.30.1)(monaco-yaml@4.0.0)(overlayscrollbars-react@0.5.6)(react-dom@18.3.1)(react@18.3.1)(styled-components@6.2.0)
'@huggingface/gguf': '@huggingface/gguf':
specifier: ^0.1.7 specifier: ^0.1.7
version: 0.1.18 version: 0.1.18
@@ -2905,8 +2905,8 @@ packages:
resolution: {integrity: sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==, tarball: https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz} resolution: {integrity: sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==, tarball: https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz}
deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package
/@gpustack/core-ui@1.0.3(@ant-design/icons@6.1.0)(@monaco-editor/react@4.6.0)(@types/react@18.3.27)(ahooks@3.9.6)(antd-style@3.7.1)(antd@6.3.3)(axios@1.13.2)(echarts@5.6.0)(file-saver@2.0.5)(monaco-editor@0.30.1)(monaco-yaml@4.0.0)(overlayscrollbars-react@0.5.6)(react-dom@18.3.1)(react@18.3.1)(styled-components@6.2.0): /@gpustack/core-ui@1.0.4(@ant-design/icons@6.1.0)(@monaco-editor/react@4.6.0)(@types/react@18.3.27)(ahooks@3.9.6)(antd-style@3.7.1)(antd@6.3.3)(axios@1.13.2)(echarts@5.6.0)(file-saver@2.0.5)(monaco-editor@0.30.1)(monaco-yaml@4.0.0)(overlayscrollbars-react@0.5.6)(react-dom@18.3.1)(react@18.3.1)(styled-components@6.2.0):
resolution: {integrity: sha512-DFl5Sh4zAVYBmGx905l4ECV5ZtbAn2S1SZTycfMw48iisveGbx//SQU8mktdkcllzoGCMGurbX0NQvlyciB0OA==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.3.tgz} resolution: {integrity: sha512-8v/rduG6fjmVEEZ8/IlccFMrfHryGRGdAt2yCRdYbCRsQ/wWoe4WgcIkFtQwDeJKB9ONEjggApT5SCvY6ST+hg==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.4.tgz}
peerDependencies: peerDependencies:
'@ant-design/icons': '>=6.0.0' '@ant-design/icons': '>=6.0.0'
'@monaco-editor/react': ^4.6.0 '@monaco-editor/react': ^4.6.0
@@ -2984,6 +2984,7 @@ packages:
/@hono/node-ws@1.3.0(@hono/node-server@1.19.11)(hono@4.12.8): /@hono/node-ws@1.3.0(@hono/node-server@1.19.11)(hono@4.12.8):
resolution: {integrity: sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q==, tarball: https://registry.npmjs.org/@hono/node-ws/-/node-ws-1.3.0.tgz} resolution: {integrity: sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q==, tarball: https://registry.npmjs.org/@hono/node-ws/-/node-ws-1.3.0.tgz}
engines: {node: '>=18.14.1'} engines: {node: '>=18.14.1'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies: peerDependencies:
'@hono/node-server': ^1.19.2 '@hono/node-server': ^1.19.2
hono: ^4.6.0 hono: ^4.6.0
+5 -1
View File
@@ -28,5 +28,9 @@ export default {
'usage.user.currentAccount': 'Current Account', 'usage.user.currentAccount': 'Current Account',
'usage.table.cluster': 'Cluster', 'usage.table.cluster': 'Cluster',
'usage.table.provider': 'Provider', 'usage.table.provider': 'Provider',
'usage.table.deleted': 'Deleted' 'usage.table.deleted': 'Deleted',
'usage.chart.cached': 'Cached',
'usage.chart.uncached': 'Uncached',
'usage.chart.inputTokensCached': 'Input Tokens (Cached/Uncached)',
'usage.table.inputTokensCached': 'Input Tokens Cached'
}; };
+5 -1
View File
@@ -28,5 +28,9 @@ export default {
'usage.user.currentAccount': 'Current Account', 'usage.user.currentAccount': 'Current Account',
'usage.table.cluster': 'Cluster', 'usage.table.cluster': 'Cluster',
'usage.table.provider': 'Provider', 'usage.table.provider': 'Provider',
'usage.table.deleted': 'Deleted' 'usage.table.deleted': 'Deleted',
'usage.chart.cached': 'Cached',
'usage.chart.uncached': 'Uncached',
'usage.chart.inputTokensCached': 'Input Tokens (Cached/Uncached)',
'usage.table.inputTokensCached': 'Input Tokens Cached'
}; };
+5 -1
View File
@@ -28,5 +28,9 @@ export default {
'usage.user.currentAccount': 'Current Account', 'usage.user.currentAccount': 'Current Account',
'usage.table.cluster': 'Cluster', 'usage.table.cluster': 'Cluster',
'usage.table.provider': 'Provider', 'usage.table.provider': 'Provider',
'usage.table.deleted': 'Deleted' 'usage.table.deleted': 'Deleted',
'usage.chart.cached': 'Cached',
'usage.chart.uncached': 'Uncached',
'usage.chart.inputTokensCached': 'Input Tokens (Cached/Uncached)',
'usage.table.inputTokensCached': 'Input Tokens Cached'
}; };
+5 -1
View File
@@ -28,5 +28,9 @@ export default {
'usage.user.currentAccount': 'Current Account', 'usage.user.currentAccount': 'Current Account',
'usage.table.cluster': 'Cluster', 'usage.table.cluster': 'Cluster',
'usage.table.provider': 'Provider', 'usage.table.provider': 'Provider',
'usage.table.deleted': 'Deleted' 'usage.table.deleted': 'Deleted',
'usage.chart.cached': 'Cached',
'usage.chart.uncached': 'Uncached',
'usage.chart.inputTokensCached': 'Input Tokens (Cached/Uncached)',
'usage.table.inputTokensCached': 'Input Tokens Cached'
}; };
+5 -1
View File
@@ -28,5 +28,9 @@ export default {
'usage.user.currentAccount': '当前账户', 'usage.user.currentAccount': '当前账户',
'usage.table.cluster': '集群', 'usage.table.cluster': '集群',
'usage.table.provider': '提供商', 'usage.table.provider': '提供商',
'usage.table.deleted': '已删除' 'usage.table.deleted': '已删除',
'usage.chart.cached': '缓存',
'usage.chart.uncached': '非缓存',
'usage.chart.inputTokensCached': '输入 Token 数(缓存/非缓存)',
'usage.table.inputTokensCached': '输入 Token 缓存数'
}; };
+282
View File
@@ -0,0 +1,282 @@
import { Chart } from '@gpustack/core-ui';
import { formatLargeNumber } from '@gpustack/core-ui/utils';
import { theme } from 'antd';
import _ from 'lodash';
import React, { useEffect, useMemo, useRef } from 'react';
const COOL_HUE_START = 180;
const COOL_HUE_END = 280;
const COOL_HUE_RANGE = COOL_HUE_END - COOL_HUE_START;
// golden angle (137.508) mod COOL_HUE_RANGE — coprime offset for even spread
const HUE_STEP = 37.508;
export function generateCoolColors(count: number): string[] {
if (count <= 0) return [];
const colors: string[] = [];
for (let i = 0; i < count; i++) {
const hue = COOL_HUE_START + ((i * HUE_STEP) % COOL_HUE_RANGE);
const saturation = 55 + (i % 3) * 5;
const lightness = 60 + (i % 2) * 6;
colors.push(`hsl(${Math.round(hue)}, ${saturation}%, ${lightness}%)`);
}
return colors;
}
export interface BarSeriesItem {
name: string;
data: any[];
color?: string;
stack?: string | false;
}
export interface BarChartProps {
seriesData: BarSeriesItem[];
xAxisData: string[];
height: number | string;
width?: number | string;
legendData?: { name: string; icon?: string }[];
labelFormatter?: (val: any, index?: number) => string;
tooltipValueFormatter?: (val: any) => string;
title?: string;
legendIsolate?: boolean;
}
const BarChart: React.FC<BarChartProps> = (props) => {
const {
seriesData,
xAxisData,
height,
width,
legendData,
labelFormatter,
tooltipValueFormatter,
title,
legendIsolate
} = props;
const { token } = theme.useToken();
const chartRef = useRef<{ chart: any } | null>(null);
const dynamicColors = useMemo(
() => generateCoolColors(seriesData.length),
[seriesData.length]
);
const options = useMemo(() => {
// Group series by stack name to compute top-of-stack per date
const stackGroups = new Map<string, BarSeriesItem[]>();
seriesData.forEach((s) => {
if (s.stack === false || !s.stack) return;
const list = stackGroups.get(s.stack) ?? [];
list.push(s);
stackGroups.set(s.stack, list);
});
// topMap[`${stack}::${dateIdx}`] = index within stackGroups list of the
// topmost visible (value > 0) series at that date
const topMap = new Map<string, number>();
stackGroups.forEach((stackSeries, stackKey) => {
const dateCount = Math.max(...stackSeries.map((s) => s.data.length), 0);
for (let dateIdx = 0; dateIdx < dateCount; dateIdx++) {
for (let i = stackSeries.length - 1; i >= 0; i--) {
const dp: any = stackSeries[i].data[dateIdx];
const value = dp && typeof dp === 'object' ? dp.value : dp;
if ((value ?? 0) > 0) {
topMap.set(`${stackKey}::${dateIdx}`, i);
break;
}
}
}
});
const series = _.map(seriesData, (item: BarSeriesItem, index: number) => {
const { stack, color, data, ...rest } = item;
const resolvedColor = color || dynamicColors[index];
const stackKey = stack === false || !stack ? null : (stack as string);
const stackList = stackKey ? stackGroups.get(stackKey) : null;
const indexInStack = stackList ? stackList.indexOf(item) : -1;
const processedData =
stackKey && stackList
? data.map((dp: any, dateIdx: number) => {
if (dp == null) return dp;
const isTop =
topMap.get(`${stackKey}::${dateIdx}`) === indexInStack;
const base = typeof dp === 'object' ? dp : { value: dp };
return {
...base,
itemStyle: {
borderRadius: isTop ? [2, 2, 0, 0] : 0
}
};
})
: data;
return {
...rest,
data: processedData,
type: 'bar',
barMaxWidth: 20,
barMinWidth: 8,
barGap: '30%',
barCategoryGap: '50%',
...(stack === false || stack === undefined ? {} : { stack }),
itemStyle: {
color: resolvedColor,
borderRadius: [2, 2, 0, 0]
}
};
});
return {
title: {
show: true,
left: 'center',
textStyle: { fontSize: 12, color: token.colorText },
text: title || ''
},
grid: {
left: 0,
right: 0,
top: title ? 30 : 10,
bottom: 28,
containLabel: true
},
tooltip: {
trigger: 'axis',
backgroundColor: token.colorBgElevated,
borderColor: 'transparent',
formatter: (params: any) => {
let result = `<span class="tooltip-x-name">${params[0].axisValue}</span>`;
let visibleItemCount = 0;
params.forEach((item: any) => {
const raw = item.data?.value ?? item.value;
const value = tooltipValueFormatter
? tooltipValueFormatter(raw)
: raw;
if (value === null || value === undefined) return;
visibleItemCount += 1;
const stackLabel = item.data?.stackLabel;
const baseName = item.data?.tooltipName ?? item.seriesName;
const displayName = stackLabel
? `${baseName} (${stackLabel})`
: baseName;
result += `<span class="tooltip-item">
<span class="tooltip-item-name">
<span class="tooltip-item-dot" style="border-radius:2px;background-color:${item.color};"></span>
<span class="tooltip-item-title">${displayName}</span>:
</span>
<span class="tooltip-value">${value}</span>
</span>`;
});
const wrapperClassName =
visibleItemCount >= 12
? 'tooltip-wrapper tooltip-grid'
: 'tooltip-wrapper';
return `<div class="${wrapperClassName}">${result}</div>`;
}
},
legend: {
itemWidth: 8,
itemHeight: 8,
itemGap: 12,
textStyle: { color: token.colorTextTertiary },
bottom: 0,
data: legendData,
show: !!legendData?.length
},
xAxis: {
type: 'category',
axisTick: {
show: true,
lineStyle: { color: token.colorSplit }
},
axisLabel: {
color: token.colorTextTertiary,
fontSize: 12,
formatter: labelFormatter
},
axisLine: { show: false },
data: xAxisData
},
yAxis: {
type: 'value',
nameTextStyle: { padding: [0, 0, 0, -20] },
splitLine: {
show: true,
lineStyle: { type: 'dashed', color: token.colorBorder }
},
axisLabel: {
color: token.colorTextTertiary,
fontSize: 12,
formatter: formatLargeNumber
},
axisTick: { show: false }
},
animation: false,
series
};
}, [
seriesData,
xAxisData,
dynamicColors,
labelFormatter,
tooltipValueFormatter,
legendData,
title,
token
]);
useEffect(() => {
if (!legendIsolate) return;
const chart = chartRef.current?.chart;
if (!chart) return;
const handler = (params: any) => {
const clicked = params.name;
const allNames = Object.keys(params.selected || {});
if (!allNames.length) return;
const isClickedSelected = !!params.selected[clicked];
const allOthersHidden = allNames
.filter((n) => n !== clicked)
.every((n) => !params.selected[n]);
let newSelected: Record<string, boolean>;
if (!isClickedSelected && allOthersHidden) {
// clicking the only-visible one → restore all
newSelected = Object.fromEntries(allNames.map((n) => [n, true]));
} else {
// isolate clicked
newSelected = Object.fromEntries(
allNames.map((n) => [n, n === clicked])
);
}
chart.setOption({ legend: { selected: newSelected } });
};
chart.on('legendselectchanged', handler);
return () => {
chart.off('legendselectchanged', handler);
};
}, [legendIsolate, seriesData, legendData]);
if (!seriesData.length) {
return <div style={{ width: width || '100%', height }} />;
}
return (
<Chart
ref={chartRef as any}
options={options as any}
height={height}
width={width || '100%'}
/>
);
};
export default BarChart;
+4 -5
View File
@@ -2,12 +2,11 @@ import { request } from '@umijs/max';
import { import {
BreakdownItem, BreakdownItem,
FilterOptionType, FilterOptionType,
TimeSeriesData, UsageBreakdownResponse,
UsageMeta UsageMeta
} from '../config/types'; } from '../config/types';
export const USAGE_META = '/usage/meta'; export const USAGE_META = '/usage/meta';
export const USAGE_TIMESERIES = '/usage/timeseries';
export const USAGE_BREAKDOWN = '/usage/breakdown'; export const USAGE_BREAKDOWN = '/usage/breakdown';
export const MODEL_ROUTE_TARGETS = '/model-route-targets'; export const MODEL_ROUTE_TARGETS = '/model-route-targets';
@@ -29,7 +28,7 @@ export async function queryUsageTimeSeriesData(
end_date: string; end_date: string;
scope: string; scope: string;
metric: string; metric: string;
group_by: string; group_by: string[];
granularity: string; granularity: string;
filters: { filters: {
models?: FilterOptionType[]; models?: FilterOptionType[];
@@ -38,8 +37,8 @@ export async function queryUsageTimeSeriesData(
}; };
}, },
options?: any options?: any
): Promise<TimeSeriesData> { ): Promise<UsageBreakdownResponse> {
return request<TimeSeriesData>(USAGE_TIMESERIES, { return request<UsageBreakdownResponse>(USAGE_BREAKDOWN, {
data: params, data: params,
method: 'POST', method: 'POST',
cancelToken: options?.token cancelToken: options?.token
+176 -58
View File
@@ -1,12 +1,16 @@
import { baseColorMap } from '@/pages/dashboard/config'; import BarChart, { generateCoolColors } from '@/pages/_components/bar-chart';
import { BaseSelect, CardWrapper, MixLineBarChart } from '@gpustack/core-ui'; import { BaseSelect, CardWrapper } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Segmented } from 'antd'; import { Segmented } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { granularities, groupByOptions, metricOptions } from '../config'; import { granularities, groupByOptions, metricOptions } from '../config';
import { TimeSeriesData } from '../config/types'; import {
BreakdownItem,
UsageBreakdownResponse,
UsageFilterItem
} from '../config/types';
const ControlsWrapper = styled.div` const ControlsWrapper = styled.div`
display: flex; display: flex;
@@ -26,34 +30,41 @@ const ControlLabel = styled.span`
margin-right: 8px; margin-right: 8px;
`; `;
function getColorForIndex(index: number) { const tooltipNameMap: Record<string, string> = {
const hue = (index * 137.5) % 360; input_cached_tokens: 'usage.filter.inputTokens'
return `oklch(70% 0.12 ${hue})`; };
}
const colors = [
baseColorMap.base,
baseColorMap.baseR3,
baseColorMap.baseL1,
baseColorMap.baseR1,
baseColorMap.baseR2,
baseColorMap.baseL2
];
interface DailyUsageProps { interface DailyUsageProps {
timeSeriesData: TimeSeriesData | null; timeSeriesData: UsageBreakdownResponse | null;
metric: string; metric: string;
groupBy: string | null; groupBy: string | null;
granularity: string; granularity: string;
startDate: string;
endDate: string;
onMetricChange: (value: string) => void; onMetricChange: (value: string) => void;
onGroupByChange: (value: string | null) => void; onGroupByChange: (value: string | null) => void;
onGranularityChange: (value: string) => void; onGranularityChange: (value: string) => void;
} }
const labelFormatter = (v: any) => { const generateDateRange = (
return dayjs(v).format('MM-DD'); start: string,
end: string,
granularity: string
): string[] => {
if (!start || !end) return [];
const unit = granularity as 'day' | 'week' | 'month';
const dates: string[] = [];
let cursor = dayjs(start);
const endDay = dayjs(end);
while (cursor.isSame(endDay, 'day') || cursor.isBefore(endDay, 'day')) {
dates.push(cursor.format('YYYY-MM-DD'));
cursor = cursor.add(1, unit);
}
return dates;
}; };
const CACHED_METRIC = 'input_cached_tokens';
const DailyUsage: React.FC<DailyUsageProps> = (props) => { const DailyUsage: React.FC<DailyUsageProps> = (props) => {
const intl = useIntl(); const intl = useIntl();
const { const {
@@ -61,60 +72,168 @@ const DailyUsage: React.FC<DailyUsageProps> = (props) => {
metric, metric,
groupBy, groupBy,
granularity, granularity,
startDate,
endDate,
onMetricChange, onMetricChange,
onGroupByChange, onGroupByChange,
onGranularityChange onGranularityChange
} = props; } = props;
const { chartData, xAxisData } = useMemo(() => { const labelFormatter = (v: any) => {
if (!timeSeriesData?.series || timeSeriesData.series.length === 0) { if (granularity === 'month') {
return { chartData: { line: [], bar: [] }, xAxisData: [] }; return dayjs(v).format('YYYY-MM');
}
return dayjs(v).format('MM-DD');
};
const { seriesData, xAxisData, legendData } = useMemo(() => {
const items = timeSeriesData?.items || [];
if (items.length === 0) {
return {
seriesData: [],
xAxisData: [],
legendData: []
};
} }
const series = timeSeriesData.series; const groupDim = groupBy as 'user' | 'model' | 'api_key' | null;
const dateSet = new Set<string>(); const isCached = metric === CACHED_METRIC;
for (const item of series) { const dateSet = new Set<string>(
for (const point of item.timeline) { generateDateRange(startDate, endDate, granularity)
dateSet.add(point.date); );
items.forEach((item) => {
if (item.date?.value) {
dateSet.add(item.date.value);
} }
} });
// sort dates in ascending order
const xAxis = Array.from(dateSet).sort( const xAxis = Array.from(dateSet).sort(
(a, b) => dayjs(a).valueOf() - dayjs(b).valueOf() (a, b) => dayjs(a).valueOf() - dayjs(b).valueOf()
); );
const chartSeries = series.map((item, index) => { const groupOrder: string[] = [];
const timelineMap = new Map( const groupItemsMap = new Map<string, Map<string, BreakdownItem>>();
item.timeline.map((point) => [
point.date,
{ time: point.date, value: point.value }
])
);
return { items.forEach((item) => {
name: item.label, const groupLabel = groupDim
data: xAxis.map((date) => timelineMap.get(date) ?? null), ? ((item[groupDim] as UsageFilterItem)?.label ?? '-')
color: colors[index % colors.length] : '__total__';
};
if (!groupItemsMap.has(groupLabel)) {
groupItemsMap.set(groupLabel, new Map());
groupOrder.push(groupLabel);
}
const dateKey = item.date?.value;
if (dateKey) {
groupItemsMap.get(groupLabel)!.set(dateKey, item);
}
}); });
// API requests use line chart, tokens use bar chart const metricOption = metricOptions.find((m) => m.value === metric);
const metricLabel = metricOption?.label
? intl.formatMessage({ id: metricOption.label })
: metric;
const tooltipNameId = tooltipNameMap[metric];
const tooltipName = tooltipNameId
? intl.formatMessage({ id: tooltipNameId })
: null;
const barSeries: any[] = [];
const groupColors = generateCoolColors(groupOrder.length);
groupOrder.forEach((groupLabel, groupIdx) => {
const dateMap = groupItemsMap.get(groupLabel)!;
const isTotal = groupLabel === '__total__';
const baseName = isTotal ? metricLabel : groupLabel;
const groupColor = groupColors[groupIdx];
const seriesTooltipName = isTotal ? tooltipName : null;
if (isCached) {
const uncachedLabel = intl.formatMessage({
id: 'usage.chart.uncached'
});
const cachedLabel = intl.formatMessage({
id: 'usage.chart.cached'
});
const uncachedData = xAxis.map((date) => {
const item = dateMap.get(date);
return {
time: date,
value: item
? (item.input_tokens || 0) - (item.input_cached_tokens || 0)
: 0,
stackLabel: uncachedLabel,
tooltipName: seriesTooltipName
};
});
const cachedData = xAxis.map((date) => {
const item = dateMap.get(date);
return {
time: date,
value: item ? item.input_cached_tokens || 0 : 0,
stackLabel: cachedLabel,
tooltipName: seriesTooltipName
};
});
const seriesName =
groupLabel === '__total__' ? metricLabel : groupLabel;
barSeries.push({
name: seriesName,
data: uncachedData,
color: groupColor,
stack: 'uncached'
});
barSeries.push({
name: seriesName,
data: cachedData,
color: groupColor,
stack: 'cached'
});
} else {
const data = xAxis.map((date) => {
const item = dateMap.get(date);
return {
time: date,
value: item
? (item[metric as keyof BreakdownItem] as number) || 0
: 0,
tooltipName: seriesTooltipName
};
});
barSeries.push({
name: baseName,
data,
color: groupColor,
stack: 'total'
});
}
});
const seenNames = new Set<string>();
const dedupedLegend = barSeries
.filter((s) => {
if (seenNames.has(s.name)) return false;
seenNames.add(s.name);
return true;
})
.map((s) => ({
name: s.name,
icon: metric === 'api_requests' ? 'circle' : 'roundRect'
}));
return { return {
chartData: { line: [], bar: chartSeries }, seriesData: barSeries,
xAxisData: xAxis xAxisData: xAxis,
legendData: dedupedLegend
}; };
}, [timeSeriesData, metric]); }, [timeSeriesData, metric, groupBy, granularity, startDate, endDate, intl]);
const legendData = useMemo(() => {
if (!timeSeriesData?.series) return [];
return timeSeriesData.series.map((item) => ({
name: item.label,
icon: metric === 'api_requests' ? 'circle' : 'roundRect'
}));
}, [timeSeriesData, metric]);
const handleOnGroupByChange = (value: string) => { const handleOnGroupByChange = (value: string) => {
onGroupByChange(value || null); onGroupByChange(value || null);
@@ -170,14 +289,13 @@ const DailyUsage: React.FC<DailyUsageProps> = (props) => {
onChange={onGranularityChange} onChange={onGranularityChange}
></Segmented> ></Segmented>
</ControlsWrapper> </ControlsWrapper>
<MixLineBarChart <BarChart
chartData={chartData} seriesData={seriesData}
seriesData={[]}
xAxisData={xAxisData} xAxisData={xAxisData}
height={280} height={280}
smooth={false}
legendData={legendData} legendData={legendData}
labelFormatter={labelFormatter} labelFormatter={labelFormatter}
legendIsolate={metric === CACHED_METRIC}
/> />
</CardWrapper> </CardWrapper>
</div> </div>
+64 -13
View File
@@ -147,20 +147,57 @@ const ExportData: React.FC<{
} }
}, },
{ {
title: intl.formatMessage({ id: 'usage.filter.inputTokens' }), title: (
dataIndex: 'input_tokens' <AutoTooltip ghost>
{intl.formatMessage({ id: 'usage.filter.inputTokens' })}
</AutoTooltip>
),
width: 130,
dataIndex: 'input_tokens',
render: (text: string, record: any) => {
return <AutoTooltip ghost>{text}</AutoTooltip>;
}
}, },
{ {
title: intl.formatMessage({ id: 'usage.filter.outputTokens' }), title: (
dataIndex: 'output_tokens' <AutoTooltip ghost>
{intl.formatMessage({ id: 'usage.table.inputTokensCached' })}
</AutoTooltip>
),
width: 150,
dataIndex: 'input_cached_tokens',
render: (text: string, record: any) => {
return <AutoTooltip ghost>{text}</AutoTooltip>;
}
},
{
title: (
<AutoTooltip ghost>
{intl.formatMessage({ id: 'usage.filter.outputTokens' })}
</AutoTooltip>
),
dataIndex: 'output_tokens',
render: (text: string, record: any) => {
return <AutoTooltip ghost>{text}</AutoTooltip>;
}
}, },
{ {
title: intl.formatMessage({ id: 'usage.filter.totalTokens' }), title: intl.formatMessage({ id: 'usage.filter.totalTokens' }),
dataIndex: 'total_tokens' dataIndex: 'total_tokens',
render: (text: string, record: any) => {
return <AutoTooltip ghost>{text}</AutoTooltip>;
}
}, },
{ {
title: intl.formatMessage({ id: 'usage.filter.apiRequests' }), title: (
dataIndex: 'api_requests' <AutoTooltip ghost>
{intl.formatMessage({ id: 'usage.filter.apiRequests' })}
</AutoTooltip>
),
dataIndex: 'api_requests',
render: (text: string, record: any) => {
return <AutoTooltip ghost>{text}</AutoTooltip>;
}
} }
]; ];
@@ -176,6 +213,7 @@ const ExportData: React.FC<{
model: item?.model?.identity?.value?.model_name, model: item?.model?.identity?.value?.model_name,
api_key: item?.api_key?.label, api_key: item?.api_key?.label,
input_tokens: item?.input_tokens, input_tokens: item?.input_tokens,
input_cached_tokens: item?.input_cached_tokens,
output_tokens: item?.output_tokens, output_tokens: item?.output_tokens,
total_tokens: item?.total_tokens, total_tokens: item?.total_tokens,
api_requests: item?.api_requests api_requests: item?.api_requests
@@ -187,6 +225,7 @@ const ExportData: React.FC<{
'model', 'model',
'api_key', 'api_key',
'input_tokens', 'input_tokens',
'input_cached_tokens',
'output_tokens', 'output_tokens',
'total_tokens', 'total_tokens',
'api_requests' 'api_requests'
@@ -200,6 +239,9 @@ const ExportData: React.FC<{
input_tokens: intl.formatMessage({ input_tokens: intl.formatMessage({
id: 'usage.filter.inputTokens' id: 'usage.filter.inputTokens'
}), }),
input_cached_tokens: intl.formatMessage({
id: 'usage.table.inputTokensCached'
}),
output_tokens: intl.formatMessage({ output_tokens: intl.formatMessage({
id: 'usage.filter.outputTokens' id: 'usage.filter.outputTokens'
}), }),
@@ -276,17 +318,26 @@ const ExportData: React.FC<{
></ModalFooter> ></ModalFooter>
} }
> >
<FilterBar <div
{...filterBar} style={{
pageType="modal" position: 'sticky',
handlePickerChange={handlePickerChange} top: 0,
></FilterBar> zIndex: 1
}}
>
<FilterBar
{...filterBar}
pageType="modal"
handlePickerChange={handlePickerChange}
></FilterBar>
</div>
<Table <Table
columns={exportTableColumns} columns={exportTableColumns}
className={'scroll-table'}
tableLayout={'auto'} tableLayout={'auto'}
style={{ width: '100%', marginTop: '16px', minHeight: 400 }} style={{ width: '100%', marginTop: '16px', minHeight: 400 }}
dataSource={dataSource.dataList || []} dataSource={dataSource.dataList || []}
rowKey={getBreakdownRowKey} rowKey={(record) => getBreakdownRowKey(record, 'export')}
loading={{ loading={{
spinning: loading, spinning: loading,
size: 'middle' size: 'middle'
+4
View File
@@ -39,6 +39,10 @@ export const metricOptions = [
value: 'input_tokens', value: 'input_tokens',
label: 'usage.filter.inputTokens' label: 'usage.filter.inputTokens'
}, },
{
value: 'input_cached_tokens',
label: 'usage.chart.inputTokensCached'
},
{ {
value: 'output_tokens', value: 'output_tokens',
label: 'usage.filter.outputTokens' label: 'usage.filter.outputTokens'
+18
View File
@@ -54,6 +54,7 @@ export type BreakdownItem = {
output_tokens: number; output_tokens: number;
total_tokens: number; total_tokens: number;
api_requests: number; api_requests: number;
input_cached_tokens: number;
avg_tokens_per_request: number; avg_tokens_per_request: number;
models_called: number; models_called: number;
api_keys_used: number; api_keys_used: number;
@@ -63,8 +64,25 @@ export type BreakdownItem = {
user: UsageFilterItem; user: UsageFilterItem;
model: UsageFilterItem; model: UsageFilterItem;
api_key: UsageFilterItem; api_key: UsageFilterItem;
date: {
value: string;
label: string;
};
}; };
export interface UsageBreakdownResponse {
summary: TimeSeriesSummary;
group_by: string[];
granularity: string;
pagination: {
page: number;
perPage: number;
total: number;
totalPage: number;
};
items: BreakdownItem[];
}
export interface UsageMeta { export interface UsageMeta {
filters: { filters: {
models: UsageFilterItem[]; models: UsageFilterItem[];
@@ -68,6 +68,15 @@ const useModelsColumns = (): Array<{
<AutoTooltip ghost>{text}</AutoTooltip> <AutoTooltip ghost>{text}</AutoTooltip>
) )
}, },
{
title: intl.formatMessage({ id: 'usage.table.inputTokensCached' }),
dataIndex: 'input_cached_tokens',
key: 'input_cached_tokens',
sorter: true,
render: (text: number, record: ListItem) => (
<AutoTooltip ghost>{text ?? '-'}</AutoTooltip>
)
},
{ {
title: intl.formatMessage({ id: 'usage.filter.outputTokens' }), title: intl.formatMessage({ id: 'usage.filter.outputTokens' }),
dataIndex: 'output_tokens', dataIndex: 'output_tokens',
@@ -80,6 +80,15 @@ const useModelsColumns = (): Array<{
<AutoTooltip ghost>{text}</AutoTooltip> <AutoTooltip ghost>{text}</AutoTooltip>
) )
}, },
{
title: intl.formatMessage({ id: 'usage.table.inputTokensCached' }),
dataIndex: 'input_cached_tokens',
key: 'input_cached_tokens',
sorter: true,
render: (text: number, record: ListItem) => (
<AutoTooltip ghost>{text ?? '-'}</AutoTooltip>
)
},
{ {
title: intl.formatMessage({ id: 'usage.filter.outputTokens' }), title: intl.formatMessage({ id: 'usage.filter.outputTokens' }),
dataIndex: 'output_tokens', dataIndex: 'output_tokens',
+40 -8
View File
@@ -1,12 +1,8 @@
import { exportJsonToExcel } from '@gpustack/core-ui/excel'; import { exportJsonToExcel } from '@gpustack/core-ui/excel';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { import { GroupOption } from '../config';
generateColumns, import { BreakdownItem, UsageFilterItem } from '../config/types';
GroupOption,
transformTimelineToTable
} from '../config';
import { UsageFilterItem } from '../config/types';
import useQueryTimeSeriesData from '../services/use-query-timeseries-data'; import useQueryTimeSeriesData from '../services/use-query-timeseries-data';
const DefaultDateConfig = { const DefaultDateConfig = {
@@ -206,8 +202,13 @@ export const useUsageFilters = ({
return; return;
} }
const groupByArray = currentChartFilters.group_by
? ['date', currentChartFilters.group_by]
: ['date'];
fetchTimeSeriesData({ fetchTimeSeriesData({
...currentChartFilters, ...currentChartFilters,
group_by: groupByArray,
start_date: currentSelectedFilters.start_date, start_date: currentSelectedFilters.start_date,
end_date: currentSelectedFilters.end_date, end_date: currentSelectedFilters.end_date,
filters: nextFilters filters: nextFilters
@@ -263,8 +264,39 @@ export const useUsageFilters = ({
}; };
const handleOnExportChart = () => { const handleOnExportChart = () => {
const columns = generateColumns(timeSeriesData?.series || []); const items = timeSeriesData?.items || [];
const tableData = transformTimelineToTable(timeSeriesData?.series || []); const groupDim = chartFilters.group_by as
| 'user'
| 'model'
| 'api_key'
| null;
const metric = chartFilters.metric as keyof BreakdownItem;
const dateMap: Record<string, Record<string, any>> = {};
const groupLabels = new Set<string>();
items.forEach((item) => {
const date = item.date?.value;
if (!date) return;
const groupLabel = groupDim
? ((item[groupDim] as UsageFilterItem)?.label ?? '-')
: metric;
groupLabels.add(groupLabel);
if (!dateMap[date]) dateMap[date] = { date };
dateMap[date][groupLabel] = item[metric];
});
const tableData = Object.values(dateMap).sort((a, b) =>
String(a.date).localeCompare(String(b.date))
);
const columns = [
{ title: 'Date', dataIndex: 'date' },
...Array.from(groupLabels).map((label) => ({
title: label,
dataIndex: label
}))
];
exportJsonToExcel({ exportJsonToExcel({
fileName: `${chartFilters.metric}_${chartFilters.group_by}_${chartFilters.granularity}_${commonFilters.start_date}_${commonFilters.end_date}.xlsx`, fileName: `${chartFilters.metric}_${chartFilters.group_by}_${chartFilters.granularity}_${commonFilters.start_date}_${commonFilters.end_date}.xlsx`,
@@ -77,6 +77,15 @@ const useModelsColumns = (): Array<{
<AutoTooltip ghost>{text}</AutoTooltip> <AutoTooltip ghost>{text}</AutoTooltip>
) )
}, },
{
title: intl.formatMessage({ id: 'usage.table.inputTokensCached' }),
dataIndex: 'input_cached_tokens',
key: 'input_cached_tokens',
sorter: true,
render: (text: number, record: ListItem) => (
<AutoTooltip ghost>{text ?? '-'}</AutoTooltip>
)
},
{ {
title: intl.formatMessage({ id: 'usage.filter.outputTokens' }), title: intl.formatMessage({ id: 'usage.filter.outputTokens' }),
dataIndex: 'output_tokens', dataIndex: 'output_tokens',
+2
View File
@@ -202,6 +202,8 @@ const Usage: React.FC = () => {
metric={chartFilters.metric} metric={chartFilters.metric}
groupBy={chartFilters.group_by} groupBy={chartFilters.group_by}
granularity={chartFilters.granularity} granularity={chartFilters.granularity}
startDate={commonFilters.start_date}
endDate={commonFilters.end_date}
onMetricChange={(value) => handleChartFilterChange('metric', value)} onMetricChange={(value) => handleChartFilterChange('metric', value)}
onGroupByChange={(value) => onGroupByChange={(value) =>
handleChartFilterChange('group_by', value as string) handleChartFilterChange('group_by', value as string)
@@ -1,10 +1,10 @@
import { useQueryData } from '@/hooks/use-query-data-list'; import { useQueryData } from '@/hooks/use-query-data-list';
import { queryUsageTimeSeriesData } from '../apis'; import { queryUsageTimeSeriesData } from '../apis';
import { TimeSeriesData } from '../config/types'; import { UsageBreakdownResponse } from '../config/types';
export default function useQueryTimeSeriesData() { export default function useQueryTimeSeriesData() {
const { detailData, loading, cancelRequest, fetchData } = const { detailData, loading, cancelRequest, fetchData } =
useQueryData<TimeSeriesData>({ useQueryData<UsageBreakdownResponse>({
fetchDetail: queryUsageTimeSeriesData, fetchDetail: queryUsageTimeSeriesData,
key: 'timeSeriesData' key: 'timeSeriesData'
}); });
+22 -24
View File
@@ -2,7 +2,7 @@ import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import PageBox from '@/pages/_components/page-box'; import PageBox from '@/pages/_components/page-box';
import { IconFont, NoResult } from '@gpustack/core-ui'; import { IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { ConfigProvider, Table } from 'antd'; import { Table } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { FilterOptionType } from '../config/types'; import { FilterOptionType } from '../config/types';
@@ -108,29 +108,27 @@ const APIKeys: React.FC<{
return ( return (
<> <>
<PageBox> <PageBox>
<ConfigProvider renderEmpty={renderEmpty}> <Table
<Table columns={columns}
columns={columns} dataSource={dataSource.dataList}
dataSource={dataSource.dataList} rowKey={(record) => getBreakdownRowKey(record, 'api_keys')}
rowKey={(record) => getBreakdownRowKey(record, 'api_keys')} loading={{
loading={{ spinning: loading,
spinning: loading, size: 'middle'
size: 'middle' }}
}} sortDirections={TABLE_SORT_DIRECTIONS}
sortDirections={TABLE_SORT_DIRECTIONS} showSorterTooltip={false}
showSorterTooltip={false} onChange={handleTableChange}
onChange={handleTableChange} pagination={{
pagination={{ size: 'middle',
size: 'middle', showSizeChanger: true,
showSizeChanger: true, pageSize: queryParams.perPage,
pageSize: queryParams.perPage, current: queryParams.page,
current: queryParams.page, total: dataSource.total,
total: dataSource.total, hideOnSinglePage: queryParams.perPage === 10,
hideOnSinglePage: queryParams.perPage === 10, onChange: handlePageChange
onChange: handlePageChange }}
}} ></Table>
></Table>
</ConfigProvider>
</PageBox> </PageBox>
</> </>
); );
+25 -25
View File
@@ -2,10 +2,10 @@ import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import PageBox from '@/pages/_components/page-box'; import PageBox from '@/pages/_components/page-box';
import { IconFont, NoResult } from '@gpustack/core-ui'; import { IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { ConfigProvider, Table } from 'antd'; import { Table } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { FilterOptionType } from '../config/types'; import { BreakdownItem, FilterOptionType } from '../config/types';
import useUsersColumns from '../hooks/use-models-columns'; import useUsersColumns from '../hooks/use-models-columns';
import useQueryBreakdownList from '../services/use-query-breakdown-list'; import useQueryBreakdownList from '../services/use-query-breakdown-list';
import getBreakdownRowKey from '../utils/get-breakdown-row-key'; import getBreakdownRowKey from '../utils/get-breakdown-row-key';
@@ -110,29 +110,29 @@ const Models: React.FC<{
return ( return (
<> <>
<PageBox> <PageBox>
<ConfigProvider renderEmpty={renderEmpty}> <Table
<Table columns={columns}
columns={columns} dataSource={dataSource.dataList}
dataSource={dataSource.dataList} rowKey={(record: BreakdownItem) =>
rowKey={(record) => getBreakdownRowKey(record, 'models')} getBreakdownRowKey(record, 'models')
loading={{ }
spinning: loading, loading={{
size: 'middle' spinning: loading,
}} size: 'middle'
sortDirections={TABLE_SORT_DIRECTIONS} }}
showSorterTooltip={false} sortDirections={TABLE_SORT_DIRECTIONS}
onChange={handleTableChange} showSorterTooltip={false}
pagination={{ onChange={handleTableChange}
size: 'middle', pagination={{
showSizeChanger: true, size: 'middle',
pageSize: queryParams.perPage, showSizeChanger: true,
current: queryParams.page, pageSize: queryParams.perPage,
total: dataSource.total, current: queryParams.page,
hideOnSinglePage: queryParams.perPage === 10, total: dataSource.total,
onChange: handlePageChange hideOnSinglePage: queryParams.perPage === 10,
}} onChange: handlePageChange
></Table> }}
</ConfigProvider> ></Table>
</PageBox> </PageBox>
</> </>
); );
+22 -24
View File
@@ -2,7 +2,7 @@ import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import PageBox from '@/pages/_components/page-box'; import PageBox from '@/pages/_components/page-box';
import { IconFont, NoResult } from '@gpustack/core-ui'; import { IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { ConfigProvider, Table } from 'antd'; import { Table } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { FilterOptionType } from '../config/types'; import { FilterOptionType } from '../config/types';
@@ -111,29 +111,27 @@ const Users: React.FC<{
return ( return (
<> <>
<PageBox> <PageBox>
<ConfigProvider renderEmpty={renderEmpty}> <Table
<Table columns={columns}
columns={columns} dataSource={dataSource.dataList}
dataSource={dataSource.dataList} rowKey={(record) => getBreakdownRowKey(record, 'users')}
rowKey={(record) => getBreakdownRowKey(record, 'users')} loading={{
loading={{ spinning: loading,
spinning: loading, size: 'middle'
size: 'middle' }}
}} sortDirections={TABLE_SORT_DIRECTIONS}
sortDirections={TABLE_SORT_DIRECTIONS} showSorterTooltip={false}
showSorterTooltip={false} onChange={handleTableChange}
onChange={handleTableChange} pagination={{
pagination={{ size: 'middle',
size: 'middle', showSizeChanger: true,
showSizeChanger: true, pageSize: queryParams.perPage,
pageSize: queryParams.perPage, current: queryParams.page,
current: queryParams.page, total: dataSource.total,
total: dataSource.total, hideOnSinglePage: queryParams.perPage === 10,
hideOnSinglePage: queryParams.perPage === 10, onChange: handlePageChange
onChange: handlePageChange }}
}} ></Table>
></Table>
</ConfigProvider>
</PageBox> </PageBox>
</> </>
); );
@@ -16,7 +16,7 @@ export const getBreakdownRowKey = (
return `${record.api_key.label}`; return `${record.api_key.label}`;
} }
return record.last_active; return `${record.date?.value || ''}-${record.model.label}-${record.user.label}-${record.api_key.label}`;
}; };
export default getBreakdownRowKey; export default getBreakdownRowKey;