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