diff --git a/package.json b/package.json index bc5dd79f..ee7f1a1c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16f436f8..c16492c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/locales/en-US/usage.ts b/src/locales/en-US/usage.ts index 0b2a0814..f24af40a 100644 --- a/src/locales/en-US/usage.ts +++ b/src/locales/en-US/usage.ts @@ -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' }; diff --git a/src/locales/ja-JP/usage.ts b/src/locales/ja-JP/usage.ts index 0b2a0814..f24af40a 100644 --- a/src/locales/ja-JP/usage.ts +++ b/src/locales/ja-JP/usage.ts @@ -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' }; diff --git a/src/locales/ru-RU/usage.ts b/src/locales/ru-RU/usage.ts index 0b2a0814..f24af40a 100644 --- a/src/locales/ru-RU/usage.ts +++ b/src/locales/ru-RU/usage.ts @@ -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' }; diff --git a/src/locales/tr-TR/usage.ts b/src/locales/tr-TR/usage.ts index 0b2a0814..f24af40a 100644 --- a/src/locales/tr-TR/usage.ts +++ b/src/locales/tr-TR/usage.ts @@ -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' }; diff --git a/src/locales/zh-CN/usage.ts b/src/locales/zh-CN/usage.ts index 9a8b02c4..33d916c4 100644 --- a/src/locales/zh-CN/usage.ts +++ b/src/locales/zh-CN/usage.ts @@ -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 缓存数' }; diff --git a/src/pages/_components/bar-chart/index.tsx b/src/pages/_components/bar-chart/index.tsx new file mode 100644 index 00000000..64f83644 --- /dev/null +++ b/src/pages/_components/bar-chart/index.tsx @@ -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 = (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(); + 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(); + 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 = `${params[0].axisValue}`; + 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 += ` + + + ${displayName}: + + ${value} + `; + }); + + const wrapperClassName = + visibleItemCount >= 12 + ? 'tooltip-wrapper tooltip-grid' + : 'tooltip-wrapper'; + return `
${result}
`; + } + }, + 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; + 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
; + } + + return ( + + ); +}; + +export default BarChart; diff --git a/src/pages/usage/apis/index.ts b/src/pages/usage/apis/index.ts index daa13cc3..4c0e6506 100644 --- a/src/pages/usage/apis/index.ts +++ b/src/pages/usage/apis/index.ts @@ -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 { - return request(USAGE_TIMESERIES, { +): Promise { + return request(USAGE_BREAKDOWN, { data: params, method: 'POST', cancelToken: options?.token diff --git a/src/pages/usage/components/daily-usage.tsx b/src/pages/usage/components/daily-usage.tsx index 56000eb5..3628ce31 100644 --- a/src/pages/usage/components/daily-usage.tsx +++ b/src/pages/usage/components/daily-usage.tsx @@ -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 = { + 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 = (props) => { const intl = useIntl(); const { @@ -61,60 +72,168 @@ const DailyUsage: React.FC = (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(); + 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( + 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>(); - 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(); + 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 = (props) => { onChange={onGranularityChange} > -
diff --git a/src/pages/usage/components/export-data.tsx b/src/pages/usage/components/export-data.tsx index 1bdf57a1..4031ec7b 100644 --- a/src/pages/usage/components/export-data.tsx +++ b/src/pages/usage/components/export-data.tsx @@ -147,20 +147,57 @@ const ExportData: React.FC<{ } }, { - title: intl.formatMessage({ id: 'usage.filter.inputTokens' }), - dataIndex: 'input_tokens' + title: ( + + {intl.formatMessage({ id: 'usage.filter.inputTokens' })} + + ), + width: 130, + dataIndex: 'input_tokens', + render: (text: string, record: any) => { + return {text}; + } }, { - title: intl.formatMessage({ id: 'usage.filter.outputTokens' }), - dataIndex: 'output_tokens' + title: ( + + {intl.formatMessage({ id: 'usage.table.inputTokensCached' })} + + ), + width: 150, + dataIndex: 'input_cached_tokens', + render: (text: string, record: any) => { + return {text}; + } + }, + { + title: ( + + {intl.formatMessage({ id: 'usage.filter.outputTokens' })} + + ), + dataIndex: 'output_tokens', + render: (text: string, record: any) => { + return {text}; + } }, { title: intl.formatMessage({ id: 'usage.filter.totalTokens' }), - dataIndex: 'total_tokens' + dataIndex: 'total_tokens', + render: (text: string, record: any) => { + return {text}; + } }, { - title: intl.formatMessage({ id: 'usage.filter.apiRequests' }), - dataIndex: 'api_requests' + title: ( + + {intl.formatMessage({ id: 'usage.filter.apiRequests' })} + + ), + dataIndex: 'api_requests', + render: (text: string, record: any) => { + return {text}; + } } ]; @@ -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<{ > } > - +
+ +
getBreakdownRowKey(record, 'export')} loading={{ spinning: loading, size: 'middle' diff --git a/src/pages/usage/config/index.ts b/src/pages/usage/config/index.ts index c285731e..f582dba7 100644 --- a/src/pages/usage/config/index.ts +++ b/src/pages/usage/config/index.ts @@ -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' diff --git a/src/pages/usage/config/types.ts b/src/pages/usage/config/types.ts index cfdab6a3..251e0310 100644 --- a/src/pages/usage/config/types.ts +++ b/src/pages/usage/config/types.ts @@ -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[]; diff --git a/src/pages/usage/hooks/use-apikeys-columns.tsx b/src/pages/usage/hooks/use-apikeys-columns.tsx index 876a234c..1579482f 100644 --- a/src/pages/usage/hooks/use-apikeys-columns.tsx +++ b/src/pages/usage/hooks/use-apikeys-columns.tsx @@ -68,6 +68,15 @@ const useModelsColumns = (): Array<{ {text} ) }, + { + title: intl.formatMessage({ id: 'usage.table.inputTokensCached' }), + dataIndex: 'input_cached_tokens', + key: 'input_cached_tokens', + sorter: true, + render: (text: number, record: ListItem) => ( + {text ?? '-'} + ) + }, { title: intl.formatMessage({ id: 'usage.filter.outputTokens' }), dataIndex: 'output_tokens', diff --git a/src/pages/usage/hooks/use-models-columns.tsx b/src/pages/usage/hooks/use-models-columns.tsx index f49125bf..bb7fce3c 100644 --- a/src/pages/usage/hooks/use-models-columns.tsx +++ b/src/pages/usage/hooks/use-models-columns.tsx @@ -80,6 +80,15 @@ const useModelsColumns = (): Array<{ {text} ) }, + { + title: intl.formatMessage({ id: 'usage.table.inputTokensCached' }), + dataIndex: 'input_cached_tokens', + key: 'input_cached_tokens', + sorter: true, + render: (text: number, record: ListItem) => ( + {text ?? '-'} + ) + }, { title: intl.formatMessage({ id: 'usage.filter.outputTokens' }), dataIndex: 'output_tokens', diff --git a/src/pages/usage/hooks/use-usage-filters.ts b/src/pages/usage/hooks/use-usage-filters.ts index e919c611..ca0bf5de 100644 --- a/src/pages/usage/hooks/use-usage-filters.ts +++ b/src/pages/usage/hooks/use-usage-filters.ts @@ -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> = {}; + const groupLabels = new Set(); + + 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`, diff --git a/src/pages/usage/hooks/use-users-columns.tsx b/src/pages/usage/hooks/use-users-columns.tsx index b3d97e57..c945799b 100644 --- a/src/pages/usage/hooks/use-users-columns.tsx +++ b/src/pages/usage/hooks/use-users-columns.tsx @@ -77,6 +77,15 @@ const useModelsColumns = (): Array<{ {text} ) }, + { + title: intl.formatMessage({ id: 'usage.table.inputTokensCached' }), + dataIndex: 'input_cached_tokens', + key: 'input_cached_tokens', + sorter: true, + render: (text: number, record: ListItem) => ( + {text ?? '-'} + ) + }, { title: intl.formatMessage({ id: 'usage.filter.outputTokens' }), dataIndex: 'output_tokens', diff --git a/src/pages/usage/index.tsx b/src/pages/usage/index.tsx index bece2cf8..1b30ac9d 100644 --- a/src/pages/usage/index.tsx +++ b/src/pages/usage/index.tsx @@ -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) diff --git a/src/pages/usage/services/use-query-timeseries-data.ts b/src/pages/usage/services/use-query-timeseries-data.ts index 6c743513..3254c996 100644 --- a/src/pages/usage/services/use-query-timeseries-data.ts +++ b/src/pages/usage/services/use-query-timeseries-data.ts @@ -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({ + useQueryData({ fetchDetail: queryUsageTimeSeriesData, key: 'timeSeriesData' }); diff --git a/src/pages/usage/tables/apikeys-table.tsx b/src/pages/usage/tables/apikeys-table.tsx index 622238ce..1c46dc55 100644 --- a/src/pages/usage/tables/apikeys-table.tsx +++ b/src/pages/usage/tables/apikeys-table.tsx @@ -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 ( <> - -
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 - }} - >
- + 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 + }} + >
); diff --git a/src/pages/usage/tables/models-table.tsx b/src/pages/usage/tables/models-table.tsx index b5e11e03..c91f0758 100644 --- a/src/pages/usage/tables/models-table.tsx +++ b/src/pages/usage/tables/models-table.tsx @@ -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 ( <> - - 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 - }} - >
-
+ + 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 + }} + >
); diff --git a/src/pages/usage/tables/users-table.tsx b/src/pages/usage/tables/users-table.tsx index c1744ac5..2f2a33c7 100644 --- a/src/pages/usage/tables/users-table.tsx +++ b/src/pages/usage/tables/users-table.tsx @@ -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 ( <> - - 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 - }} - >
-
+ 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 + }} + >
); diff --git a/src/pages/usage/utils/get-breakdown-row-key.ts b/src/pages/usage/utils/get-breakdown-row-key.ts index 20939dd6..21a2ebe6 100644 --- a/src/pages/usage/utils/get-breakdown-row-key.ts +++ b/src/pages/usage/utils/get-breakdown-row-key.ts @@ -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;