refactor: usage chart
This commit is contained in:
+1
-1
@@ -22,7 +22,7 @@
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@ant-design/pro-components": "3.1.0-0",
|
||||
"@braintree/sanitize-url": "^7.1.1",
|
||||
"@gpustack/core-ui": "^1.0.3",
|
||||
"@gpustack/core-ui": "^1.0.4",
|
||||
"@huggingface/gguf": "^0.1.7",
|
||||
"@huggingface/hub": "^0.15.1",
|
||||
"@huggingface/tasks": "^0.11.6",
|
||||
|
||||
Generated
+5
-4
@@ -18,8 +18,8 @@ dependencies:
|
||||
specifier: ^7.1.1
|
||||
version: 7.1.1
|
||||
'@gpustack/core-ui':
|
||||
specifier: ^1.0.3
|
||||
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)
|
||||
specifier: ^1.0.4
|
||||
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':
|
||||
specifier: ^0.1.7
|
||||
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}
|
||||
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):
|
||||
resolution: {integrity: sha512-DFl5Sh4zAVYBmGx905l4ECV5ZtbAn2S1SZTycfMw48iisveGbx//SQU8mktdkcllzoGCMGurbX0NQvlyciB0OA==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.3.tgz}
|
||||
/@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-8v/rduG6fjmVEEZ8/IlccFMrfHryGRGdAt2yCRdYbCRsQ/wWoe4WgcIkFtQwDeJKB9ONEjggApT5SCvY6ST+hg==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.4.tgz}
|
||||
peerDependencies:
|
||||
'@ant-design/icons': '>=6.0.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):
|
||||
resolution: {integrity: sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q==, tarball: https://registry.npmjs.org/@hono/node-ws/-/node-ws-1.3.0.tgz}
|
||||
engines: {node: '>=18.14.1'}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
peerDependencies:
|
||||
'@hono/node-server': ^1.19.2
|
||||
hono: ^4.6.0
|
||||
|
||||
@@ -28,5 +28,9 @@ export default {
|
||||
'usage.user.currentAccount': 'Current Account',
|
||||
'usage.table.cluster': 'Cluster',
|
||||
'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'
|
||||
};
|
||||
|
||||
@@ -28,5 +28,9 @@ export default {
|
||||
'usage.user.currentAccount': 'Current Account',
|
||||
'usage.table.cluster': 'Cluster',
|
||||
'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'
|
||||
};
|
||||
|
||||
@@ -28,5 +28,9 @@ export default {
|
||||
'usage.user.currentAccount': 'Current Account',
|
||||
'usage.table.cluster': 'Cluster',
|
||||
'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'
|
||||
};
|
||||
|
||||
@@ -28,5 +28,9 @@ export default {
|
||||
'usage.user.currentAccount': 'Current Account',
|
||||
'usage.table.cluster': 'Cluster',
|
||||
'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'
|
||||
};
|
||||
|
||||
@@ -28,5 +28,9 @@ export default {
|
||||
'usage.user.currentAccount': '当前账户',
|
||||
'usage.table.cluster': '集群',
|
||||
'usage.table.provider': '提供商',
|
||||
'usage.table.deleted': '已删除'
|
||||
'usage.table.deleted': '已删除',
|
||||
'usage.chart.cached': '缓存',
|
||||
'usage.chart.uncached': '非缓存',
|
||||
'usage.chart.inputTokensCached': '输入 Token 数(缓存/非缓存)',
|
||||
'usage.table.inputTokensCached': '输入 Token 缓存数'
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -2,12 +2,11 @@ import { request } from '@umijs/max';
|
||||
import {
|
||||
BreakdownItem,
|
||||
FilterOptionType,
|
||||
TimeSeriesData,
|
||||
UsageBreakdownResponse,
|
||||
UsageMeta
|
||||
} from '../config/types';
|
||||
|
||||
export const USAGE_META = '/usage/meta';
|
||||
export const USAGE_TIMESERIES = '/usage/timeseries';
|
||||
export const USAGE_BREAKDOWN = '/usage/breakdown';
|
||||
|
||||
export const MODEL_ROUTE_TARGETS = '/model-route-targets';
|
||||
@@ -29,7 +28,7 @@ export async function queryUsageTimeSeriesData(
|
||||
end_date: string;
|
||||
scope: string;
|
||||
metric: string;
|
||||
group_by: string;
|
||||
group_by: string[];
|
||||
granularity: string;
|
||||
filters: {
|
||||
models?: FilterOptionType[];
|
||||
@@ -38,8 +37,8 @@ export async function queryUsageTimeSeriesData(
|
||||
};
|
||||
},
|
||||
options?: any
|
||||
): Promise<TimeSeriesData> {
|
||||
return request<TimeSeriesData>(USAGE_TIMESERIES, {
|
||||
): Promise<UsageBreakdownResponse> {
|
||||
return request<UsageBreakdownResponse>(USAGE_BREAKDOWN, {
|
||||
data: params,
|
||||
method: 'POST',
|
||||
cancelToken: options?.token
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { baseColorMap } from '@/pages/dashboard/config';
|
||||
import { BaseSelect, CardWrapper, MixLineBarChart } from '@gpustack/core-ui';
|
||||
import BarChart, { generateCoolColors } from '@/pages/_components/bar-chart';
|
||||
import { BaseSelect, CardWrapper } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Segmented } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { granularities, groupByOptions, metricOptions } from '../config';
|
||||
import { TimeSeriesData } from '../config/types';
|
||||
import {
|
||||
BreakdownItem,
|
||||
UsageBreakdownResponse,
|
||||
UsageFilterItem
|
||||
} from '../config/types';
|
||||
|
||||
const ControlsWrapper = styled.div`
|
||||
display: flex;
|
||||
@@ -26,34 +30,41 @@ const ControlLabel = styled.span`
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
function getColorForIndex(index: number) {
|
||||
const hue = (index * 137.5) % 360;
|
||||
return `oklch(70% 0.12 ${hue})`;
|
||||
}
|
||||
|
||||
const colors = [
|
||||
baseColorMap.base,
|
||||
baseColorMap.baseR3,
|
||||
baseColorMap.baseL1,
|
||||
baseColorMap.baseR1,
|
||||
baseColorMap.baseR2,
|
||||
baseColorMap.baseL2
|
||||
];
|
||||
const tooltipNameMap: Record<string, string> = {
|
||||
input_cached_tokens: 'usage.filter.inputTokens'
|
||||
};
|
||||
|
||||
interface DailyUsageProps {
|
||||
timeSeriesData: TimeSeriesData | null;
|
||||
timeSeriesData: UsageBreakdownResponse | null;
|
||||
metric: string;
|
||||
groupBy: string | null;
|
||||
granularity: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
onMetricChange: (value: string) => void;
|
||||
onGroupByChange: (value: string | null) => void;
|
||||
onGranularityChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const labelFormatter = (v: any) => {
|
||||
return dayjs(v).format('MM-DD');
|
||||
const generateDateRange = (
|
||||
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 intl = useIntl();
|
||||
const {
|
||||
@@ -61,60 +72,168 @@ const DailyUsage: React.FC<DailyUsageProps> = (props) => {
|
||||
metric,
|
||||
groupBy,
|
||||
granularity,
|
||||
startDate,
|
||||
endDate,
|
||||
onMetricChange,
|
||||
onGroupByChange,
|
||||
onGranularityChange
|
||||
} = props;
|
||||
|
||||
const { chartData, xAxisData } = useMemo(() => {
|
||||
if (!timeSeriesData?.series || timeSeriesData.series.length === 0) {
|
||||
return { chartData: { line: [], bar: [] }, xAxisData: [] };
|
||||
const labelFormatter = (v: any) => {
|
||||
if (granularity === 'month') {
|
||||
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 dateSet = new Set<string>();
|
||||
const groupDim = groupBy as 'user' | 'model' | 'api_key' | null;
|
||||
const isCached = metric === CACHED_METRIC;
|
||||
|
||||
for (const item of series) {
|
||||
for (const point of item.timeline) {
|
||||
dateSet.add(point.date);
|
||||
const dateSet = new Set<string>(
|
||||
generateDateRange(startDate, endDate, granularity)
|
||||
);
|
||||
items.forEach((item) => {
|
||||
if (item.date?.value) {
|
||||
dateSet.add(item.date.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// sort dates in ascending order
|
||||
const xAxis = Array.from(dateSet).sort(
|
||||
(a, b) => dayjs(a).valueOf() - dayjs(b).valueOf()
|
||||
);
|
||||
|
||||
const chartSeries = series.map((item, index) => {
|
||||
const timelineMap = new Map(
|
||||
item.timeline.map((point) => [
|
||||
point.date,
|
||||
{ time: point.date, value: point.value }
|
||||
])
|
||||
);
|
||||
const groupOrder: string[] = [];
|
||||
const groupItemsMap = new Map<string, Map<string, BreakdownItem>>();
|
||||
|
||||
return {
|
||||
name: item.label,
|
||||
data: xAxis.map((date) => timelineMap.get(date) ?? null),
|
||||
color: colors[index % colors.length]
|
||||
};
|
||||
items.forEach((item) => {
|
||||
const groupLabel = groupDim
|
||||
? ((item[groupDim] as UsageFilterItem)?.label ?? '-')
|
||||
: '__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 {
|
||||
chartData: { line: [], bar: chartSeries },
|
||||
xAxisData: xAxis
|
||||
seriesData: barSeries,
|
||||
xAxisData: xAxis,
|
||||
legendData: dedupedLegend
|
||||
};
|
||||
}, [timeSeriesData, metric]);
|
||||
|
||||
const legendData = useMemo(() => {
|
||||
if (!timeSeriesData?.series) return [];
|
||||
|
||||
return timeSeriesData.series.map((item) => ({
|
||||
name: item.label,
|
||||
icon: metric === 'api_requests' ? 'circle' : 'roundRect'
|
||||
}));
|
||||
}, [timeSeriesData, metric]);
|
||||
}, [timeSeriesData, metric, groupBy, granularity, startDate, endDate, intl]);
|
||||
|
||||
const handleOnGroupByChange = (value: string) => {
|
||||
onGroupByChange(value || null);
|
||||
@@ -170,14 +289,13 @@ const DailyUsage: React.FC<DailyUsageProps> = (props) => {
|
||||
onChange={onGranularityChange}
|
||||
></Segmented>
|
||||
</ControlsWrapper>
|
||||
<MixLineBarChart
|
||||
chartData={chartData}
|
||||
seriesData={[]}
|
||||
<BarChart
|
||||
seriesData={seriesData}
|
||||
xAxisData={xAxisData}
|
||||
height={280}
|
||||
smooth={false}
|
||||
legendData={legendData}
|
||||
labelFormatter={labelFormatter}
|
||||
legendIsolate={metric === CACHED_METRIC}
|
||||
/>
|
||||
</CardWrapper>
|
||||
</div>
|
||||
|
||||
@@ -147,20 +147,57 @@ const ExportData: React.FC<{
|
||||
}
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.filter.inputTokens' }),
|
||||
dataIndex: 'input_tokens'
|
||||
title: (
|
||||
<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' }),
|
||||
dataIndex: 'output_tokens'
|
||||
title: (
|
||||
<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' }),
|
||||
dataIndex: 'total_tokens'
|
||||
dataIndex: 'total_tokens',
|
||||
render: (text: string, record: any) => {
|
||||
return <AutoTooltip ghost>{text}</AutoTooltip>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.filter.apiRequests' }),
|
||||
dataIndex: 'api_requests'
|
||||
title: (
|
||||
<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,
|
||||
api_key: item?.api_key?.label,
|
||||
input_tokens: item?.input_tokens,
|
||||
input_cached_tokens: item?.input_cached_tokens,
|
||||
output_tokens: item?.output_tokens,
|
||||
total_tokens: item?.total_tokens,
|
||||
api_requests: item?.api_requests
|
||||
@@ -187,6 +225,7 @@ const ExportData: React.FC<{
|
||||
'model',
|
||||
'api_key',
|
||||
'input_tokens',
|
||||
'input_cached_tokens',
|
||||
'output_tokens',
|
||||
'total_tokens',
|
||||
'api_requests'
|
||||
@@ -200,6 +239,9 @@ const ExportData: React.FC<{
|
||||
input_tokens: intl.formatMessage({
|
||||
id: 'usage.filter.inputTokens'
|
||||
}),
|
||||
input_cached_tokens: intl.formatMessage({
|
||||
id: 'usage.table.inputTokensCached'
|
||||
}),
|
||||
output_tokens: intl.formatMessage({
|
||||
id: 'usage.filter.outputTokens'
|
||||
}),
|
||||
@@ -276,17 +318,26 @@ const ExportData: React.FC<{
|
||||
></ModalFooter>
|
||||
}
|
||||
>
|
||||
<FilterBar
|
||||
{...filterBar}
|
||||
pageType="modal"
|
||||
handlePickerChange={handlePickerChange}
|
||||
></FilterBar>
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
<FilterBar
|
||||
{...filterBar}
|
||||
pageType="modal"
|
||||
handlePickerChange={handlePickerChange}
|
||||
></FilterBar>
|
||||
</div>
|
||||
<Table
|
||||
columns={exportTableColumns}
|
||||
className={'scroll-table'}
|
||||
tableLayout={'auto'}
|
||||
style={{ width: '100%', marginTop: '16px', minHeight: 400 }}
|
||||
dataSource={dataSource.dataList || []}
|
||||
rowKey={getBreakdownRowKey}
|
||||
rowKey={(record) => getBreakdownRowKey(record, 'export')}
|
||||
loading={{
|
||||
spinning: loading,
|
||||
size: 'middle'
|
||||
|
||||
@@ -39,6 +39,10 @@ export const metricOptions = [
|
||||
value: 'input_tokens',
|
||||
label: 'usage.filter.inputTokens'
|
||||
},
|
||||
{
|
||||
value: 'input_cached_tokens',
|
||||
label: 'usage.chart.inputTokensCached'
|
||||
},
|
||||
{
|
||||
value: 'output_tokens',
|
||||
label: 'usage.filter.outputTokens'
|
||||
|
||||
@@ -54,6 +54,7 @@ export type BreakdownItem = {
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
api_requests: number;
|
||||
input_cached_tokens: number;
|
||||
avg_tokens_per_request: number;
|
||||
models_called: number;
|
||||
api_keys_used: number;
|
||||
@@ -63,8 +64,25 @@ export type BreakdownItem = {
|
||||
user: UsageFilterItem;
|
||||
model: 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 {
|
||||
filters: {
|
||||
models: UsageFilterItem[];
|
||||
|
||||
@@ -68,6 +68,15 @@ const useModelsColumns = (): Array<{
|
||||
<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' }),
|
||||
dataIndex: 'output_tokens',
|
||||
|
||||
@@ -80,6 +80,15 @@ const useModelsColumns = (): Array<{
|
||||
<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' }),
|
||||
dataIndex: 'output_tokens',
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
|
||||
import dayjs from 'dayjs';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
generateColumns,
|
||||
GroupOption,
|
||||
transformTimelineToTable
|
||||
} from '../config';
|
||||
import { UsageFilterItem } from '../config/types';
|
||||
import { GroupOption } from '../config';
|
||||
import { BreakdownItem, UsageFilterItem } from '../config/types';
|
||||
import useQueryTimeSeriesData from '../services/use-query-timeseries-data';
|
||||
|
||||
const DefaultDateConfig = {
|
||||
@@ -206,8 +202,13 @@ export const useUsageFilters = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const groupByArray = currentChartFilters.group_by
|
||||
? ['date', currentChartFilters.group_by]
|
||||
: ['date'];
|
||||
|
||||
fetchTimeSeriesData({
|
||||
...currentChartFilters,
|
||||
group_by: groupByArray,
|
||||
start_date: currentSelectedFilters.start_date,
|
||||
end_date: currentSelectedFilters.end_date,
|
||||
filters: nextFilters
|
||||
@@ -263,8 +264,39 @@ export const useUsageFilters = ({
|
||||
};
|
||||
|
||||
const handleOnExportChart = () => {
|
||||
const columns = generateColumns(timeSeriesData?.series || []);
|
||||
const tableData = transformTimelineToTable(timeSeriesData?.series || []);
|
||||
const items = timeSeriesData?.items || [];
|
||||
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({
|
||||
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>
|
||||
)
|
||||
},
|
||||
{
|
||||
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' }),
|
||||
dataIndex: 'output_tokens',
|
||||
|
||||
@@ -202,6 +202,8 @@ const Usage: React.FC = () => {
|
||||
metric={chartFilters.metric}
|
||||
groupBy={chartFilters.group_by}
|
||||
granularity={chartFilters.granularity}
|
||||
startDate={commonFilters.start_date}
|
||||
endDate={commonFilters.end_date}
|
||||
onMetricChange={(value) => handleChartFilterChange('metric', value)}
|
||||
onGroupByChange={(value) =>
|
||||
handleChartFilterChange('group_by', value as string)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useQueryData } from '@/hooks/use-query-data-list';
|
||||
import { queryUsageTimeSeriesData } from '../apis';
|
||||
import { TimeSeriesData } from '../config/types';
|
||||
import { UsageBreakdownResponse } from '../config/types';
|
||||
|
||||
export default function useQueryTimeSeriesData() {
|
||||
const { detailData, loading, cancelRequest, fetchData } =
|
||||
useQueryData<TimeSeriesData>({
|
||||
useQueryData<UsageBreakdownResponse>({
|
||||
fetchDetail: queryUsageTimeSeriesData,
|
||||
key: 'timeSeriesData'
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
|
||||
import PageBox from '@/pages/_components/page-box';
|
||||
import { IconFont, NoResult } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { ConfigProvider, Table } from 'antd';
|
||||
import { Table } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FilterOptionType } from '../config/types';
|
||||
@@ -108,29 +108,27 @@ const APIKeys: React.FC<{
|
||||
return (
|
||||
<>
|
||||
<PageBox>
|
||||
<ConfigProvider renderEmpty={renderEmpty}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource.dataList}
|
||||
rowKey={(record) => getBreakdownRowKey(record, 'api_keys')}
|
||||
loading={{
|
||||
spinning: loading,
|
||||
size: 'middle'
|
||||
}}
|
||||
sortDirections={TABLE_SORT_DIRECTIONS}
|
||||
showSorterTooltip={false}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
size: 'middle',
|
||||
showSizeChanger: true,
|
||||
pageSize: queryParams.perPage,
|
||||
current: queryParams.page,
|
||||
total: dataSource.total,
|
||||
hideOnSinglePage: queryParams.perPage === 10,
|
||||
onChange: handlePageChange
|
||||
}}
|
||||
></Table>
|
||||
</ConfigProvider>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource.dataList}
|
||||
rowKey={(record) => getBreakdownRowKey(record, 'api_keys')}
|
||||
loading={{
|
||||
spinning: loading,
|
||||
size: 'middle'
|
||||
}}
|
||||
sortDirections={TABLE_SORT_DIRECTIONS}
|
||||
showSorterTooltip={false}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
size: 'middle',
|
||||
showSizeChanger: true,
|
||||
pageSize: queryParams.perPage,
|
||||
current: queryParams.page,
|
||||
total: dataSource.total,
|
||||
hideOnSinglePage: queryParams.perPage === 10,
|
||||
onChange: handlePageChange
|
||||
}}
|
||||
></Table>
|
||||
</PageBox>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,10 +2,10 @@ import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
|
||||
import PageBox from '@/pages/_components/page-box';
|
||||
import { IconFont, NoResult } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { ConfigProvider, Table } from 'antd';
|
||||
import { Table } from 'antd';
|
||||
import _ from 'lodash';
|
||||
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 useQueryBreakdownList from '../services/use-query-breakdown-list';
|
||||
import getBreakdownRowKey from '../utils/get-breakdown-row-key';
|
||||
@@ -110,29 +110,29 @@ const Models: React.FC<{
|
||||
return (
|
||||
<>
|
||||
<PageBox>
|
||||
<ConfigProvider renderEmpty={renderEmpty}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource.dataList}
|
||||
rowKey={(record) => getBreakdownRowKey(record, 'models')}
|
||||
loading={{
|
||||
spinning: loading,
|
||||
size: 'middle'
|
||||
}}
|
||||
sortDirections={TABLE_SORT_DIRECTIONS}
|
||||
showSorterTooltip={false}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
size: 'middle',
|
||||
showSizeChanger: true,
|
||||
pageSize: queryParams.perPage,
|
||||
current: queryParams.page,
|
||||
total: dataSource.total,
|
||||
hideOnSinglePage: queryParams.perPage === 10,
|
||||
onChange: handlePageChange
|
||||
}}
|
||||
></Table>
|
||||
</ConfigProvider>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource.dataList}
|
||||
rowKey={(record: BreakdownItem) =>
|
||||
getBreakdownRowKey(record, 'models')
|
||||
}
|
||||
loading={{
|
||||
spinning: loading,
|
||||
size: 'middle'
|
||||
}}
|
||||
sortDirections={TABLE_SORT_DIRECTIONS}
|
||||
showSorterTooltip={false}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
size: 'middle',
|
||||
showSizeChanger: true,
|
||||
pageSize: queryParams.perPage,
|
||||
current: queryParams.page,
|
||||
total: dataSource.total,
|
||||
hideOnSinglePage: queryParams.perPage === 10,
|
||||
onChange: handlePageChange
|
||||
}}
|
||||
></Table>
|
||||
</PageBox>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
|
||||
import PageBox from '@/pages/_components/page-box';
|
||||
import { IconFont, NoResult } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { ConfigProvider, Table } from 'antd';
|
||||
import { Table } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FilterOptionType } from '../config/types';
|
||||
@@ -111,29 +111,27 @@ const Users: React.FC<{
|
||||
return (
|
||||
<>
|
||||
<PageBox>
|
||||
<ConfigProvider renderEmpty={renderEmpty}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource.dataList}
|
||||
rowKey={(record) => getBreakdownRowKey(record, 'users')}
|
||||
loading={{
|
||||
spinning: loading,
|
||||
size: 'middle'
|
||||
}}
|
||||
sortDirections={TABLE_SORT_DIRECTIONS}
|
||||
showSorterTooltip={false}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
size: 'middle',
|
||||
showSizeChanger: true,
|
||||
pageSize: queryParams.perPage,
|
||||
current: queryParams.page,
|
||||
total: dataSource.total,
|
||||
hideOnSinglePage: queryParams.perPage === 10,
|
||||
onChange: handlePageChange
|
||||
}}
|
||||
></Table>
|
||||
</ConfigProvider>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource.dataList}
|
||||
rowKey={(record) => getBreakdownRowKey(record, 'users')}
|
||||
loading={{
|
||||
spinning: loading,
|
||||
size: 'middle'
|
||||
}}
|
||||
sortDirections={TABLE_SORT_DIRECTIONS}
|
||||
showSorterTooltip={false}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
size: 'middle',
|
||||
showSizeChanger: true,
|
||||
pageSize: queryParams.perPage,
|
||||
current: queryParams.page,
|
||||
total: dataSource.total,
|
||||
hideOnSinglePage: queryParams.perPage === 10,
|
||||
onChange: handlePageChange
|
||||
}}
|
||||
></Table>
|
||||
</PageBox>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ export const getBreakdownRowKey = (
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user