323 lines
8.9 KiB
TypeScript
323 lines
8.9 KiB
TypeScript
import useCoolColors from '@/hooks/use-cool-colors';
|
|
import BarChart from '@/pages/_components/bar-chart';
|
|
import { BaseSelect, CardWrapper } from '@gpustack/core-ui';
|
|
import { useIntl, useModel } 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 {
|
|
BreakdownItem,
|
|
UsageBreakdownResponse,
|
|
UsageFilterItem
|
|
} from '../config/types';
|
|
|
|
const ControlsWrapper = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 8px;
|
|
.group {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
`;
|
|
|
|
const ControlLabel = styled.span`
|
|
font-size: 14px;
|
|
color: var(--ant-color-text-tertiary);
|
|
margin-right: 8px;
|
|
`;
|
|
|
|
const tooltipNameMap: Record<string, string> = {
|
|
input_cached_tokens: 'usage.filter.inputTokens'
|
|
};
|
|
|
|
interface DailyUsageProps {
|
|
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 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 {
|
|
timeSeriesData,
|
|
metric,
|
|
groupBy,
|
|
granularity,
|
|
startDate,
|
|
endDate,
|
|
onMetricChange,
|
|
onGroupByChange,
|
|
onGranularityChange
|
|
} = props;
|
|
const generateCoolColors = useCoolColors();
|
|
const { initialState } = useModel('@@initialState');
|
|
const { currentUser } = initialState || {};
|
|
|
|
const labelFormatter = (v: any) => {
|
|
if (granularity === 'month') {
|
|
return dayjs(v).format('YYYY-MM');
|
|
}
|
|
return dayjs(v).format('MM-DD');
|
|
};
|
|
|
|
// Recompute only when data (or locale) changes, NOT when metric/groupBy/granularity change.
|
|
// Filter changes already trigger a refetch; deriving on stale data with new filters
|
|
// produces an inconsistent intermediate frame (e.g. grouped data flattened to total),
|
|
// which is the visible flicker.
|
|
const { seriesData, xAxisData, legendData } = useMemo(() => {
|
|
const items = timeSeriesData?.items || [];
|
|
if (items.length === 0) {
|
|
return {
|
|
seriesData: [],
|
|
xAxisData: [],
|
|
legendData: []
|
|
};
|
|
}
|
|
|
|
const groupDim = groupBy as 'user' | 'model' | 'api_key' | null;
|
|
const isCached = metric === CACHED_METRIC;
|
|
|
|
const dateSet = new Set<string>(
|
|
generateDateRange(startDate, endDate, granularity)
|
|
);
|
|
items.forEach((item) => {
|
|
if (item.date?.value) {
|
|
dateSet.add(item.date.value);
|
|
}
|
|
});
|
|
|
|
const xAxis = Array.from(dateSet).sort(
|
|
(a, b) => dayjs(a).valueOf() - dayjs(b).valueOf()
|
|
);
|
|
|
|
const groupOrder: string[] = [];
|
|
const groupItemsMap = new Map<string, Map<string, BreakdownItem>>();
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
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 {
|
|
seriesData: barSeries,
|
|
xAxisData: xAxis,
|
|
legendData: dedupedLegend
|
|
};
|
|
}, [timeSeriesData, intl, generateCoolColors]);
|
|
|
|
const handleOnGroupByChange = (value: string) => {
|
|
onGroupByChange(value || null);
|
|
};
|
|
|
|
const groupByOptionsFiltered = groupByOptions
|
|
.map((item) => ({
|
|
label: intl.formatMessage({ id: item.label }),
|
|
value: item.value
|
|
}))
|
|
.filter((option) => {
|
|
if (currentUser?.is_admin) {
|
|
return true;
|
|
}
|
|
return option.value !== 'user';
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<CardWrapper style={{ width: '100%', marginTop: 20 }}>
|
|
<ControlsWrapper>
|
|
<div className="group">
|
|
<BaseSelect
|
|
variant="borderless"
|
|
prefix={
|
|
<ControlLabel>
|
|
{intl.formatMessage({ id: 'usage.filter.metric' })}
|
|
</ControlLabel>
|
|
}
|
|
options={metricOptions.map((item) => ({
|
|
label: intl.formatMessage({ id: item.label }),
|
|
value: item.value
|
|
}))}
|
|
value={metric}
|
|
popupMatchSelectWidth={false}
|
|
onChange={onMetricChange}
|
|
style={{ width: 'max-content' }}
|
|
/>
|
|
|
|
<BaseSelect
|
|
allowClear
|
|
variant="borderless"
|
|
prefix={
|
|
<ControlLabel>
|
|
{intl.formatMessage({ id: 'usage.filter.groupBy' })}
|
|
</ControlLabel>
|
|
}
|
|
options={groupByOptionsFiltered}
|
|
value={groupBy}
|
|
popupMatchSelectWidth={false}
|
|
onChange={handleOnGroupByChange}
|
|
style={{ width: 'max-content' }}
|
|
/>
|
|
</div>
|
|
<Segmented
|
|
size="small"
|
|
options={granularities.map((item) => ({
|
|
label: intl.formatMessage({ id: item.label }),
|
|
value: item.value
|
|
}))}
|
|
value={granularity}
|
|
onChange={onGranularityChange}
|
|
></Segmented>
|
|
</ControlsWrapper>
|
|
<BarChart
|
|
seriesData={seriesData}
|
|
xAxisData={xAxisData}
|
|
height={280}
|
|
legendData={legendData}
|
|
labelFormatter={labelFormatter}
|
|
legendIsolate={metric === CACHED_METRIC}
|
|
/>
|
|
</CardWrapper>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DailyUsage;
|