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
+9
View File
@@ -126,6 +126,15 @@ export default [
access: 'canSeeAdmin',
component: './model-routes/index'
},
{
name: 'usage',
path: '/models/usage',
key: 'usage',
icon: 'icon-usage-outlined',
selectedIcon: 'icon-usage-filled',
defaultIcon: 'icon-usage-outlined',
component: './usage/index'
},
{
name: 'providers',
path: '/models/providers',
+25
View File
@@ -0,0 +1,25 @@
import { atom } from 'jotai';
export interface UsageTableData {
dataList: any[];
total: number;
loadend: boolean;
}
export const apiKeysTableDataAtom = atom<UsageTableData>({
dataList: [],
total: 0,
loadend: false
});
export const usersTableDataAtom = atom<UsageTableData>({
dataList: [],
total: 0,
loadend: false
});
export const modelsTableDataAtom = atom<UsageTableData>({
dataList: [],
total: 0,
loadend: false
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { createFromIconfontCN } from '@ant-design/icons';
// import './iconfont/iconfont.js';
const IconFont = createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/c/font_4613488_jctdntmi5mf.js'
scriptUrl: '//at.alicdn.com/t/c/font_4613488_htxf2x5ewb.js'
});
export default IconFont;
+228 -218
View File
@@ -43,241 +43,251 @@ const TagWrapper = styled(Tag)`
border-radius: 12px;
`;
const SimpleSelect: React.FC<SelectProps & { ref?: any; showTags?: boolean }> =
forwardRef((props, ref) => {
const intl = useIntl();
const { options = [], showTags, ...restProps } = props;
const SimpleSelect: React.FC<
SelectProps & {
ref?: any;
showTags?: boolean;
styles?: {
wrapper?: React.CSSProperties;
select?: React.CSSProperties;
};
}
> = forwardRef((props, ref) => {
const intl = useIntl();
const { options = [], showTags, styles = {}, ...restProps } = props;
const [allSelection, setAllSelection] = React.useState<{
checked: boolean;
indeterminate: boolean;
}>({
checked: false,
const [allSelection, setAllSelection] = React.useState<{
checked: boolean;
indeterminate: boolean;
}>({
checked: false,
indeterminate: false
});
const [optionsList, setOptionsList] = React.useState<any[]>(options || []);
const selectRef = React.useRef<any>(null);
const selectorRef = React.useRef<any>(null);
useEffect(() => {
setOptionsList(options || []);
}, [options]);
const optionRender = (option: any, info: any) => {
const { value, label } = option;
return (
<OptionWrapper>
{restProps.value?.includes?.(value) ? (
<Checkbox checked></Checkbox>
) : (
<Checkbox></Checkbox>
)}
<AutoTooltip ghost>{label}</AutoTooltip>
</OptionWrapper>
);
};
const handleOnCheckboxChange = (e: CheckboxChangeEvent) => {
const isChecked = e.target.checked;
const allValues = optionsList?.map((opt: any) => opt.value) || [];
setAllSelection({
checked: isChecked,
indeterminate: false
});
const [optionsList, setOptionsList] = React.useState<any[]>(options || []);
const selectRef = React.useRef<any>(null);
const selectorRef = React.useRef<any>(null);
useEffect(() => {
setOptionsList(options || []);
}, [options]);
let allSelectedValues = [...(restProps.value || [])];
const optionRender = (option: any, info: any) => {
const { value, label } = option;
return (
<OptionWrapper>
{restProps.value?.includes?.(value) ? (
<Checkbox checked></Checkbox>
) : (
<Checkbox></Checkbox>
)}
<AutoTooltip ghost>{label}</AutoTooltip>
</OptionWrapper>
if (isChecked) {
// Select all options
allSelectedValues = Array.from(
new Set([...allSelectedValues, ...allValues])
);
};
} else {
// Deselect all options
allSelectedValues = allSelectedValues.filter(
(value) => !allValues.includes(value)
);
}
const handleOnCheckboxChange = (e: CheckboxChangeEvent) => {
const isChecked = e.target.checked;
const allValues = optionsList?.map((opt: any) => opt.value) || [];
restProps.onChange?.(allSelectedValues, optionsList || []);
};
const dropdownRender = (originPanel: React.ReactNode) => {
return (
<DropdownWrapper>
{restProps.mode === 'multiple' && (
<SelectAllWrapper>
<Checkbox
checked={allSelection.checked}
indeterminate={allSelection.indeterminate}
onChange={handleOnCheckboxChange}
>
{intl.formatMessage({ id: 'common.checkbox.all' })}
</Checkbox>
</SelectAllWrapper>
)}
{originPanel}
</DropdownWrapper>
);
};
const handleOnChange = (value: any, option: any) => {
const selectedValues = Array.isArray(value) ? value : [value];
const allSelected = optionsList?.map((opt: any) => opt.value) || [];
const isAllSelected = selectedValues.length === allSelected?.length;
setAllSelection({
checked: isAllSelected,
indeterminate: !isAllSelected && selectedValues.length > 0
});
restProps.onChange?.(selectedValues, option);
};
const filterOption = (inputValue: string, option: any) => {
if (!option || !option.label) return false;
return option.label.toLowerCase().includes(inputValue.toLowerCase());
};
const checkAllSelection = (list: Global.BaseOption<string | number>[]) => {
if (
!restProps.value ||
!Array.isArray(restProps.value) ||
list.length === 0
) {
setAllSelection({
checked: isChecked,
checked: false,
indeterminate: false
});
return;
}
const selectedValues = new Set(restProps.value);
const allValues = list?.map((opt: any) => opt.value) || [];
let allSelectedValues = [...(restProps.value || [])];
const isAllSelected = allValues.every((val: any) =>
selectedValues.has(val)
);
if (isChecked) {
// Select all options
allSelectedValues = Array.from(
new Set([...allSelectedValues, ...allValues])
);
} else {
// Deselect all options
allSelectedValues = allSelectedValues.filter(
(value) => !allValues.includes(value)
);
}
const isSomeSelected = allValues.some((val: any) =>
selectedValues.has(val)
);
restProps.onChange?.(allSelectedValues, optionsList || []);
};
setAllSelection({
checked: isAllSelected,
indeterminate: isSomeSelected && !isAllSelected
});
};
const dropdownRender = (originPanel: React.ReactNode) => {
return (
<DropdownWrapper>
{restProps.mode === 'multiple' && (
<SelectAllWrapper>
<Checkbox
checked={allSelection.checked}
indeterminate={allSelection.indeterminate}
onChange={handleOnCheckboxChange}
>
{intl.formatMessage({ id: 'common.checkbox.all' })}
</Checkbox>
</SelectAllWrapper>
)}
{originPanel}
</DropdownWrapper>
);
};
const handleOnChange = (value: any, option: any) => {
const selectedValues = Array.isArray(value) ? value : [value];
const allSelected = optionsList?.map((opt: any) => opt.value) || [];
const isAllSelected = selectedValues.length === allSelected?.length;
setAllSelection({
checked: isAllSelected,
indeterminate: !isAllSelected && selectedValues.length > 0
});
restProps.onChange?.(selectedValues, option);
};
const filterOption = (inputValue: string, option: any) => {
if (!option || !option.label) return false;
return option.label.toLowerCase().includes(inputValue.toLowerCase());
};
const checkAllSelection = (list: Global.BaseOption<string | number>[]) => {
if (
!restProps.value ||
!Array.isArray(restProps.value) ||
list.length === 0
) {
setAllSelection({
checked: false,
indeterminate: false
});
return;
}
const selectedValues = new Set(restProps.value);
const allValues = list?.map((opt: any) => opt.value) || [];
const isAllSelected = allValues.every((val: any) =>
selectedValues.has(val)
);
const isSomeSelected = allValues.some((val: any) =>
selectedValues.has(val)
);
setAllSelection({
checked: isAllSelected,
indeterminate: isSomeSelected && !isAllSelected
});
};
const TagRender = (props: any) => {
const { label } = props;
const count = props.isMaxTag ? label.slice(0, -3).slice(1) : label;
return (
<TagWrapper
variant="filled"
closable={props.closable}
onClose={props.onClose}
style={{
height: 22,
backgroundColor: 'var(--ant-color-fill-secondary)',
fontSize: 'var(--ant-font-size)'
}}
className="flex-center"
>
{showTags
? label
: intl.formatMessage(
{ id: 'common.select.count' },
{ count: count }
)}
</TagWrapper>
);
};
const handleOnSearch = (value: string) => {
if (restProps.showSearch?.onSearch) {
restProps.showSearch?.onSearch?.(value);
} else {
const filteredOptions = options?.filter((option: any) =>
option.label.toLowerCase().includes(value.toLowerCase())
) as Global.BaseOption<string | number>[];
setOptionsList(filteredOptions || []);
checkAllSelection(filteredOptions || []);
}
};
const handleOnBlur = (e: any) => {
restProps.onBlur?.(e);
};
const handleOnFocus = (e: any) => {
restProps.onFocus?.(e);
};
const handleOnOpenChange = (open: boolean) => {
if (!open) {
checkAllSelection(options as Global.BaseOption<string | number>[]);
setOptionsList(options || []);
}
};
useEffect(() => {
const input = selectRef.current?.querySelector?.('input');
if (!input) return;
const handler = (event: KeyboardEvent) => {
if (
event.key === 'Backspace' &&
(input as HTMLInputElement).value === ''
) {
event.stopPropagation();
event.preventDefault();
}
};
input.addEventListener('keydown', handler);
return () => {
input.removeEventListener('keydown', handler);
};
}, [selectRef.current]);
useImperativeHandle(ref, () => ({
focus: () => {
selectorRef.current?.focus();
},
blur: () => {
selectorRef.current?.blur();
}
}));
const TagRender = (props: any) => {
const { label } = props;
const count = props.isMaxTag ? label.slice(0, -3).slice(1) : label;
return (
<div ref={selectRef} style={{ width: 'inherit' }}>
<BaseSelect
{...restProps}
ref={selectorRef}
options={optionsList}
maxTagCount={restProps.maxTagCount || 0}
defaultActiveFirstOption={false}
popupRender={dropdownRender}
optionRender={optionRender}
menuItemSelectedIcon={false}
onChange={handleOnChange}
tagRender={TagRender}
onBlur={handleOnBlur}
onFocus={handleOnFocus}
showSearch={{
onSearch: handleOnSearch,
filterOption: restProps.showSearch?.filterOption || filterOption
}}
onOpenChange={handleOnOpenChange}
>
{props.children}
</BaseSelect>
</div>
<TagWrapper
variant="filled"
closable={props.closable}
onClose={props.onClose}
style={{
height: 22,
backgroundColor: 'var(--ant-color-fill-secondary)',
fontSize: 'var(--ant-font-size)'
}}
className="flex-center"
>
{showTags
? label
: intl.formatMessage({ id: 'common.select.count' }, { count: count })}
</TagWrapper>
);
});
};
const handleOnSearch = (value: string) => {
if (restProps.showSearch?.onSearch) {
restProps.showSearch?.onSearch?.(value);
} else {
const filteredOptions = options?.filter((option: any) =>
option.label.toLowerCase().includes(value.toLowerCase())
) as Global.BaseOption<string | number>[];
setOptionsList(filteredOptions || []);
checkAllSelection(filteredOptions || []);
}
};
const handleOnBlur = (e: any) => {
restProps.onBlur?.(e);
};
const handleOnFocus = (e: any) => {
restProps.onFocus?.(e);
};
const handleOnOpenChange = (open: boolean) => {
if (!open) {
checkAllSelection(options as Global.BaseOption<string | number>[]);
setOptionsList(options || []);
}
};
useEffect(() => {
const input = selectRef.current?.querySelector?.('input');
if (!input) return;
const handler = (event: KeyboardEvent) => {
if (
event.key === 'Backspace' &&
(input as HTMLInputElement).value === ''
) {
event.stopPropagation();
event.preventDefault();
}
};
input.addEventListener('keydown', handler);
return () => {
input.removeEventListener('keydown', handler);
};
}, [selectRef.current]);
useImperativeHandle(ref, () => ({
focus: () => {
selectorRef.current?.focus();
},
blur: () => {
selectorRef.current?.blur();
}
}));
return (
<div ref={selectRef} style={{ ...styles.wrapper }}>
<BaseSelect
{...restProps}
style={{
width: restProps.style?.width || '100%',
...restProps.style,
...styles.select
}}
ref={selectorRef}
options={optionsList}
maxTagCount={restProps.maxTagCount || 0}
defaultActiveFirstOption={false}
popupRender={dropdownRender}
optionRender={optionRender}
menuItemSelectedIcon={false}
onChange={handleOnChange}
tagRender={TagRender}
onBlur={handleOnBlur}
onFocus={handleOnFocus}
showSearch={{
onSearch: handleOnSearch,
filterOption: restProps.showSearch?.filterOption || filterOption
}}
onOpenChange={handleOnOpenChange}
>
{props.children}
</BaseSelect>
</div>
);
});
export default SimpleSelect;
+18 -4
View File
@@ -12,8 +12,13 @@ import { useEffect, useRef, useState } from 'react';
* @param option.fetchList: (params, extra) => Promise<{ items: ListItem[] }>
* @returns loading, dataList, fetchData, cancelRequest
*/
export function useQueryDataList<ListItem, Params = any>(option: {
export function useQueryDataList<
ListItem,
Params = any,
Response = Array<ListItem>
>(option: {
key: string;
responseType?: 'array' | 'object';
fetchList: (
params: Params,
options?: any
@@ -21,13 +26,21 @@ export function useQueryDataList<ListItem, Params = any>(option: {
getLabel?: (item: ListItem) => string;
getValue?: (item: ListItem) => any;
errorMsg?: string;
debounceWait?: number;
}): {
loading: boolean;
dataList: Array<ListItem & { label: string; value: any }>;
cancelRequest: () => void;
fetchData: (params: Params, extra?: any) => Promise<ListItem[]>;
fetchData: (params: Params, extra?: any) => Promise<Response>;
} {
const { key, fetchList, getLabel, getValue, errorMsg } = option;
const {
key,
fetchList,
getLabel,
getValue,
responseType = 'array',
errorMsg
} = option;
const axiosTokenRef = useRef<CancelTokenSource | null>(null);
const [dataList, setDataList] = useState<
Array<ListItem & { label: string; value: any }>
@@ -54,10 +67,11 @@ export function useQueryDataList<ListItem, Params = any>(option: {
})) || []
);
return res.items || [];
return responseType === 'array' ? res.items || [] : res;
},
{
manual: true,
debounceWait: option.debounceWait || 300,
onSuccess: () => {},
onError: (error) => {
message.error(
+2 -1
View File
@@ -31,5 +31,6 @@ export default {
'dashboard.usage.export.date': 'Date',
'dashboard.usage.datePicker.last7days': 'Last 7 Days',
'dashboard.usage.datePicker.last30days': 'Last 30 Days',
'dashboard.usage.datePicker.last60days': 'Last 60 Days'
'dashboard.usage.datePicker.last60days': 'Last 60 Days',
'dashboard.usage.datePicker.last90days': 'Last 90 Days'
};
+1
View File
@@ -19,6 +19,7 @@ export default {
'menu.models.providers': 'Providers',
'menu.models.instances': 'Instances',
'menu.models.routes': 'Routes',
'menu.models.usage': 'Usage',
'menu.modelCatalog': 'Catalog',
'menu.resources': 'Resources',
'menu.apikeys': 'API Keys',
+1
View File
@@ -31,6 +31,7 @@ export default {
'dashboard.usage.datePicker.last7days': 'Last 7 Days',
'dashboard.usage.datePicker.last30days': 'Last 30 Days',
'dashboard.usage.datePicker.last60days': 'Last 60 Days',
'dashboard.usage.datePicker.last90days': 'Last 90 Days',
'dashboard.clusters': 'Clusters'
};
+1
View File
@@ -19,6 +19,7 @@ export default {
'menu.models.providers': 'Providers',
'menu.models.instances': 'Instances',
'menu.models.routes': 'Routes',
'menu.models.usage': 'Usage',
'menu.resources': 'リソース',
'menu.apikeys': 'APIキー',
'menu.users': 'ユーザー',
+1
View File
@@ -31,6 +31,7 @@ export default {
'dashboard.usage.datePicker.last7days': 'Последние 7 Days',
'dashboard.usage.datePicker.last30days': 'Последние 30 Days',
'dashboard.usage.datePicker.last60days': 'Последние 60 Days',
'dashboard.usage.datePicker.last90days': 'Последние 90 Days',
'dashboard.clusters': 'Кластеры'
};
+1
View File
@@ -18,6 +18,7 @@ export default {
'menu.models.providers': 'Providers',
'menu.models.instances': 'Instances',
'menu.models.routes': 'Routes',
'menu.models.usage': 'Usage',
'menu.modelCatalog': 'Каталог',
'menu.resources': 'Ресурсы',
'menu.apikeys': 'API-ключи',
+4 -3
View File
@@ -3,8 +3,8 @@ export default {
'dashboard.workers': 'İşçi Düğümler',
'dashboard.models': 'Modeller',
'dashboard.clusters': 'Kümeler',
'dashboard.totalgpus': 'GPU\'lar',
'dashboard.allocategpus': 'Ayrılan GPU\'lar',
'dashboard.totalgpus': "GPU'lar",
'dashboard.allocategpus': "Ayrılan GPU'lar",
'dashboard.instances': 'Örnekler',
'dashboard.systemload': 'Sistem Yükü',
'dashboard.memory': 'RAM',
@@ -31,5 +31,6 @@ export default {
'dashboard.usage.export.date': 'Tarih',
'dashboard.usage.datePicker.last7days': 'Son 7 Gün',
'dashboard.usage.datePicker.last30days': 'Son 30 Gün',
'dashboard.usage.datePicker.last60days': 'Son 60 Gün'
'dashboard.usage.datePicker.last60days': 'Son 60 Gün',
'dashboard.usage.datePicker.last90days': 'Son 90 Gün'
};
+2
View File
@@ -18,6 +18,7 @@ export default {
'menu.models.benchmarkDetail': 'Kıyaslama Detayları',
'menu.models.providers': 'Sağlayıcılar',
'menu.models.routes': 'Yönlendirmeler',
'menu.models.usage': 'Usage',
'menu.modelCatalog': 'Katalog',
'menu.resources': 'Kaynaklar',
'menu.apikeys': 'API Anahtarları',
@@ -43,4 +44,5 @@ export default {
// ========== To-Do: Translate Keys (Remove After Translation) ==========
// 1. 'menu.models.instances': 'Instances'
// 2. 'menu.models.usage': 'Usage',
// ========== End of To-Do List ==========
+2 -1
View File
@@ -31,5 +31,6 @@ export default {
'dashboard.usage.export.date': '日期',
'dashboard.usage.datePicker.last7days': '最近 7 天',
'dashboard.usage.datePicker.last30days': '最近 30 天',
'dashboard.usage.datePicker.last60days': '最近 60 天'
'dashboard.usage.datePicker.last60days': '最近 60 天',
'dashboard.usage.datePicker.last90days': '最近 90 天'
};
+1
View File
@@ -18,6 +18,7 @@ export default {
'menu.models.providers': '提供商',
'menu.models.instances': '实例',
'menu.models.routes': '路由',
'menu.models.usage': '用量统计',
'menu.modelCatalog': '模型库',
'menu.models.catalog': '模型库',
'menu.resources': '资源',
+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;
+55 -34
View File
@@ -16,10 +16,6 @@ export default function readExcelContent(file: File): Promise<string> {
});
}
interface FormatMap {
[key: string]: (value: any, row?: any) => any;
}
/**
* @param jsonData raw JSON data to export
* @param fields export fields (keys in the JSON objects)
@@ -27,45 +23,70 @@ interface FormatMap {
* @param formatMap custom the cell format functions
* @param fileName file name for the exported Excel file
*/
export function exportJsonToExcel(data: {
interface FormatMap {
[key: string]: (value: any, row?: any) => any;
}
interface ExportSheetConfig {
sheetName: string;
jsonData: any[];
fields: string[];
fieldLabels?: Record<string, string>;
formatMap?: FormatMap;
}
interface ExportExcelOptions {
sheets: ExportSheetConfig[];
fileName: string;
}) {
const {
jsonData,
fields,
fieldLabels,
formatMap,
fileName = 'data.xlsx'
} = data;
// 1. Process data: filter fields and format values
const formattedData = jsonData.map((row) => {
const result: Record<string, any> = {};
for (const field of fields) {
const rawValue = row[field];
const formatFn = formatMap?.[field];
result[field] = formatFn ? formatFn(rawValue, row) : rawValue;
}
export function exportJsonToExcel({
sheets,
fileName = 'data.xlsx'
}: ExportExcelOptions) {
const workbook = XLSX.utils.book_new();
sheets.forEach((sheet) => {
const { sheetName, jsonData, fields, fieldLabels, formatMap } = sheet;
// 1. format data
const formattedData = jsonData.map((row) => {
const result: Record<string, any> = {};
for (const field of fields) {
const rawValue = row[field];
const formatFn = formatMap?.[field];
result[field] = formatFn ? formatFn(rawValue, row) : rawValue;
}
return result;
});
// 2. convert to worksheet
const worksheet = XLSX.utils.json_to_sheet(formattedData, {
header: fields
});
// 3. customize headers
if (fieldLabels) {
const headerRow = fields.map((key) => fieldLabels[key] || key);
XLSX.utils.sheet_add_aoa(worksheet, [headerRow], { origin: 'A1' });
}
return result;
// 4. add to workbook
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
});
// 2. Convert to worksheet
const worksheet = XLSX.utils.json_to_sheet(formattedData, { header: fields });
// 5. export file
const excelBuffer = XLSX.write(workbook, {
bookType: 'xlsx',
type: 'array'
});
// 3. Add headers if provided
if (fieldLabels) {
const headerRow = fields.map((key) => fieldLabels[key] || key);
XLSX.utils.sheet_add_aoa(worksheet, [headerRow], { origin: 'A1' });
}
const blob = new Blob([excelBuffer], {
type: 'application/octet-stream'
});
// 4. Create workbook and write to file
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([excelBuffer], { type: 'application/octet-stream' });
saveAs(blob, fileName);
}