feat: usage

This commit is contained in:
jialin
2026-04-22 13:16:48 +08:00
committed by jialin
parent b057d52499
commit 7d75714662
37 changed files with 2199 additions and 288 deletions
@@ -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;
+196
View File
@@ -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;
+186
View File
@@ -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;