feat: usage
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { Tabs } from 'antd';
|
||||
import React from 'react';
|
||||
import { UsageFilterItem } from '../config/types';
|
||||
import ApiKeysTable from '../tables/apikeys-table';
|
||||
import ModelsTable from '../tables/models-table';
|
||||
import UsersTable from '../tables/users-table';
|
||||
|
||||
type FilterOptionType = Omit<UsageFilterItem, 'label' | 'deleted'>;
|
||||
|
||||
const BreakdownTabs: React.FC<{
|
||||
dateRange: {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
};
|
||||
scope: string;
|
||||
filters: {
|
||||
models?: FilterOptionType[];
|
||||
users?: FilterOptionType[];
|
||||
api_keys?: FilterOptionType[];
|
||||
};
|
||||
}> = ({ filters, dateRange, scope }) => {
|
||||
const items = [
|
||||
{
|
||||
key: 'models',
|
||||
label: 'Models',
|
||||
forceRender: true,
|
||||
children: (
|
||||
<ModelsTable
|
||||
models={filters.models || []}
|
||||
dateRange={dateRange}
|
||||
scope={scope}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
label: 'Users',
|
||||
forceRender: true,
|
||||
children: <UsersTable users={filters.users || []} dateRange={dateRange} />
|
||||
},
|
||||
{
|
||||
key: 'api_keys',
|
||||
label: 'API Keys',
|
||||
forceRender: true,
|
||||
children: (
|
||||
<ApiKeysTable
|
||||
apiKeys={filters.api_keys || []}
|
||||
dateRange={dateRange}
|
||||
scope={scope}
|
||||
/>
|
||||
)
|
||||
}
|
||||
].filter((item) => {
|
||||
if (item.key === 'users') {
|
||||
return scope === 'all';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Tabs defaultActiveKey="models" items={items} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default BreakdownTabs;
|
||||
@@ -0,0 +1,196 @@
|
||||
import CardWrapper from '@/components/card-wrapper';
|
||||
import { SimpleCard } from '@/components/card-wrapper/simple-card';
|
||||
import MixLineBar from '@/components/echarts/mix-line-bar';
|
||||
import BaseSelect from '@/components/seal-form/base/select';
|
||||
import { baseColorMap } from '@/pages/dashboard/config';
|
||||
import { formatLargeNumber } from '@/utils';
|
||||
import { Divider, 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';
|
||||
|
||||
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);
|
||||
border-right: 1px solid var(--ant-color-split);
|
||||
padding-right: 8px;
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
interface DailyUsageProps {
|
||||
timeSeriesData: TimeSeriesData | null;
|
||||
metric: string;
|
||||
groupBy: string;
|
||||
granularity: string;
|
||||
onMetricChange: (value: string) => void;
|
||||
onGroupByChange: (value: string) => void;
|
||||
onGranularityChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const labelFormatter = (v: any) => {
|
||||
return dayjs(v).format('MM-DD');
|
||||
};
|
||||
|
||||
const DailyUsage: React.FC<DailyUsageProps> = (props) => {
|
||||
const {
|
||||
timeSeriesData,
|
||||
metric,
|
||||
groupBy,
|
||||
granularity,
|
||||
onMetricChange,
|
||||
onGroupByChange,
|
||||
onGranularityChange
|
||||
} = props;
|
||||
|
||||
const summary = timeSeriesData?.summary || {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
api_requests: 0,
|
||||
models_called: 0
|
||||
};
|
||||
|
||||
const summaryCards = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: formatLargeNumber(summary.input_tokens) as string,
|
||||
value: 'Input tokens',
|
||||
color: baseColorMap.baseR3,
|
||||
iconType: 'roundRect'
|
||||
},
|
||||
{
|
||||
label: formatLargeNumber(summary.output_tokens) as string,
|
||||
value: 'Output tokens',
|
||||
color: baseColorMap.base,
|
||||
iconType: 'roundRect'
|
||||
},
|
||||
{
|
||||
label: formatLargeNumber(summary.total_tokens) as string,
|
||||
value: 'Total tokens',
|
||||
color: baseColorMap.baseL1,
|
||||
iconType: 'roundRect'
|
||||
},
|
||||
{
|
||||
label: formatLargeNumber(summary.api_requests) as string,
|
||||
value: 'API requests',
|
||||
color: baseColorMap.baseR1,
|
||||
iconType: 'circle'
|
||||
},
|
||||
{
|
||||
label: summary.models_called.toString(),
|
||||
value: 'Models called',
|
||||
color: baseColorMap.baseR2,
|
||||
iconType: 'roundRect'
|
||||
}
|
||||
];
|
||||
}, [summary]);
|
||||
|
||||
const { chartData, xAxisData } = useMemo(() => {
|
||||
if (!timeSeriesData?.series || timeSeriesData.series.length === 0) {
|
||||
return { chartData: { line: [], bar: [] }, xAxisData: [] };
|
||||
}
|
||||
|
||||
const series = timeSeriesData.series;
|
||||
const xAxis = series[0]?.timeline?.map((item) => item.date) || [];
|
||||
|
||||
const colors = [
|
||||
baseColorMap.base,
|
||||
baseColorMap.baseR3,
|
||||
baseColorMap.baseL1,
|
||||
baseColorMap.baseR1,
|
||||
baseColorMap.baseR2,
|
||||
baseColorMap.baseL2
|
||||
];
|
||||
|
||||
const chartSeries = series.map((item, index) => ({
|
||||
name: item.label,
|
||||
data: item.timeline.map((t) => ({ time: t.date, value: t.value })),
|
||||
color: colors[index % colors.length]
|
||||
}));
|
||||
|
||||
// API requests use line chart, tokens use bar chart
|
||||
if (metric === 'api_requests') {
|
||||
return {
|
||||
chartData: { line: chartSeries, bar: [] },
|
||||
xAxisData: xAxis
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
chartData: { line: [], bar: chartSeries },
|
||||
xAxisData: xAxis
|
||||
};
|
||||
}
|
||||
}, [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]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardWrapper style={{ width: '100%', marginTop: 20 }}>
|
||||
<ControlsWrapper>
|
||||
<div className="group">
|
||||
<BaseSelect
|
||||
variant="outlined"
|
||||
prefix={<ControlLabel>Metric</ControlLabel>}
|
||||
options={metricOptions}
|
||||
value={metric}
|
||||
onChange={onMetricChange}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
|
||||
<BaseSelect
|
||||
variant="outlined"
|
||||
prefix={<ControlLabel>Group by</ControlLabel>}
|
||||
options={groupByOptions}
|
||||
value={groupBy}
|
||||
onChange={onGroupByChange}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
</div>
|
||||
<Segmented
|
||||
size="middle"
|
||||
shape="round"
|
||||
options={granularities}
|
||||
value={granularity}
|
||||
onChange={onGranularityChange}
|
||||
></Segmented>
|
||||
</ControlsWrapper>
|
||||
<Divider style={{ margin: 0 }} />
|
||||
|
||||
<SimpleCard dataList={summaryCards} height={80} />
|
||||
<MixLineBar
|
||||
chartData={chartData}
|
||||
seriesData={[]}
|
||||
xAxisData={xAxisData}
|
||||
height={360}
|
||||
smooth={false}
|
||||
legendData={legendData}
|
||||
labelFormatter={labelFormatter}
|
||||
/>
|
||||
</CardWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DailyUsage;
|
||||
@@ -0,0 +1,186 @@
|
||||
import BaseSelect from '@/components/seal-form/base/select';
|
||||
import SimpleSelect from '@/components/seal-form/simple-select';
|
||||
import useRangePickerPreset from '@/pages/dashboard/hooks/use-rangepicker-preset';
|
||||
import { useModel } from '@@/plugin-model';
|
||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Button, DatePicker, Dropdown, MenuProps } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import React from 'react';
|
||||
import { scopeOptions } from '../config';
|
||||
import { UsageFilterItem } from '../config/types';
|
||||
import FilterBarCss from '../styles/filter-bar.less';
|
||||
|
||||
const DefaultDateConfig = {
|
||||
maxRange: 90,
|
||||
defaultRange: 29
|
||||
};
|
||||
type OptionType = UsageFilterItem & {
|
||||
value: string;
|
||||
};
|
||||
interface FilterBarProps {
|
||||
scope: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
selectedModels: string[];
|
||||
selectedUsers: string[];
|
||||
selectedApiKeys: string[];
|
||||
modelOptions: OptionType[];
|
||||
userOptions: OptionType[];
|
||||
apiKeyOptions: OptionType[];
|
||||
onScopeChange: (value: string) => void;
|
||||
onDateChange: (dates: any, dateStrings: [string, string]) => void;
|
||||
onModelsChange: (value: string[]) => void;
|
||||
onUsersChange: (value: string[]) => void;
|
||||
onApiKeysChange: (value: string[]) => void;
|
||||
onExport?: () => void;
|
||||
handleSearch?: () => void;
|
||||
onExportChart: () => void;
|
||||
onExportTable: () => void;
|
||||
}
|
||||
|
||||
const FilterBar: React.FC<FilterBarProps> = (props) => {
|
||||
const {
|
||||
scope,
|
||||
startDate,
|
||||
endDate,
|
||||
selectedModels,
|
||||
selectedUsers,
|
||||
selectedApiKeys,
|
||||
modelOptions,
|
||||
userOptions,
|
||||
apiKeyOptions,
|
||||
onScopeChange,
|
||||
onDateChange,
|
||||
onModelsChange,
|
||||
onUsersChange,
|
||||
onApiKeysChange,
|
||||
onExportChart,
|
||||
onExportTable,
|
||||
handleSearch
|
||||
} = props;
|
||||
const intl = useIntl();
|
||||
const { disabledRangeDaysDate, rangePresets } = useRangePickerPreset({
|
||||
range: DefaultDateConfig.maxRange,
|
||||
disabledDate: true,
|
||||
presetRanges: [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'dashboard.usage.datePicker.last7days'
|
||||
}),
|
||||
value: [dayjs().add(-6, 'd'), dayjs()]
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'dashboard.usage.datePicker.last30days'
|
||||
}),
|
||||
value: [dayjs().add(-29, 'd'), dayjs()]
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'dashboard.usage.datePicker.last60days'
|
||||
}),
|
||||
value: [dayjs().add(-59, 'd'), dayjs()]
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'dashboard.usage.datePicker.last90days'
|
||||
}),
|
||||
value: [dayjs().add(-89, 'd'), dayjs()]
|
||||
}
|
||||
]
|
||||
});
|
||||
const initialInfo = useModel('@@initialState');
|
||||
const { initialState } = initialInfo || {};
|
||||
|
||||
const exportMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'chart',
|
||||
label: 'Export Chart Data',
|
||||
onClick: onExportChart
|
||||
},
|
||||
{
|
||||
key: 'table',
|
||||
label: 'Export Table Data',
|
||||
onClick: onExportTable
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={FilterBarCss.wrapper}>
|
||||
<div className={FilterBarCss.filters}>
|
||||
{initialState?.currentUser?.is_admin && (
|
||||
<BaseSelect
|
||||
options={scopeOptions}
|
||||
value={scope}
|
||||
onChange={onScopeChange}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
)}
|
||||
<DatePicker.RangePicker
|
||||
maxDate={dayjs()}
|
||||
defaultValue={[
|
||||
dayjs().add(-DefaultDateConfig.defaultRange, 'd'),
|
||||
dayjs()
|
||||
]}
|
||||
disabledDate={disabledRangeDaysDate}
|
||||
presets={rangePresets}
|
||||
allowClear={false}
|
||||
style={{ width: 240 }}
|
||||
value={[dayjs(startDate), dayjs(endDate)]}
|
||||
onChange={onDateChange}
|
||||
/>
|
||||
<SimpleSelect
|
||||
allowClear
|
||||
showSearch
|
||||
mode="multiple"
|
||||
options={modelOptions}
|
||||
maxTagCount={0}
|
||||
placeholder="Model"
|
||||
styles={{
|
||||
wrapper: { flex: 1, maxWidth: 300, minWidth: 150 }
|
||||
}}
|
||||
value={selectedModels}
|
||||
onChange={onModelsChange}
|
||||
/>
|
||||
<SimpleSelect
|
||||
allowClear
|
||||
showSearch
|
||||
mode="multiple"
|
||||
options={userOptions}
|
||||
maxTagCount={0}
|
||||
placeholder="User"
|
||||
styles={{
|
||||
wrapper: { flex: 1, maxWidth: 240, minWidth: 150 }
|
||||
}}
|
||||
value={selectedUsers}
|
||||
onChange={onUsersChange}
|
||||
/>
|
||||
<SimpleSelect
|
||||
allowClear
|
||||
showSearch
|
||||
mode="multiple"
|
||||
options={apiKeyOptions}
|
||||
maxTagCount={0}
|
||||
placeholder="API Key"
|
||||
styles={{
|
||||
wrapper: { flex: 1, maxWidth: 240, minWidth: 100 }
|
||||
}}
|
||||
value={selectedApiKeys}
|
||||
onChange={onApiKeysChange}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
style={{ color: 'var(--ant-color-text-tertiary)' }}
|
||||
onClick={handleSearch}
|
||||
icon={<SyncOutlined></SyncOutlined>}
|
||||
></Button>
|
||||
</div>
|
||||
<Dropdown menu={{ items: exportMenuItems }}>
|
||||
<Button icon={<DownloadOutlined />} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterBar;
|
||||
Reference in New Issue
Block a user