From 7d75714662566d48702f06738edade71da8a435b Mon Sep 17 00:00:00 2001 From: jialin Date: Thu, 16 Apr 2026 11:34:06 +0800 Subject: [PATCH] feat: usage --- config/routes.ts | 9 + src/atoms/usage.ts | 25 + src/components/icon-font/index.tsx | 2 +- src/components/seal-form/simple-select.tsx | 446 +++++++++--------- src/hooks/use-query-data-list.ts | 22 +- src/locales/en-US/dashboard.ts | 3 +- src/locales/en-US/menu.ts | 1 + src/locales/ja-JP/dashboard.ts | 1 + src/locales/ja-JP/menu.ts | 1 + src/locales/ru-RU/dashboard.ts | 1 + src/locales/ru-RU/menu.ts | 1 + src/locales/tr-TR/dashboard.ts | 7 +- src/locales/tr-TR/menu.ts | 2 + src/locales/zh-CN/dashboard.ts | 3 +- src/locales/zh-CN/menu.ts | 1 + src/pages/benchmark/hooks/use-export-data.ts | 15 +- .../components/usage-inner/export-data.tsx | 45 +- .../dashboard/hooks/use-rangepicker-preset.ts | 8 +- src/pages/usage/apis/index.ts | 64 +++ src/pages/usage/components/breakdown-tabs.tsx | 66 +++ src/pages/usage/components/daily-usage.tsx | 196 ++++++++ src/pages/usage/components/filter-bar.tsx | 186 ++++++++ src/pages/usage/config/index.ts | 104 ++++ src/pages/usage/config/types.ts | 69 +++ src/pages/usage/hooks/use-apikeys-columns.tsx | 99 ++++ src/pages/usage/hooks/use-export-table.tsx | 76 +++ src/pages/usage/hooks/use-models-columns.tsx | 103 ++++ src/pages/usage/hooks/use-users-columns.tsx | 103 ++++ src/pages/usage/index.tsx | 260 ++++++++++ .../services/use-query-breakdown-list.ts | 93 ++++ .../usage/services/use-query-meta-data.ts | 56 +++ .../services/use-query-timeseries-data.ts | 17 + src/pages/usage/styles/filter-bar.less | 14 + src/pages/usage/tables/apikeys-table.tsx | 100 ++++ src/pages/usage/tables/models-table.tsx | 100 ++++ src/pages/usage/tables/users-table.tsx | 99 ++++ src/utils/excel-reader.ts | 89 ++-- 37 files changed, 2199 insertions(+), 288 deletions(-) create mode 100644 src/atoms/usage.ts create mode 100644 src/pages/usage/apis/index.ts create mode 100644 src/pages/usage/components/breakdown-tabs.tsx create mode 100644 src/pages/usage/components/daily-usage.tsx create mode 100644 src/pages/usage/components/filter-bar.tsx create mode 100644 src/pages/usage/config/index.ts create mode 100644 src/pages/usage/config/types.ts create mode 100644 src/pages/usage/hooks/use-apikeys-columns.tsx create mode 100644 src/pages/usage/hooks/use-export-table.tsx create mode 100644 src/pages/usage/hooks/use-models-columns.tsx create mode 100644 src/pages/usage/hooks/use-users-columns.tsx create mode 100644 src/pages/usage/index.tsx create mode 100644 src/pages/usage/services/use-query-breakdown-list.ts create mode 100644 src/pages/usage/services/use-query-meta-data.ts create mode 100644 src/pages/usage/services/use-query-timeseries-data.ts create mode 100644 src/pages/usage/styles/filter-bar.less create mode 100644 src/pages/usage/tables/apikeys-table.tsx create mode 100644 src/pages/usage/tables/models-table.tsx create mode 100644 src/pages/usage/tables/users-table.tsx diff --git a/config/routes.ts b/config/routes.ts index 76b41960..d0d63288 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -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', diff --git a/src/atoms/usage.ts b/src/atoms/usage.ts new file mode 100644 index 00000000..8bfd0520 --- /dev/null +++ b/src/atoms/usage.ts @@ -0,0 +1,25 @@ +import { atom } from 'jotai'; + +export interface UsageTableData { + dataList: any[]; + total: number; + loadend: boolean; +} + +export const apiKeysTableDataAtom = atom({ + dataList: [], + total: 0, + loadend: false +}); + +export const usersTableDataAtom = atom({ + dataList: [], + total: 0, + loadend: false +}); + +export const modelsTableDataAtom = atom({ + dataList: [], + total: 0, + loadend: false +}); diff --git a/src/components/icon-font/index.tsx b/src/components/icon-font/index.tsx index 2029ef5b..a1d3f372 100644 --- a/src/components/icon-font/index.tsx +++ b/src/components/icon-font/index.tsx @@ -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; diff --git a/src/components/seal-form/simple-select.tsx b/src/components/seal-form/simple-select.tsx index 8d7c4bd1..c3b2f0ec 100644 --- a/src/components/seal-form/simple-select.tsx +++ b/src/components/seal-form/simple-select.tsx @@ -43,241 +43,251 @@ const TagWrapper = styled(Tag)` border-radius: 12px; `; -const SimpleSelect: React.FC = - 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(options || []); + const selectRef = React.useRef(null); + const selectorRef = React.useRef(null); + + useEffect(() => { + setOptionsList(options || []); + }, [options]); + + const optionRender = (option: any, info: any) => { + const { value, label } = option; + return ( + + {restProps.value?.includes?.(value) ? ( + + ) : ( + + )} + {label} + + ); + }; + + 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(options || []); - const selectRef = React.useRef(null); - const selectorRef = React.useRef(null); - useEffect(() => { - setOptionsList(options || []); - }, [options]); + let allSelectedValues = [...(restProps.value || [])]; - const optionRender = (option: any, info: any) => { - const { value, label } = option; - return ( - - {restProps.value?.includes?.(value) ? ( - - ) : ( - - )} - {label} - + 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 ( + + {restProps.mode === 'multiple' && ( + + + {intl.formatMessage({ id: 'common.checkbox.all' })} + + + )} + {originPanel} + + ); + }; + + 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[]) => { + 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 ( - - {restProps.mode === 'multiple' && ( - - - {intl.formatMessage({ id: 'common.checkbox.all' })} - - - )} - {originPanel} - - ); - }; - - 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[]) => { - 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 ( - - {showTags - ? label - : intl.formatMessage( - { id: 'common.select.count' }, - { count: count } - )} - - ); - }; - - 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[]; - 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[]); - 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 ( -
- - {props.children} - -
+ + {showTags + ? label + : intl.formatMessage({ id: 'common.select.count' }, { count: count })} + ); - }); + }; + + 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[]; + 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[]); + 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 ( +
+ + {props.children} + +
+ ); +}); export default SimpleSelect; diff --git a/src/hooks/use-query-data-list.ts b/src/hooks/use-query-data-list.ts index f43b2db6..fa0b2c4e 100644 --- a/src/hooks/use-query-data-list.ts +++ b/src/hooks/use-query-data-list.ts @@ -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(option: { +export function useQueryDataList< + ListItem, + Params = any, + Response = Array +>(option: { key: string; + responseType?: 'array' | 'object'; fetchList: ( params: Params, options?: any @@ -21,13 +26,21 @@ export function useQueryDataList(option: { getLabel?: (item: ListItem) => string; getValue?: (item: ListItem) => any; errorMsg?: string; + debounceWait?: number; }): { loading: boolean; dataList: Array; cancelRequest: () => void; - fetchData: (params: Params, extra?: any) => Promise; + fetchData: (params: Params, extra?: any) => Promise; } { - const { key, fetchList, getLabel, getValue, errorMsg } = option; + const { + key, + fetchList, + getLabel, + getValue, + responseType = 'array', + errorMsg + } = option; const axiosTokenRef = useRef(null); const [dataList, setDataList] = useState< Array @@ -54,10 +67,11 @@ export function useQueryDataList(option: { })) || [] ); - return res.items || []; + return responseType === 'array' ? res.items || [] : res; }, { manual: true, + debounceWait: option.debounceWait || 300, onSuccess: () => {}, onError: (error) => { message.error( diff --git a/src/locales/en-US/dashboard.ts b/src/locales/en-US/dashboard.ts index c79543f1..92998ccc 100644 --- a/src/locales/en-US/dashboard.ts +++ b/src/locales/en-US/dashboard.ts @@ -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' }; diff --git a/src/locales/en-US/menu.ts b/src/locales/en-US/menu.ts index 71ef09bd..aae53820 100644 --- a/src/locales/en-US/menu.ts +++ b/src/locales/en-US/menu.ts @@ -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', diff --git a/src/locales/ja-JP/dashboard.ts b/src/locales/ja-JP/dashboard.ts index 435e75b7..63ee9aa3 100644 --- a/src/locales/ja-JP/dashboard.ts +++ b/src/locales/ja-JP/dashboard.ts @@ -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' }; diff --git a/src/locales/ja-JP/menu.ts b/src/locales/ja-JP/menu.ts index baae2f30..d5251429 100644 --- a/src/locales/ja-JP/menu.ts +++ b/src/locales/ja-JP/menu.ts @@ -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': 'ユーザー', diff --git a/src/locales/ru-RU/dashboard.ts b/src/locales/ru-RU/dashboard.ts index 77dfd231..ee04bca1 100644 --- a/src/locales/ru-RU/dashboard.ts +++ b/src/locales/ru-RU/dashboard.ts @@ -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': 'Кластеры' }; diff --git a/src/locales/ru-RU/menu.ts b/src/locales/ru-RU/menu.ts index b3bd6bab..1bf03b96 100644 --- a/src/locales/ru-RU/menu.ts +++ b/src/locales/ru-RU/menu.ts @@ -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-ключи', diff --git a/src/locales/tr-TR/dashboard.ts b/src/locales/tr-TR/dashboard.ts index 4a8f8220..eb0844cc 100644 --- a/src/locales/tr-TR/dashboard.ts +++ b/src/locales/tr-TR/dashboard.ts @@ -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' }; diff --git a/src/locales/tr-TR/menu.ts b/src/locales/tr-TR/menu.ts index 5203c84c..34b95d65 100644 --- a/src/locales/tr-TR/menu.ts +++ b/src/locales/tr-TR/menu.ts @@ -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 ========== diff --git a/src/locales/zh-CN/dashboard.ts b/src/locales/zh-CN/dashboard.ts index 17f2d2d6..09a3b04f 100644 --- a/src/locales/zh-CN/dashboard.ts +++ b/src/locales/zh-CN/dashboard.ts @@ -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 天' }; diff --git a/src/locales/zh-CN/menu.ts b/src/locales/zh-CN/menu.ts index bc20da52..0927abfb 100644 --- a/src/locales/zh-CN/menu.ts +++ b/src/locales/zh-CN/menu.ts @@ -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': '资源', diff --git a/src/pages/benchmark/hooks/use-export-data.ts b/src/pages/benchmark/hooks/use-export-data.ts index d7851de7..70e042c2 100644 --- a/src/pages/benchmark/hooks/use-export-data.ts +++ b/src/pages/benchmark/hooks/use-export-data.ts @@ -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 }; diff --git a/src/pages/dashboard/components/usage-inner/export-data.tsx b/src/pages/dashboard/components/usage-inner/export-data.tsx index eef8211d..cb424bb1 100644 --- a/src/pages/dashboard/components/usage-inner/export-data.tsx +++ b/src/pages/dashboard/components/usage-inner/export-data.tsx @@ -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); + } + } } - } + ] }); }; diff --git a/src/pages/dashboard/hooks/use-rangepicker-preset.ts b/src/pages/dashboard/hooks/use-rangepicker-preset.ts index cca83256..d2308c3a 100644 --- a/src/pages/dashboard/hooks/use-rangepicker-preset.ts +++ b/src/pages/dashboard/hooks/use-rangepicker-preset.ts @@ -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()] diff --git a/src/pages/usage/apis/index.ts b/src/pages/usage/apis/index.ts new file mode 100644 index 00000000..daa13cc3 --- /dev/null +++ b/src/pages/usage/apis/index.ts @@ -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, + options?: any +): Promise { + return request(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 { + return request(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>(USAGE_BREAKDOWN, { + data: params, + method: 'POST', + cancelToken: options?.token + }); +} diff --git a/src/pages/usage/components/breakdown-tabs.tsx b/src/pages/usage/components/breakdown-tabs.tsx new file mode 100644 index 00000000..ba147798 --- /dev/null +++ b/src/pages/usage/components/breakdown-tabs.tsx @@ -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; + +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: ( + + ) + }, + { + key: 'users', + label: 'Users', + forceRender: true, + children: + }, + { + key: 'api_keys', + label: 'API Keys', + forceRender: true, + children: ( + + ) + } + ].filter((item) => { + if (item.key === 'users') { + return scope === 'all'; + } + return true; + }); + + return ( +
+ +
+ ); +}; +export default BreakdownTabs; diff --git a/src/pages/usage/components/daily-usage.tsx b/src/pages/usage/components/daily-usage.tsx new file mode 100644 index 00000000..4c3d14e3 --- /dev/null +++ b/src/pages/usage/components/daily-usage.tsx @@ -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 = (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 ( +
+ + +
+ Metric} + options={metricOptions} + value={metric} + onChange={onMetricChange} + style={{ width: 200 }} + /> + + Group by} + options={groupByOptions} + value={groupBy} + onChange={onGroupByChange} + style={{ width: 200 }} + /> +
+ +
+ + + + +
+
+ ); +}; + +export default DailyUsage; diff --git a/src/pages/usage/components/filter-bar.tsx b/src/pages/usage/components/filter-bar.tsx new file mode 100644 index 00000000..7ed55c54 --- /dev/null +++ b/src/pages/usage/components/filter-bar.tsx @@ -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 = (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 ( +
+
+ {initialState?.currentUser?.is_admin && ( + + )} + + + + + +
+ +
+ ); +}; + +export default FilterBar; diff --git a/src/pages/usage/config/index.ts b/src/pages/usage/config/index.ts new file mode 100644 index 00000000..060e8642 --- /dev/null +++ b/src/pages/usage/config/index.ts @@ -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> = {}; + + 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 + })) + ]; +}; diff --git a/src/pages/usage/config/types.ts b/src/pages/usage/config/types.ts new file mode 100644 index 00000000..f46f4e7b --- /dev/null +++ b/src/pages/usage/config/types.ts @@ -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; diff --git a/src/pages/usage/hooks/use-apikeys-columns.tsx b/src/pages/usage/hooks/use-apikeys-columns.tsx new file mode 100644 index 00000000..f270d7c8 --- /dev/null +++ b/src/pages/usage/hooks/use-apikeys-columns.tsx @@ -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) => ( + + + {text} + + + ) + }, + { + title: 'User', + dataIndex: 'user_name', + key: 'user_name', + render: (text: string, record: ListItem) => ( + + {text || '-'} + + ) + }, + { + title: 'Models Used', + dataIndex: 'models_called', + key: 'models_called', + sorter: tableSorter(2), + render: (text: string, record: ListItem) => ( + {text || '-'} + ) + }, + { + title: 'Input Tokens', + dataIndex: 'input_tokens', + key: 'input_tokens', + render: (text: string[], record: ListItem) => ( + {text} + ) + }, + { + title: 'Output Tokens', + dataIndex: 'output_tokens', + key: 'output_tokens', + render: (text: string[], record: ListItem) => ( + {text} + ) + }, + { + title: 'Total Tokens', + dataIndex: 'total_tokens', + key: 'total_tokens', + sorter: tableSorter(3), + ellipsis: { + showTitle: false + }, + render: (text: number) => {text} + }, + { + title: 'API Requests', + dataIndex: 'api_requests', + key: 'api_requests', + sorter: tableSorter(3), + ellipsis: { + showTitle: false + }, + render: (text: number) => {text} + }, + { + title: 'Last Active', + dataIndex: 'last_active', + key: 'last_active', + sorter: tableSorter(3), + ellipsis: { + showTitle: false + }, + render: (text: number) => {text} + } + ]; + }, [intl]); +}; + +export default useModelsColumns; diff --git a/src/pages/usage/hooks/use-export-table.tsx b/src/pages/usage/hooks/use-export-table.tsx new file mode 100644 index 00000000..ac27c169 --- /dev/null +++ b/src/pages/usage/hooks/use-export-table.tsx @@ -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 + ), + 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 + ), + 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 + ), + formatMap: {} + } + ] + }); + }; + return { exportTable }; +}; + +export default useExportTable; diff --git a/src/pages/usage/hooks/use-models-columns.tsx b/src/pages/usage/hooks/use-models-columns.tsx new file mode 100644 index 00000000..60ce789b --- /dev/null +++ b/src/pages/usage/hooks/use-models-columns.tsx @@ -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) => ( + + + {text} + + + ) + }, + { + title: 'Provider', + dataIndex: 'provider', + key: 'provider', + render: (text: string, record: ListItem) => ( + + {text || '-'} + + ) + }, + { + title: 'Cluster', + dataIndex: 'cluster_name', + key: 'cluster_name', + sorter: tableSorter(2), + render: (text: string, record: ListItem) => ( + {text || '-'} + ) + }, + { + title: 'Input Tokens', + dataIndex: 'input_tokens', + key: 'input_tokens', + render: (text: string[], record: ListItem) => ( + {text} + ) + }, + { + title: 'Output Tokens', + dataIndex: 'output_tokens', + key: 'output_tokens', + render: (text: string[], record: ListItem) => ( + {text} + ) + }, + { + title: 'Total Tokens', + dataIndex: 'total_tokens', + key: 'total_tokens', + sorter: tableSorter(3), + ellipsis: { + showTitle: false + }, + render: (text: number) => {text} + }, + { + title: 'API Requests', + dataIndex: 'api_requests', + key: 'api_requests', + sorter: tableSorter(3), + ellipsis: { + showTitle: false + }, + render: (text: number) => {text} + }, + { + title: 'Last Active', + dataIndex: 'last_active', + key: 'last_active', + sorter: tableSorter(3), + ellipsis: { + showTitle: false + }, + render: (text: number) => {text} + } + ]; + }, [intl]); +}; + +export default useModelsColumns; diff --git a/src/pages/usage/hooks/use-users-columns.tsx b/src/pages/usage/hooks/use-users-columns.tsx new file mode 100644 index 00000000..993b53ae --- /dev/null +++ b/src/pages/usage/hooks/use-users-columns.tsx @@ -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) => ( + + + {text} + + + ) + }, + { + title: 'Models Used', + dataIndex: 'models_called', + key: 'models_called', + render: (text: string, record: ListItem) => ( + + {text || '-'} + + ) + }, + { + title: 'API Keys Used', + dataIndex: 'api_keys_used', + key: 'api_keys_used', + sorter: tableSorter(2), + render: (text: string, record: ListItem) => ( + {text || '-'} + ) + }, + { + title: 'Input Tokens', + dataIndex: 'input_tokens', + key: 'input_tokens', + render: (text: string[], record: ListItem) => ( + {text} + ) + }, + { + title: 'Output Tokens', + dataIndex: 'output_tokens', + key: 'output_tokens', + render: (text: string[], record: ListItem) => ( + {text} + ) + }, + { + title: 'Total Tokens', + dataIndex: 'total_tokens', + key: 'total_tokens', + sorter: tableSorter(3), + ellipsis: { + showTitle: false + }, + render: (text: number) => {text} + }, + { + title: 'API Requests', + dataIndex: 'api_requests', + key: 'api_requests', + sorter: tableSorter(3), + ellipsis: { + showTitle: false + }, + render: (text: number) => {text} + }, + { + title: 'Last Active', + dataIndex: 'last_active', + key: 'last_active', + sorter: tableSorter(3), + ellipsis: { + showTitle: false + }, + render: (text: number) => {text} + } + ]; + }, [intl]); +}; + +export default useModelsColumns; diff --git a/src/pages/usage/index.tsx b/src/pages/usage/index.tsx new file mode 100644 index 00000000..e16a0f11 --- /dev/null +++ b/src/pages/usage/index.tsx @@ -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; + +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 + ), + 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 + ), + formatMap: {} + } + ] + }); + }; + + return ( +
+ handleFilterChange('models', value)} + onUsersChange={(value) => handleFilterChange('users', value)} + onApiKeysChange={(value) => handleFilterChange('api_keys', value)} + onExportChart={handleOnExportChart} + onExportTable={exportTable} + /> + handleChartFilterChange('metric', value)} + onGroupByChange={(value) => handleChartFilterChange('group_by', value)} + onGranularityChange={(value) => + handleChartFilterChange('granularity', value) + } + /> + +
+ ); +}; + +export default Usage; diff --git a/src/pages/usage/services/use-query-breakdown-list.ts b/src/pages/usage/services/use-query-breakdown-list.ts new file mode 100644 index 00000000..db3b16fe --- /dev/null +++ b/src/pages/usage/services/use-query-breakdown-list.ts @@ -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 + >({ + 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; diff --git a/src/pages/usage/services/use-query-meta-data.ts b/src/pages/usage/services/use-query-meta-data.ts new file mode 100644 index 00000000..6c51a279 --- /dev/null +++ b/src/pages/usage/services/use-query-meta-data.ts @@ -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({ + 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 + }; +} diff --git a/src/pages/usage/services/use-query-timeseries-data.ts b/src/pages/usage/services/use-query-timeseries-data.ts new file mode 100644 index 00000000..6c743513 --- /dev/null +++ b/src/pages/usage/services/use-query-timeseries-data.ts @@ -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({ + fetchDetail: queryUsageTimeSeriesData, + key: 'timeSeriesData' + }); + return { + detailData, + loading, + cancelRequest, + fetchData + }; +} diff --git a/src/pages/usage/styles/filter-bar.less b/src/pages/usage/styles/filter-bar.less new file mode 100644 index 00000000..42fc46c6 --- /dev/null +++ b/src/pages/usage/styles/filter-bar.less @@ -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; +} diff --git a/src/pages/usage/tables/apikeys-table.tsx b/src/pages/usage/tables/apikeys-table.tsx new file mode 100644 index 00000000..1b30e8c7 --- /dev/null +++ b/src/pages/usage/tables/apikeys-table.tsx @@ -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 ( + } + 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' })} + > + ); + }; + + useEffect(() => { + fetchData({ + ...queryParams, + group_by: 'api_key', + filters: { + api_keys: apiKeys || [] + }, + scope: scope, + ...dateRange + }); + }, [dateRange, apiKeys, queryParams, scope]); + + return ( + <> + + +
+
+
+ + ); +}; + +export default APIKeys; diff --git a/src/pages/usage/tables/models-table.tsx b/src/pages/usage/tables/models-table.tsx new file mode 100644 index 00000000..cec07492 --- /dev/null +++ b/src/pages/usage/tables/models-table.tsx @@ -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 ( + } + 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' })} + > + ); + }; + + useEffect(() => { + fetchData({ + ...queryParams, + group_by: 'model', + filters: { + models: models || [] + }, + scope: scope, + ...dateRange + }); + }, [dateRange, models, queryParams, scope]); + + return ( + <> + + +
+
+
+ + ); +}; + +export default Models; diff --git a/src/pages/usage/tables/users-table.tsx b/src/pages/usage/tables/users-table.tsx new file mode 100644 index 00000000..4c9a15f2 --- /dev/null +++ b/src/pages/usage/tables/users-table.tsx @@ -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 ( + } + 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' })} + > + ); + }; + + useEffect(() => { + fetchData({ + ...queryParams, + group_by: 'user', + filters: { + users: users || [] + }, + scope: 'all', + ...dateRange + }); + }, [dateRange, users, queryParams]); + + return ( + <> + + +
+
+
+ + ); +}; + +export default Users; diff --git a/src/utils/excel-reader.ts b/src/utils/excel-reader.ts index 8d438fa1..79100d45 100644 --- a/src/utils/excel-reader.ts +++ b/src/utils/excel-reader.ts @@ -16,10 +16,6 @@ export default function readExcelContent(file: File): Promise { }); } -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; 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 = {}; - 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 = {}; + + 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); }