feat: usage
This commit is contained in:
@@ -126,6 +126,15 @@ export default [
|
|||||||
access: 'canSeeAdmin',
|
access: 'canSeeAdmin',
|
||||||
component: './model-routes/index'
|
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',
|
name: 'providers',
|
||||||
path: '/models/providers',
|
path: '/models/providers',
|
||||||
|
|||||||
@@ -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';
|
// import './iconfont/iconfont.js';
|
||||||
|
|
||||||
const IconFont = createFromIconfontCN({
|
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;
|
export default IconFont;
|
||||||
|
|||||||
@@ -43,241 +43,251 @@ const TagWrapper = styled(Tag)`
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const SimpleSelect: React.FC<SelectProps & { ref?: any; showTags?: boolean }> =
|
const SimpleSelect: React.FC<
|
||||||
forwardRef((props, ref) => {
|
SelectProps & {
|
||||||
const intl = useIntl();
|
ref?: any;
|
||||||
const { options = [], showTags, ...restProps } = props;
|
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<{
|
const [allSelection, setAllSelection] = React.useState<{
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
indeterminate: boolean;
|
indeterminate: boolean;
|
||||||
}>({
|
}>({
|
||||||
checked: false,
|
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
|
indeterminate: false
|
||||||
});
|
});
|
||||||
const [optionsList, setOptionsList] = React.useState<any[]>(options || []);
|
|
||||||
const selectRef = React.useRef<any>(null);
|
|
||||||
const selectorRef = React.useRef<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
let allSelectedValues = [...(restProps.value || [])];
|
||||||
setOptionsList(options || []);
|
|
||||||
}, [options]);
|
|
||||||
|
|
||||||
const optionRender = (option: any, info: any) => {
|
if (isChecked) {
|
||||||
const { value, label } = option;
|
// Select all options
|
||||||
return (
|
allSelectedValues = Array.from(
|
||||||
<OptionWrapper>
|
new Set([...allSelectedValues, ...allValues])
|
||||||
{restProps.value?.includes?.(value) ? (
|
|
||||||
<Checkbox checked></Checkbox>
|
|
||||||
) : (
|
|
||||||
<Checkbox></Checkbox>
|
|
||||||
)}
|
|
||||||
<AutoTooltip ghost>{label}</AutoTooltip>
|
|
||||||
</OptionWrapper>
|
|
||||||
);
|
);
|
||||||
};
|
} else {
|
||||||
|
// Deselect all options
|
||||||
|
allSelectedValues = allSelectedValues.filter(
|
||||||
|
(value) => !allValues.includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleOnCheckboxChange = (e: CheckboxChangeEvent) => {
|
restProps.onChange?.(allSelectedValues, optionsList || []);
|
||||||
const isChecked = e.target.checked;
|
};
|
||||||
const allValues = optionsList?.map((opt: any) => opt.value) || [];
|
|
||||||
|
|
||||||
|
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({
|
setAllSelection({
|
||||||
checked: isChecked,
|
checked: false,
|
||||||
indeterminate: 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) {
|
const isSomeSelected = allValues.some((val: any) =>
|
||||||
// Select all options
|
selectedValues.has(val)
|
||||||
allSelectedValues = Array.from(
|
);
|
||||||
new Set([...allSelectedValues, ...allValues])
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Deselect all options
|
|
||||||
allSelectedValues = allSelectedValues.filter(
|
|
||||||
(value) => !allValues.includes(value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
restProps.onChange?.(allSelectedValues, optionsList || []);
|
setAllSelection({
|
||||||
};
|
checked: isAllSelected,
|
||||||
|
indeterminate: isSomeSelected && !isAllSelected
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const dropdownRender = (originPanel: React.ReactNode) => {
|
const TagRender = (props: any) => {
|
||||||
return (
|
const { label } = props;
|
||||||
<DropdownWrapper>
|
const count = props.isMaxTag ? label.slice(0, -3).slice(1) : label;
|
||||||
{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();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={selectRef} style={{ width: 'inherit' }}>
|
<TagWrapper
|
||||||
<BaseSelect
|
variant="filled"
|
||||||
{...restProps}
|
closable={props.closable}
|
||||||
ref={selectorRef}
|
onClose={props.onClose}
|
||||||
options={optionsList}
|
style={{
|
||||||
maxTagCount={restProps.maxTagCount || 0}
|
height: 22,
|
||||||
defaultActiveFirstOption={false}
|
backgroundColor: 'var(--ant-color-fill-secondary)',
|
||||||
popupRender={dropdownRender}
|
fontSize: 'var(--ant-font-size)'
|
||||||
optionRender={optionRender}
|
}}
|
||||||
menuItemSelectedIcon={false}
|
className="flex-center"
|
||||||
onChange={handleOnChange}
|
>
|
||||||
tagRender={TagRender}
|
{showTags
|
||||||
onBlur={handleOnBlur}
|
? label
|
||||||
onFocus={handleOnFocus}
|
: intl.formatMessage({ id: 'common.select.count' }, { count: count })}
|
||||||
showSearch={{
|
</TagWrapper>
|
||||||
onSearch: handleOnSearch,
|
|
||||||
filterOption: restProps.showSearch?.filterOption || filterOption
|
|
||||||
}}
|
|
||||||
onOpenChange={handleOnOpenChange}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</BaseSelect>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
|
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;
|
export default SimpleSelect;
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
* @param option.fetchList: (params, extra) => Promise<{ items: ListItem[] }>
|
* @param option.fetchList: (params, extra) => Promise<{ items: ListItem[] }>
|
||||||
* @returns loading, dataList, fetchData, cancelRequest
|
* @returns loading, dataList, fetchData, cancelRequest
|
||||||
*/
|
*/
|
||||||
export function useQueryDataList<ListItem, Params = any>(option: {
|
export function useQueryDataList<
|
||||||
|
ListItem,
|
||||||
|
Params = any,
|
||||||
|
Response = Array<ListItem>
|
||||||
|
>(option: {
|
||||||
key: string;
|
key: string;
|
||||||
|
responseType?: 'array' | 'object';
|
||||||
fetchList: (
|
fetchList: (
|
||||||
params: Params,
|
params: Params,
|
||||||
options?: any
|
options?: any
|
||||||
@@ -21,13 +26,21 @@ export function useQueryDataList<ListItem, Params = any>(option: {
|
|||||||
getLabel?: (item: ListItem) => string;
|
getLabel?: (item: ListItem) => string;
|
||||||
getValue?: (item: ListItem) => any;
|
getValue?: (item: ListItem) => any;
|
||||||
errorMsg?: string;
|
errorMsg?: string;
|
||||||
|
debounceWait?: number;
|
||||||
}): {
|
}): {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
dataList: Array<ListItem & { label: string; value: any }>;
|
dataList: Array<ListItem & { label: string; value: any }>;
|
||||||
cancelRequest: () => void;
|
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 axiosTokenRef = useRef<CancelTokenSource | null>(null);
|
||||||
const [dataList, setDataList] = useState<
|
const [dataList, setDataList] = useState<
|
||||||
Array<ListItem & { label: string; value: any }>
|
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,
|
manual: true,
|
||||||
|
debounceWait: option.debounceWait || 300,
|
||||||
onSuccess: () => {},
|
onSuccess: () => {},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
message.error(
|
message.error(
|
||||||
|
|||||||
@@ -31,5 +31,6 @@ export default {
|
|||||||
'dashboard.usage.export.date': 'Date',
|
'dashboard.usage.export.date': 'Date',
|
||||||
'dashboard.usage.datePicker.last7days': 'Last 7 Days',
|
'dashboard.usage.datePicker.last7days': 'Last 7 Days',
|
||||||
'dashboard.usage.datePicker.last30days': 'Last 30 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.providers': 'Providers',
|
||||||
'menu.models.instances': 'Instances',
|
'menu.models.instances': 'Instances',
|
||||||
'menu.models.routes': 'Routes',
|
'menu.models.routes': 'Routes',
|
||||||
|
'menu.models.usage': 'Usage',
|
||||||
'menu.modelCatalog': 'Catalog',
|
'menu.modelCatalog': 'Catalog',
|
||||||
'menu.resources': 'Resources',
|
'menu.resources': 'Resources',
|
||||||
'menu.apikeys': 'API Keys',
|
'menu.apikeys': 'API Keys',
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default {
|
|||||||
'dashboard.usage.datePicker.last7days': 'Last 7 Days',
|
'dashboard.usage.datePicker.last7days': 'Last 7 Days',
|
||||||
'dashboard.usage.datePicker.last30days': 'Last 30 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',
|
||||||
'dashboard.clusters': 'Clusters'
|
'dashboard.clusters': 'Clusters'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default {
|
|||||||
'menu.models.providers': 'Providers',
|
'menu.models.providers': 'Providers',
|
||||||
'menu.models.instances': 'Instances',
|
'menu.models.instances': 'Instances',
|
||||||
'menu.models.routes': 'Routes',
|
'menu.models.routes': 'Routes',
|
||||||
|
'menu.models.usage': 'Usage',
|
||||||
'menu.resources': 'リソース',
|
'menu.resources': 'リソース',
|
||||||
'menu.apikeys': 'APIキー',
|
'menu.apikeys': 'APIキー',
|
||||||
'menu.users': 'ユーザー',
|
'menu.users': 'ユーザー',
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default {
|
|||||||
'dashboard.usage.datePicker.last7days': 'Последние 7 Days',
|
'dashboard.usage.datePicker.last7days': 'Последние 7 Days',
|
||||||
'dashboard.usage.datePicker.last30days': 'Последние 30 Days',
|
'dashboard.usage.datePicker.last30days': 'Последние 30 Days',
|
||||||
'dashboard.usage.datePicker.last60days': 'Последние 60 Days',
|
'dashboard.usage.datePicker.last60days': 'Последние 60 Days',
|
||||||
|
'dashboard.usage.datePicker.last90days': 'Последние 90 Days',
|
||||||
'dashboard.clusters': 'Кластеры'
|
'dashboard.clusters': 'Кластеры'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default {
|
|||||||
'menu.models.providers': 'Providers',
|
'menu.models.providers': 'Providers',
|
||||||
'menu.models.instances': 'Instances',
|
'menu.models.instances': 'Instances',
|
||||||
'menu.models.routes': 'Routes',
|
'menu.models.routes': 'Routes',
|
||||||
|
'menu.models.usage': 'Usage',
|
||||||
'menu.modelCatalog': 'Каталог',
|
'menu.modelCatalog': 'Каталог',
|
||||||
'menu.resources': 'Ресурсы',
|
'menu.resources': 'Ресурсы',
|
||||||
'menu.apikeys': 'API-ключи',
|
'menu.apikeys': 'API-ключи',
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ export default {
|
|||||||
'dashboard.workers': 'İşçi Düğümler',
|
'dashboard.workers': 'İşçi Düğümler',
|
||||||
'dashboard.models': 'Modeller',
|
'dashboard.models': 'Modeller',
|
||||||
'dashboard.clusters': 'Kümeler',
|
'dashboard.clusters': 'Kümeler',
|
||||||
'dashboard.totalgpus': 'GPU\'lar',
|
'dashboard.totalgpus': "GPU'lar",
|
||||||
'dashboard.allocategpus': 'Ayrılan GPU\'lar',
|
'dashboard.allocategpus': "Ayrılan GPU'lar",
|
||||||
'dashboard.instances': 'Örnekler',
|
'dashboard.instances': 'Örnekler',
|
||||||
'dashboard.systemload': 'Sistem Yükü',
|
'dashboard.systemload': 'Sistem Yükü',
|
||||||
'dashboard.memory': 'RAM',
|
'dashboard.memory': 'RAM',
|
||||||
@@ -31,5 +31,6 @@ export default {
|
|||||||
'dashboard.usage.export.date': 'Tarih',
|
'dashboard.usage.export.date': 'Tarih',
|
||||||
'dashboard.usage.datePicker.last7days': 'Son 7 Gün',
|
'dashboard.usage.datePicker.last7days': 'Son 7 Gün',
|
||||||
'dashboard.usage.datePicker.last30days': 'Son 30 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.benchmarkDetail': 'Kıyaslama Detayları',
|
||||||
'menu.models.providers': 'Sağlayıcılar',
|
'menu.models.providers': 'Sağlayıcılar',
|
||||||
'menu.models.routes': 'Yönlendirmeler',
|
'menu.models.routes': 'Yönlendirmeler',
|
||||||
|
'menu.models.usage': 'Usage',
|
||||||
'menu.modelCatalog': 'Katalog',
|
'menu.modelCatalog': 'Katalog',
|
||||||
'menu.resources': 'Kaynaklar',
|
'menu.resources': 'Kaynaklar',
|
||||||
'menu.apikeys': 'API Anahtarları',
|
'menu.apikeys': 'API Anahtarları',
|
||||||
@@ -43,4 +44,5 @@ export default {
|
|||||||
|
|
||||||
// ========== To-Do: Translate Keys (Remove After Translation) ==========
|
// ========== To-Do: Translate Keys (Remove After Translation) ==========
|
||||||
// 1. 'menu.models.instances': 'Instances'
|
// 1. 'menu.models.instances': 'Instances'
|
||||||
|
// 2. 'menu.models.usage': 'Usage',
|
||||||
// ========== End of To-Do List ==========
|
// ========== End of To-Do List ==========
|
||||||
|
|||||||
@@ -31,5 +31,6 @@ export default {
|
|||||||
'dashboard.usage.export.date': '日期',
|
'dashboard.usage.export.date': '日期',
|
||||||
'dashboard.usage.datePicker.last7days': '最近 7 天',
|
'dashboard.usage.datePicker.last7days': '最近 7 天',
|
||||||
'dashboard.usage.datePicker.last30days': '最近 30 天',
|
'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.providers': '提供商',
|
||||||
'menu.models.instances': '实例',
|
'menu.models.instances': '实例',
|
||||||
'menu.models.routes': '路由',
|
'menu.models.routes': '路由',
|
||||||
|
'menu.models.usage': '用量统计',
|
||||||
'menu.modelCatalog': '模型库',
|
'menu.modelCatalog': '模型库',
|
||||||
'menu.models.catalog': '模型库',
|
'menu.models.catalog': '模型库',
|
||||||
'menu.resources': '资源',
|
'menu.resources': '资源',
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ const useExportData = (params: { columns: any[] }) => {
|
|||||||
const exportData = (dataList: any[]) => {
|
const exportData = (dataList: any[]) => {
|
||||||
const fileName = `benchmark.xlsx`;
|
const fileName = `benchmark.xlsx`;
|
||||||
exportJsonToExcel({
|
exportJsonToExcel({
|
||||||
jsonData: dataList || [],
|
fileName,
|
||||||
fileName: fileName,
|
sheets: [
|
||||||
fields: Object.keys(colIndexMap),
|
{
|
||||||
fieldLabels: colIndexMap,
|
jsonData: dataList || [],
|
||||||
formatMap: {}
|
sheetName: 'benchmark_data',
|
||||||
|
fields: Object.keys(colIndexMap),
|
||||||
|
fieldLabels: colIndexMap,
|
||||||
|
formatMap: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return { exportData };
|
return { exportData };
|
||||||
|
|||||||
@@ -108,27 +108,34 @@ const ExportData: React.FC<{
|
|||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const fileName = `usage-data_${query.start_date || ''}_${query.end_date || ''}.xlsx`;
|
const fileName = `usage-data_${query.start_date || ''}_${query.end_date || ''}.xlsx`;
|
||||||
exportJsonToExcel({
|
exportJsonToExcel({
|
||||||
jsonData: result.data?.items || [],
|
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
fields: exportTableColumns
|
sheets: [
|
||||||
.map((col) => col.dataIndex)
|
{
|
||||||
.filter(Boolean) as string[],
|
jsonData: result.data?.items || [],
|
||||||
fieldLabels: {
|
sheetName: 'usage_data',
|
||||||
user_id: 'User',
|
fields: exportTableColumns
|
||||||
model_id: 'Model',
|
.map((col) => col.dataIndex)
|
||||||
date: 'Date',
|
.filter(Boolean) as string[],
|
||||||
prompt_token_count: 'Prompt Tokens',
|
fieldLabels: {
|
||||||
completion_token_count: 'Completion Tokens',
|
user_id: 'User',
|
||||||
request_count: 'API Requests'
|
model_id: 'Model',
|
||||||
},
|
date: 'Date',
|
||||||
formatMap: {
|
prompt_token_count: 'Prompt Tokens',
|
||||||
user_id: (value: string) => {
|
completion_token_count: 'Completion Tokens',
|
||||||
return userList.find((item) => item.value === value)?.label || value;
|
request_count: 'API Requests'
|
||||||
},
|
},
|
||||||
model_id: (value: string, record: any) => {
|
formatMap: {
|
||||||
return getModelName(record);
|
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 {
|
interface RangePickerPreset {
|
||||||
range: number;
|
range: number;
|
||||||
|
presetRanges?: {
|
||||||
|
label: React.ReactNode;
|
||||||
|
value: [Dayjs, Dayjs] | (() => [Dayjs, Dayjs]);
|
||||||
|
}[];
|
||||||
disabledDate?: boolean;
|
disabledDate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,7 +19,7 @@ export default function useRangePickerPreset(options?: RangePickerPreset): {
|
|||||||
}[];
|
}[];
|
||||||
range: number;
|
range: number;
|
||||||
} {
|
} {
|
||||||
const { range = 60, disabledDate } = options || {};
|
const { range = 60, disabledDate, presetRanges } = options || {};
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const getYearMonth = (date: Dayjs) => date.year() * 12 + date.month();
|
const getYearMonth = (date: Dayjs) => date.year() * 12 + date.month();
|
||||||
@@ -54,7 +58,7 @@ export default function useRangePickerPreset(options?: RangePickerPreset): {
|
|||||||
const rangePresets: {
|
const rangePresets: {
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
value: [Dayjs, Dayjs] | (() => [Dayjs, Dayjs]);
|
value: [Dayjs, Dayjs] | (() => [Dayjs, Dayjs]);
|
||||||
}[] = [
|
}[] = presetRanges || [
|
||||||
{
|
{
|
||||||
label: intl.formatMessage({ id: 'dashboard.usage.datePicker.last7days' }),
|
label: intl.formatMessage({ id: 'dashboard.usage.datePicker.last7days' }),
|
||||||
value: [dayjs().add(-6, 'd'), dayjs()]
|
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 jsonData raw JSON data to export
|
||||||
* @param fields export fields (keys in the JSON objects)
|
* @param fields export fields (keys in the JSON objects)
|
||||||
@@ -27,45 +23,70 @@ interface FormatMap {
|
|||||||
* @param formatMap custom the cell format functions
|
* @param formatMap custom the cell format functions
|
||||||
* @param fileName file name for the exported Excel file
|
* @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[];
|
jsonData: any[];
|
||||||
fields: string[];
|
fields: string[];
|
||||||
fieldLabels?: Record<string, string>;
|
fieldLabels?: Record<string, string>;
|
||||||
formatMap?: FormatMap;
|
formatMap?: FormatMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportExcelOptions {
|
||||||
|
sheets: ExportSheetConfig[];
|
||||||
fileName: string;
|
fileName: string;
|
||||||
}) {
|
}
|
||||||
const {
|
|
||||||
jsonData,
|
export function exportJsonToExcel({
|
||||||
fields,
|
sheets,
|
||||||
fieldLabels,
|
fileName = 'data.xlsx'
|
||||||
formatMap,
|
}: ExportExcelOptions) {
|
||||||
fileName = 'data.xlsx'
|
const workbook = XLSX.utils.book_new();
|
||||||
} = data;
|
|
||||||
// 1. Process data: filter fields and format values
|
sheets.forEach((sheet) => {
|
||||||
const formattedData = jsonData.map((row) => {
|
const { sheetName, jsonData, fields, fieldLabels, formatMap } = sheet;
|
||||||
const result: Record<string, any> = {};
|
|
||||||
for (const field of fields) {
|
// 1. format data
|
||||||
const rawValue = row[field];
|
const formattedData = jsonData.map((row) => {
|
||||||
const formatFn = formatMap?.[field];
|
const result: Record<string, any> = {};
|
||||||
result[field] = formatFn ? formatFn(rawValue, row) : rawValue;
|
|
||||||
|
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
|
// 5. export file
|
||||||
const worksheet = XLSX.utils.json_to_sheet(formattedData, { header: fields });
|
const excelBuffer = XLSX.write(workbook, {
|
||||||
|
bookType: 'xlsx',
|
||||||
|
type: 'array'
|
||||||
|
});
|
||||||
|
|
||||||
// 3. Add headers if provided
|
const blob = new Blob([excelBuffer], {
|
||||||
if (fieldLabels) {
|
type: 'application/octet-stream'
|
||||||
const headerRow = fields.map((key) => fieldLabels[key] || key);
|
});
|
||||||
XLSX.utils.sheet_add_aoa(worksheet, [headerRow], { origin: 'A1' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
saveAs(blob, fileName);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user