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
+10 -5
View File
@@ -15,11 +15,16 @@ const useExportData = (params: { columns: any[] }) => {
const exportData = (dataList: any[]) => {
const fileName = `benchmark.xlsx`;
exportJsonToExcel({
jsonData: dataList || [],
fileName: fileName,
fields: Object.keys(colIndexMap),
fieldLabels: colIndexMap,
formatMap: {}
fileName,
sheets: [
{
jsonData: dataList || [],
sheetName: 'benchmark_data',
fields: Object.keys(colIndexMap),
fieldLabels: colIndexMap,
formatMap: {}
}
]
});
};
return { exportData };
@@ -108,27 +108,34 @@ const ExportData: React.FC<{
const handleSubmit = () => {
const fileName = `usage-data_${query.start_date || ''}_${query.end_date || ''}.xlsx`;
exportJsonToExcel({
jsonData: result.data?.items || [],
fileName: fileName,
fields: exportTableColumns
.map((col) => col.dataIndex)
.filter(Boolean) as string[],
fieldLabels: {
user_id: 'User',
model_id: 'Model',
date: 'Date',
prompt_token_count: 'Prompt Tokens',
completion_token_count: 'Completion Tokens',
request_count: 'API Requests'
},
formatMap: {
user_id: (value: string) => {
return userList.find((item) => item.value === value)?.label || value;
},
model_id: (value: string, record: any) => {
return getModelName(record);
sheets: [
{
jsonData: result.data?.items || [],
sheetName: 'usage_data',
fields: exportTableColumns
.map((col) => col.dataIndex)
.filter(Boolean) as string[],
fieldLabels: {
user_id: 'User',
model_id: 'Model',
date: 'Date',
prompt_token_count: 'Prompt Tokens',
completion_token_count: 'Completion Tokens',
request_count: 'API Requests'
},
formatMap: {
user_id: (value: string) => {
return (
userList.find((item) => item.value === value)?.label || value
);
},
model_id: (value: string, record: any) => {
return getModelName(record);
}
}
}
}
]
});
};
@@ -4,6 +4,10 @@ import dayjs, { type Dayjs } from 'dayjs';
interface RangePickerPreset {
range: number;
presetRanges?: {
label: React.ReactNode;
value: [Dayjs, Dayjs] | (() => [Dayjs, Dayjs]);
}[];
disabledDate?: boolean;
}
@@ -15,7 +19,7 @@ export default function useRangePickerPreset(options?: RangePickerPreset): {
}[];
range: number;
} {
const { range = 60, disabledDate } = options || {};
const { range = 60, disabledDate, presetRanges } = options || {};
const intl = useIntl();
const getYearMonth = (date: Dayjs) => date.year() * 12 + date.month();
@@ -54,7 +58,7 @@ export default function useRangePickerPreset(options?: RangePickerPreset): {
const rangePresets: {
label: React.ReactNode;
value: [Dayjs, Dayjs] | (() => [Dayjs, Dayjs]);
}[] = [
}[] = presetRanges || [
{
label: intl.formatMessage({ id: 'dashboard.usage.datePicker.last7days' }),
value: [dayjs().add(-6, 'd'), dayjs()]
+64
View File
@@ -0,0 +1,64 @@
import { request } from '@umijs/max';
import {
BreakdownItem,
FilterOptionType,
TimeSeriesData,
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';
export async function queryUsageMetaData(
params: Record<string, any>,
options?: any
): Promise<UsageMeta> {
return request<UsageMeta>(USAGE_META, {
params,
method: 'GET',
cancelToken: options?.token
});
}
export async function queryUsageTimeSeriesData(
params: {
start_date: string;
end_date: string;
scope: string;
metric: string;
group_by: string;
granularity: string;
filters: {
models?: FilterOptionType[];
users?: FilterOptionType[];
api_keys?: FilterOptionType[];
};
},
options?: any
): Promise<TimeSeriesData> {
return request<TimeSeriesData>(USAGE_TIMESERIES, {
data: params,
method: 'POST',
cancelToken: options?.token
});
}
export async function queryUsageBreakdownList(
params: Global.SearchParams & {
filters: {
models?: FilterOptionType[];
users?: FilterOptionType[];
api_keys?: FilterOptionType[];
};
},
options?: any
) {
return request<Global.PageResponse<BreakdownItem>>(USAGE_BREAKDOWN, {
data: params,
method: 'POST',
cancelToken: options?.token
});
}
@@ -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;
+104
View File
@@ -0,0 +1,104 @@
export const groupByOptions = [
{
value: 'model',
label: 'Model'
},
{
value: 'user',
label: 'User'
},
{
value: 'api_key',
label: 'API Key'
}
];
export const granularities = [
{
value: 'day',
label: 'Day'
},
{
value: 'week',
label: 'Week'
},
{
value: 'month',
label: 'Month'
}
];
export const metricOptions = [
{
value: 'input_tokens',
label: 'Input Tokens'
},
{
value: 'output_tokens',
label: 'Output Tokens'
},
{
value: 'total_tokens',
label: 'Total Tokens'
},
{
value: 'api_requests',
label: 'API Requests'
}
];
export const scopeOptions = [
{
value: 'all',
label: 'All Users'
},
{
value: 'self',
label: 'My Usage'
}
];
interface TimelineItem {
date: string;
value: number;
}
interface SourceItem {
label: string;
timeline: TimelineItem[];
}
export const transformTimelineToTable = (data: SourceItem[]) => {
const dateMap: Record<string, Record<string, number | string>> = {};
data.forEach((item) => {
const label = item.label;
item.timeline.forEach(({ date, value }) => {
if (!dateMap[date]) {
dateMap[date] = { date };
}
dateMap[date][label] = value;
});
});
return Object.values(dateMap).sort((a, b) =>
String(a.date).localeCompare(String(b.date))
);
};
export const generateColumns = (data: SourceItem[]) => {
return [
{
title: 'Date',
dataIndex: 'date',
key: 'date'
},
...data.map((item) => ({
title: item.label,
dataIndex: item.label,
key: item.label
}))
];
};
+69
View File
@@ -0,0 +1,69 @@
export interface UsageFilterItem {
identity: {
value: {
cluster_name: string;
model_name: string | null;
user_name: string;
api_key_name: string | null;
access_key: string | null;
api_key_is_custom: boolean | null;
};
current: {
model_id: string | null;
user_id: string | null;
api_key_id: string | null;
};
};
label: string;
deleted: boolean;
}
export interface TimeSeriesSummary {
input_tokens: number;
output_tokens: number;
total_tokens: number;
api_requests: number;
models_called: number;
}
export interface TimeLineItem {
date: string;
value: number;
}
export type TimeSeriesItem = UsageFilterItem & {
timeline: TimeLineItem[];
};
export interface TimeSeriesData {
summary: TimeSeriesSummary;
metric: 'string';
group_by: 'string';
granularity: 'string';
series: TimeSeriesItem[];
}
export type BreakdownItem = UsageFilterItem & {
cluster_name: string;
model_name: string;
user_name: string;
api_key_name: string;
input_tokens: number;
output_tokens: number;
total_tokens: number;
api_requests: number;
avg_tokens_per_request: number;
models_called: number;
api_keys_used: number;
last_active: string;
};
export interface UsageMeta {
filters: {
models: UsageFilterItem[];
users: UsageFilterItem[];
api_keys: UsageFilterItem[];
};
}
export type FilterOptionType = Omit<UsageFilterItem, 'label' | 'deleted'>;
@@ -0,0 +1,99 @@
// columns.ts
import AutoTooltip from '@/components/auto-tooltip';
import { tableSorter } from '@/config/settings';
import { useIntl } from '@umijs/max';
import { useMemo } from 'react';
import { BreakdownItem as ListItem } from '../config/types';
const useModelsColumns = (): Array<{
title: string;
dataIndex: string;
key: string;
}> => {
const intl = useIntl();
return useMemo(() => {
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
dataIndex: 'api_key_name',
key: 'api_key_name',
sorter: tableSorter(1),
render: (text: string, record: ListItem) => (
<span className="flex items-center">
<AutoTooltip ghost style={{ maxWidth: 400 }}>
<span className="text-primary">{text}</span>
</AutoTooltip>
</span>
)
},
{
title: 'User',
dataIndex: 'user_name',
key: 'user_name',
render: (text: string, record: ListItem) => (
<AutoTooltip ghost style={{ maxWidth: 400 }}>
{text || '-'}
</AutoTooltip>
)
},
{
title: 'Models Used',
dataIndex: 'models_called',
key: 'models_called',
sorter: tableSorter(2),
render: (text: string, record: ListItem) => (
<AutoTooltip ghost>{text || '-'}</AutoTooltip>
)
},
{
title: 'Input Tokens',
dataIndex: 'input_tokens',
key: 'input_tokens',
render: (text: string[], record: ListItem) => (
<AutoTooltip ghost>{text}</AutoTooltip>
)
},
{
title: 'Output Tokens',
dataIndex: 'output_tokens',
key: 'output_tokens',
render: (text: string[], record: ListItem) => (
<AutoTooltip ghost>{text}</AutoTooltip>
)
},
{
title: 'Total Tokens',
dataIndex: 'total_tokens',
key: 'total_tokens',
sorter: tableSorter(3),
ellipsis: {
showTitle: false
},
render: (text: number) => <AutoTooltip ghost>{text}</AutoTooltip>
},
{
title: 'API Requests',
dataIndex: 'api_requests',
key: 'api_requests',
sorter: tableSorter(3),
ellipsis: {
showTitle: false
},
render: (text: number) => <AutoTooltip ghost>{text}</AutoTooltip>
},
{
title: 'Last Active',
dataIndex: 'last_active',
key: 'last_active',
sorter: tableSorter(3),
ellipsis: {
showTitle: false
},
render: (text: number) => <AutoTooltip ghost>{text}</AutoTooltip>
}
];
}, [intl]);
};
export default useModelsColumns;
@@ -0,0 +1,76 @@
import {
apiKeysTableDataAtom,
modelsTableDataAtom,
usersTableDataAtom
} from '@/atoms/usage';
import { exportJsonToExcel } from '@/utils/excel-reader';
import { useStore } from 'jotai';
import useAPIKeysColumns from './use-apikeys-columns';
import useModelsColumns from './use-models-columns';
import useUsersColumns from './use-users-columns';
const useExportTable = () => {
const store = useStore();
const modelsColumns = useModelsColumns();
const apiKeysColumns = useAPIKeysColumns();
const usersColumns = useUsersColumns();
const exportTable = () => {
const usersTableData = store.get(usersTableDataAtom);
const apiKeysTableData = store.get(apiKeysTableDataAtom);
const modelsTableData = store.get(modelsTableDataAtom);
exportJsonToExcel({
fileName: 'table_data.xlsx',
sheets: [
{
jsonData: modelsTableData.dataList || [],
sheetName: 'models',
fields: modelsColumns
.map((col: any) => col.dataIndex)
.filter(Boolean) as string[],
fieldLabels: modelsColumns.reduce(
(map, col) => {
map[col.dataIndex] = col.title;
return map;
},
{} as Record<string, any>
),
formatMap: {}
},
{
jsonData: apiKeysTableData.dataList || [],
sheetName: 'api_keys',
fields: apiKeysColumns
.map((col: any) => col.dataIndex)
.filter(Boolean) as string[],
fieldLabels: apiKeysColumns.reduce(
(map, col) => {
map[col.dataIndex] = col.title;
return map;
},
{} as Record<string, any>
),
formatMap: {}
},
{
jsonData: usersTableData.dataList || [],
sheetName: 'users',
fields: usersColumns
.map((col: any) => col.dataIndex)
.filter(Boolean) as string[],
fieldLabels: usersColumns.reduce(
(map, col) => {
map[col.dataIndex] = col.title;
return map;
},
{} as Record<string, any>
),
formatMap: {}
}
]
});
};
return { exportTable };
};
export default useExportTable;
@@ -0,0 +1,103 @@
// columns.ts
import AutoTooltip from '@/components/auto-tooltip';
import { tableSorter } from '@/config/settings';
import { useIntl } from '@umijs/max';
import { useMemo } from 'react';
import { BreakdownItem as ListItem } from '../config/types';
interface ColumnsHookProps {
sortOrder: string[];
}
const useModelsColumns = (): Array<{
title: string;
dataIndex: string;
key: string;
}> => {
const intl = useIntl();
return useMemo(() => {
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
dataIndex: 'model_name',
key: 'model_name',
sorter: tableSorter(1),
render: (text: string, record: ListItem) => (
<span className="flex items-center">
<AutoTooltip ghost style={{ maxWidth: 400 }}>
<span className="text-primary">{text}</span>
</AutoTooltip>
</span>
)
},
{
title: 'Provider',
dataIndex: 'provider',
key: 'provider',
render: (text: string, record: ListItem) => (
<AutoTooltip ghost style={{ maxWidth: 400 }}>
{text || '-'}
</AutoTooltip>
)
},
{
title: 'Cluster',
dataIndex: 'cluster_name',
key: 'cluster_name',
sorter: tableSorter(2),
render: (text: string, record: ListItem) => (
<AutoTooltip ghost>{text || '-'}</AutoTooltip>
)
},
{
title: 'Input Tokens',
dataIndex: 'input_tokens',
key: 'input_tokens',
render: (text: string[], record: ListItem) => (
<AutoTooltip ghost>{text}</AutoTooltip>
)
},
{
title: 'Output Tokens',
dataIndex: 'output_tokens',
key: 'output_tokens',
render: (text: string[], record: ListItem) => (
<AutoTooltip ghost>{text}</AutoTooltip>
)
},
{
title: 'Total Tokens',
dataIndex: 'total_tokens',
key: 'total_tokens',
sorter: tableSorter(3),
ellipsis: {
showTitle: false
},
render: (text: number) => <AutoTooltip ghost>{text}</AutoTooltip>
},
{
title: 'API Requests',
dataIndex: 'api_requests',
key: 'api_requests',
sorter: tableSorter(3),
ellipsis: {
showTitle: false
},
render: (text: number) => <AutoTooltip ghost>{text}</AutoTooltip>
},
{
title: 'Last Active',
dataIndex: 'last_active',
key: 'last_active',
sorter: tableSorter(3),
ellipsis: {
showTitle: false
},
render: (text: number) => <AutoTooltip ghost>{text}</AutoTooltip>
}
];
}, [intl]);
};
export default useModelsColumns;
+103
View File
@@ -0,0 +1,103 @@
// columns.ts
import AutoTooltip from '@/components/auto-tooltip';
import { tableSorter } from '@/config/settings';
import { useIntl } from '@umijs/max';
import { useMemo } from 'react';
import { BreakdownItem as ListItem } from '../config/types';
interface ColumnsHookProps {
sortOrder: string[];
}
const useModelsColumns = (): Array<{
title: string;
dataIndex: string;
key: string;
}> => {
const intl = useIntl();
return useMemo(() => {
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
dataIndex: 'user_name',
key: 'user_name',
sorter: tableSorter(1),
render: (text: string, record: ListItem) => (
<span className="flex items-center">
<AutoTooltip ghost style={{ maxWidth: 400 }}>
<span className="text-primary">{text}</span>
</AutoTooltip>
</span>
)
},
{
title: 'Models Used',
dataIndex: 'models_called',
key: 'models_called',
render: (text: string, record: ListItem) => (
<AutoTooltip ghost style={{ maxWidth: 400 }}>
{text || '-'}
</AutoTooltip>
)
},
{
title: 'API Keys Used',
dataIndex: 'api_keys_used',
key: 'api_keys_used',
sorter: tableSorter(2),
render: (text: string, record: ListItem) => (
<AutoTooltip ghost>{text || '-'}</AutoTooltip>
)
},
{
title: 'Input Tokens',
dataIndex: 'input_tokens',
key: 'input_tokens',
render: (text: string[], record: ListItem) => (
<AutoTooltip ghost>{text}</AutoTooltip>
)
},
{
title: 'Output Tokens',
dataIndex: 'output_tokens',
key: 'output_tokens',
render: (text: string[], record: ListItem) => (
<AutoTooltip ghost>{text}</AutoTooltip>
)
},
{
title: 'Total Tokens',
dataIndex: 'total_tokens',
key: 'total_tokens',
sorter: tableSorter(3),
ellipsis: {
showTitle: false
},
render: (text: number) => <AutoTooltip ghost>{text}</AutoTooltip>
},
{
title: 'API Requests',
dataIndex: 'api_requests',
key: 'api_requests',
sorter: tableSorter(3),
ellipsis: {
showTitle: false
},
render: (text: number) => <AutoTooltip ghost>{text}</AutoTooltip>
},
{
title: 'Last Active',
dataIndex: 'last_active',
key: 'last_active',
sorter: tableSorter(3),
ellipsis: {
showTitle: false
},
render: (text: number) => <AutoTooltip ghost>{text}</AutoTooltip>
}
];
}, [intl]);
};
export default useModelsColumns;
+260
View File
@@ -0,0 +1,260 @@
import { exportJsonToExcel } from '@/utils/excel-reader';
import { useModel } from '@@/plugin-model';
import dayjs from 'dayjs';
import React, { useEffect, useMemo, useState } from 'react';
import BreakdownTabs from './components/breakdown-tabs';
import DailyUsage from './components/daily-usage';
import FilterBar from './components/filter-bar';
import { generateColumns, transformTimelineToTable } from './config';
import { UsageFilterItem } from './config/types';
import useExportTable from './hooks/use-export-table';
import useQueryUsageMetaData from './services/use-query-meta-data';
import useQueryTimeSeriesData from './services/use-query-timeseries-data';
const DefaultDateConfig = {
defaultRange: 29
};
type FilterOptionType = Omit<UsageFilterItem, 'label' | 'deleted'>;
const Usage: React.FC = () => {
const initialInfo = useModel('@@initialState');
const { initialState } = initialInfo || {};
const { exportTable } = useExportTable();
const summaryColumns = [
{
title: 'Input Tokens',
dataIndex: 'input_tokens',
key: 'input_tokens'
},
{
title: 'Output Tokens',
dataIndex: 'output_tokens',
key: 'output_tokens'
},
{
title: 'Total Tokens',
dataIndex: 'total_tokens',
key: 'total_tokens'
},
{
title: 'API Requests',
dataIndex: 'api_requests',
key: 'api_requests'
},
{
title: 'Models Used',
dataIndex: 'models_called',
key: 'models_called'
}
];
const [chartFilters, setChartFilters] = useState<{
metric: string;
group_by: string;
granularity: string;
}>({
metric: 'total_tokens',
group_by: 'model',
granularity: 'day'
});
const [commonFilters, setCommonFilters] = useState<{
models: string[];
users: string[];
api_keys: string[];
start_date: string;
end_date: string;
scope: string;
}>({
scope: initialState?.currentUser?.is_admin ? 'all' : 'self',
models: [],
users: [],
api_keys: [],
start_date: dayjs()
.subtract(DefaultDateConfig.defaultRange, 'days')
.format('YYYY-MM-DD'),
end_date: dayjs().format('YYYY-MM-DD')
});
const { detailData: metaData, fetchData: fetchMetaData } =
useQueryUsageMetaData();
const { detailData: timeSeriesData, fetchData: fetchTimeSeriesData } =
useQueryTimeSeriesData();
const modelOptions = metaData?.models || [];
const userOptions = metaData?.users || [];
const apiKeyOptions = metaData?.api_keys || [];
const buildFilters = (selected: {
models: string[];
users: string[];
api_keys: string[];
}) => {
const filters: {
models?: FilterOptionType[];
users?: FilterOptionType[];
api_keys?: FilterOptionType[];
} = {};
if (selected.models?.length > 0) {
// filter the options
filters.models = modelOptions
.filter((item) => selected.models?.includes(item.value || ''))
.map((item) => ({
identity: item.identity
}));
}
if (selected.users?.length > 0) {
filters.users = userOptions
.filter((item) => selected.users?.includes(item.value || ''))
.map((item) => ({
identity: item.identity
}));
}
if (selected.api_keys?.length > 0) {
filters.api_keys = apiKeyOptions
.filter((item) => selected.api_keys?.includes(item.value || ''))
.map((item) => ({
identity: item.identity
}));
}
return filters;
};
const filters = useMemo(
() => buildFilters(commonFilters),
[commonFilters, modelOptions, userOptions, apiKeyOptions]
);
const fetchData = (
currentSelectedFilters = commonFilters,
currentChartFilters = chartFilters
) => {
fetchTimeSeriesData({
...currentChartFilters,
start_date: currentSelectedFilters.start_date,
end_date: currentSelectedFilters.end_date,
filters: buildFilters(currentSelectedFilters)
});
};
useEffect(() => {
fetchMetaData();
fetchData(commonFilters, chartFilters);
}, []);
const handleScopeChange = (value: string) => {
setCommonFilters((prev) => ({ ...prev, scope: value }));
fetchData(
{
...commonFilters,
scope: value
},
chartFilters
);
};
const handleDateChange = (_dates: any, dateStrings: [string, string]) => {
const [start_date, end_date] = dateStrings;
setCommonFilters((prev) => ({ ...prev, start_date, end_date }));
fetchData({ ...commonFilters, start_date, end_date }, chartFilters);
};
const handleFilterChange = (type: string, value: string[]) => {
setCommonFilters((prev) => ({ ...prev, [type]: value }));
fetchData({ ...commonFilters, [type]: value }, chartFilters);
};
const handleChartFilterChange = (type: string, value: string) => {
setChartFilters((prev) => ({ ...prev, [type]: value }));
fetchData(commonFilters, { ...chartFilters, [type]: value });
};
console.log('timeSeriesData:', timeSeriesData);
const handleOnExportChart = () => {
const columns = generateColumns(timeSeriesData.series);
const tableData = transformTimelineToTable(timeSeriesData.series);
exportJsonToExcel({
fileName: `${chartFilters.metric}_${chartFilters.group_by}_${chartFilters.granularity}_${commonFilters.start_date}_${commonFilters.end_date}.xlsx`,
sheets: [
{
jsonData: tableData || [],
sheetName: `${chartFilters.metric}`,
fields: columns
.map((col) => col.dataIndex)
.filter(Boolean) as string[],
fieldLabels: columns.reduce(
(map, col) => {
map[col.dataIndex] = col.title;
return map;
},
{} as Record<string, any>
),
formatMap: {}
},
{
jsonData: [timeSeriesData.summary || {}],
sheetName: `summary`,
fields: summaryColumns
.map((col) => col.dataIndex)
.filter(Boolean) as string[],
fieldLabels: summaryColumns.reduce(
(map, col) => {
map[col.dataIndex] = col.title;
return map;
},
{} as Record<string, any>
),
formatMap: {}
}
]
});
};
return (
<div>
<FilterBar
scope={commonFilters.scope}
startDate={commonFilters.start_date}
endDate={commonFilters.end_date}
selectedModels={commonFilters.models || []}
selectedUsers={commonFilters.users || []}
selectedApiKeys={commonFilters.api_keys || []}
modelOptions={metaData?.models || []}
userOptions={metaData?.users || []}
apiKeyOptions={metaData?.api_keys || []}
handleSearch={fetchData}
onScopeChange={handleScopeChange}
onDateChange={handleDateChange}
onModelsChange={(value) => handleFilterChange('models', value)}
onUsersChange={(value) => handleFilterChange('users', value)}
onApiKeysChange={(value) => handleFilterChange('api_keys', value)}
onExportChart={handleOnExportChart}
onExportTable={exportTable}
/>
<DailyUsage
timeSeriesData={timeSeriesData}
metric={chartFilters.metric}
groupBy={chartFilters.group_by}
granularity={chartFilters.granularity}
onMetricChange={(value) => handleChartFilterChange('metric', value)}
onGroupByChange={(value) => handleChartFilterChange('group_by', value)}
onGranularityChange={(value) =>
handleChartFilterChange('granularity', value)
}
/>
<BreakdownTabs
filters={filters}
dateRange={commonFilters}
scope={commonFilters.scope}
></BreakdownTabs>
</div>
);
};
export default Usage;
@@ -0,0 +1,93 @@
import {
apiKeysTableDataAtom,
modelsTableDataAtom,
usersTableDataAtom
} from '@/atoms/usage';
import { useQueryDataList } from '@/hooks/use-query-data-list';
import { useSetAtom } from 'jotai';
import React from 'react';
import { queryUsageBreakdownList } from '../apis';
import { FilterOptionType, BreakdownItem as ListItem } from '../config/types';
export const useQueryBreakdownList = (options?: {
getLabel?: (item: ListItem) => string;
getValue?: (item: ListItem) => any;
key: 'modelsTableData' | 'usersTableData' | 'apiKeysTableData' | string;
}) => {
const setApiKeysTableData = useSetAtom(apiKeysTableDataAtom);
const setModelsTableData = useSetAtom(modelsTableDataAtom);
const setUsersTableData = useSetAtom(usersTableDataAtom);
const { key = 'usageBreakdownList' } = options || {};
const [dataSource, setDataSource] = React.useState<{
loadend: boolean;
dataList: ListItem[];
total: number;
}>({
total: 0,
loadend: false,
dataList: []
});
const { dataList, loading, fetchData, cancelRequest } = useQueryDataList<
ListItem,
Global.SearchParams & {
filters: {
models?: FilterOptionType[];
users?: FilterOptionType[];
api_keys?: FilterOptionType[];
};
},
Global.PageResponse<ListItem>
>({
key: key,
responseType: 'object',
fetchList: queryUsageBreakdownList
});
const fetchListData = async (
params: Global.SearchParams & {
filters: {
models?: FilterOptionType[];
users?: FilterOptionType[];
api_keys?: FilterOptionType[];
};
}
) => {
const res = await fetchData(params);
setDataSource({
dataList: res.items || [],
loadend: true,
total: res.pagination?.total || 0
});
if (key === 'apiKeysTableData') {
setApiKeysTableData({
dataList: res.items || [],
loadend: true,
total: res.pagination?.total || 0
});
}
if (key === 'modelsTableData') {
setModelsTableData({
dataList: res.items || [],
loadend: true,
total: res.pagination?.total || 0
});
}
if (key === 'usersTableData') {
setUsersTableData({
dataList: res.items || [],
loadend: true,
total: res.pagination?.total || 0
});
}
};
return {
dataList,
dataSource,
loading,
fetchData: fetchListData,
cancelRequest
};
};
export default useQueryBreakdownList;
@@ -0,0 +1,56 @@
import { useQueryData } from '@/hooks/use-query-data-list';
import { useState } from 'react';
import { queryUsageMetaData } from '../apis';
import { UsageFilterItem, UsageMeta } from '../config/types';
type OptionType = UsageFilterItem & {
value: string;
};
export default function useQueryUsageMetaData() {
const { detailData, loading, cancelRequest, fetchData } =
useQueryData<UsageMeta>({
fetchDetail: queryUsageMetaData,
key: 'usageMetaData'
});
const [result, setResult] = useState<{
models: OptionType[];
users: OptionType[];
api_keys: OptionType[];
}>({
models: [],
users: [],
api_keys: []
});
const queryMetaData = async () => {
const res = await fetchData({});
const data = {
models:
res?.filters?.models?.map((item) => ({
value: item.label,
...item
})) || [],
users:
res?.filters?.users?.map((item) => ({
value: item.label,
...item
})) || [],
api_keys:
res?.filters?.api_keys?.map((item) => ({
value: item.label,
...item
})) || []
};
setResult(data);
};
return {
detailData: result,
loading,
cancelRequest,
fetchData: queryMetaData
};
}
@@ -0,0 +1,17 @@
import { useQueryData } from '@/hooks/use-query-data-list';
import { queryUsageTimeSeriesData } from '../apis';
import { TimeSeriesData } from '../config/types';
export default function useQueryTimeSeriesData() {
const { detailData, loading, cancelRequest, fetchData } =
useQueryData<TimeSeriesData>({
fetchDetail: queryUsageTimeSeriesData,
key: 'timeSeriesData'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
+14
View File
@@ -0,0 +1,14 @@
.wrapper {
width: 100%;
display: flex;
gap: 24px;
align-items: center;
justify-content: space-between;
}
.filters {
display: flex;
gap: 8px;
flex: 1;
align-items: center;
}
+100
View File
@@ -0,0 +1,100 @@
import IconFont from '@/components/icon-font';
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import NoResult from '@/pages/_components/no-result';
import PageBox from '@/pages/_components/page-box';
import { useIntl } from '@umijs/max';
import { ConfigProvider, Table } from 'antd';
import _ from 'lodash';
import { useEffect, useState } from 'react';
import { FilterOptionType } from '../config/types';
import useAPIKeys from '../hooks/use-apikeys-columns';
import useQueryBreakdownList from '../services/use-query-breakdown-list';
const APIKeys: React.FC<{
apiKeys: FilterOptionType[];
dateRange: { start_date: string; end_date: string };
scope: string;
}> = ({ apiKeys, dateRange, scope }) => {
const intl = useIntl();
const { loading, dataSource, fetchData } = useQueryBreakdownList({
key: 'apiKeysTableData'
});
const [queryParams, setQueryParams] = useState<{
page: number;
perPage: number;
sort_by: string;
}>({
page: 1,
perPage: 10,
sort_by: ''
});
const handleTableChange = (pagination: any, filters: any, sorter: any) => {};
const handlePageChange = (page: number, pageSize: number) => {};
const columns = useAPIKeys();
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
<NoResult
loading={loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-key" />}
filters={_.omit(queryParams, ['sort_by'])}
noFoundText={intl.formatMessage({
id: 'noresult.keys.nofound'
})}
title={intl.formatMessage({ id: 'noresult.keys.title' })}
subTitle={intl.formatMessage({ id: 'noresult.keys.subTitle' })}
></NoResult>
);
};
useEffect(() => {
fetchData({
...queryParams,
group_by: 'api_key',
filters: {
api_keys: apiKeys || []
},
scope: scope,
...dateRange
});
}, [dateRange, apiKeys, queryParams, scope]);
return (
<>
<PageBox>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={dataSource.dataList}
loading={{
spinning: loading,
size: 'middle'
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey="id"
onChange={handleTableChange}
pagination={{
size: 'middle',
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
></Table>
</ConfigProvider>
</PageBox>
</>
);
};
export default APIKeys;
+100
View File
@@ -0,0 +1,100 @@
import IconFont from '@/components/icon-font';
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import NoResult from '@/pages/_components/no-result';
import PageBox from '@/pages/_components/page-box';
import { useIntl } from '@umijs/max';
import { ConfigProvider, Table } from 'antd';
import _ from 'lodash';
import { useEffect, useState } from 'react';
import { FilterOptionType } from '../config/types';
import useUsersColumns from '../hooks/use-models-columns';
import useQueryBreakdownList from '../services/use-query-breakdown-list';
const Models: React.FC<{
models: FilterOptionType[];
dateRange: { start_date: string; end_date: string };
scope: string;
}> = ({ models, dateRange, scope }) => {
const intl = useIntl();
const { loading, dataSource, fetchData } = useQueryBreakdownList({
key: 'modelsTableData'
});
const [queryParams, setQueryParams] = useState<{
page: number;
perPage: number;
sort_by: string;
}>({
page: 1,
perPage: 10,
sort_by: ''
});
const handleTableChange = (pagination: any, filters: any, sorter: any) => {};
const handlePageChange = (page: number, pageSize: number) => {};
const columns = useUsersColumns();
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
<NoResult
loading={loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-key" />}
filters={_.omit(queryParams, ['sort_by'])}
noFoundText={intl.formatMessage({
id: 'noresult.keys.nofound'
})}
title={intl.formatMessage({ id: 'noresult.keys.title' })}
subTitle={intl.formatMessage({ id: 'noresult.keys.subTitle' })}
></NoResult>
);
};
useEffect(() => {
fetchData({
...queryParams,
group_by: 'model',
filters: {
models: models || []
},
scope: scope,
...dateRange
});
}, [dateRange, models, queryParams, scope]);
return (
<>
<PageBox>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={dataSource.dataList}
loading={{
spinning: loading,
size: 'middle'
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey="id"
onChange={handleTableChange}
pagination={{
size: 'middle',
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
></Table>
</ConfigProvider>
</PageBox>
</>
);
};
export default Models;
+99
View File
@@ -0,0 +1,99 @@
import IconFont from '@/components/icon-font';
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import NoResult from '@/pages/_components/no-result';
import PageBox from '@/pages/_components/page-box';
import { useIntl } from '@umijs/max';
import { ConfigProvider, Table } from 'antd';
import _ from 'lodash';
import { useEffect, useState } from 'react';
import { FilterOptionType } from '../config/types';
import useUsersColumns from '../hooks/use-users-columns';
import useQueryBreakdownList from '../services/use-query-breakdown-list';
const Users: React.FC<{
users: FilterOptionType[];
dateRange: { start_date: string; end_date: string };
}> = ({ users, dateRange }) => {
const intl = useIntl();
const { loading, dataSource, fetchData } = useQueryBreakdownList({
key: 'usersTableData'
});
const [queryParams, setQueryParams] = useState<{
page: number;
perPage: number;
sort_by: string;
}>({
page: 1,
perPage: 10,
sort_by: ''
});
const handleTableChange = (pagination: any, filters: any, sorter: any) => {};
const handlePageChange = (page: number, pageSize: number) => {};
const columns = useUsersColumns();
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
<NoResult
loading={loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-key" />}
filters={_.omit(queryParams, ['sort_by'])}
noFoundText={intl.formatMessage({
id: 'noresult.keys.nofound'
})}
title={intl.formatMessage({ id: 'noresult.keys.title' })}
subTitle={intl.formatMessage({ id: 'noresult.keys.subTitle' })}
></NoResult>
);
};
useEffect(() => {
fetchData({
...queryParams,
group_by: 'user',
filters: {
users: users || []
},
scope: 'all',
...dateRange
});
}, [dateRange, users, queryParams]);
return (
<>
<PageBox>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={dataSource.dataList}
loading={{
spinning: loading,
size: 'middle'
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey="id"
onChange={handleTableChange}
pagination={{
size: 'middle',
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
></Table>
</ConfigProvider>
</PageBox>
</>
);
};
export default Users;