refactor: usage chart
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user