diff --git a/package.json b/package.json index dd92553b..7a4626c5 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "driver.js": "^1.3.1", "echarts": "^5.5.1", "epubjs": "^0.3.93", + "file-saver": "^2.0.5", "has-ansi": "^5.0.1", "highlight.js": "^11.10.0", "jotai": "^2.8.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f33c9e0..b66e754e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ dependencies: epubjs: specifier: ^0.3.93 version: 0.3.93 + file-saver: + specifier: ^2.0.5 + version: 2.0.5 has-ansi: specifier: ^5.0.1 version: 5.0.1 @@ -10788,6 +10791,10 @@ packages: webpack: 5.97.1 dev: true + /file-saver@2.0.5: + resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==, tarball: https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz} + dev: false + /fill-range@7.0.1: resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} engines: {node: '>=8'} diff --git a/src/components/echarts/chart.tsx b/src/components/echarts/chart.tsx index 0275fa19..36a5834f 100644 --- a/src/components/echarts/chart.tsx +++ b/src/components/echarts/chart.tsx @@ -54,6 +54,13 @@ const Chart: React.FC<{ const currentChart = chart.current; const optionsYAxis = currentChart.getOption()?.yAxis; + + console.log( + 'chart finished', + finished.current, + optionsYAxis, + chart.current + ); if ( !optionsYAxis || !Array.isArray(optionsYAxis) || @@ -81,10 +88,6 @@ const Chart: React.FC<{ const newMax0 = intervals[0] * (unifiedCount - 1); const newMax1 = intervals[1] * (unifiedCount - 1); - // get yaxis max value - const maxValue0 = Math.max(); - const maxValue1 = yAxisModels[1].get('max'); - // if newMax0 equal to maxValue0, and newMax1 equal to maxValue1, do not update yAxis if (counts[0] === counts[1]) return; @@ -127,7 +130,7 @@ const Chart: React.FC<{ resize(); setOption(options); resizeable.current = true; - }, [options]); + }, [setOption]); useEffect(() => { const handleResize = throttle(() => { diff --git a/src/components/echarts/mix-line-bar.tsx b/src/components/echarts/mix-line-bar.tsx index 82e9aeb3..136f89ed 100644 --- a/src/components/echarts/mix-line-bar.tsx +++ b/src/components/echarts/mix-line-bar.tsx @@ -2,7 +2,7 @@ import Chart from '@/components/echarts/chart'; import useChartConfig from '@/components/echarts/config'; import EmptyData from '@/components/empty-data'; import _ from 'lodash'; -import React, { memo, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { ChartProps } from './types'; const MixLineBarChart: React.FC< @@ -147,4 +147,4 @@ const MixLineBarChart: React.FC< ); }; -export default memo(MixLineBarChart); +export default MixLineBarChart; diff --git a/src/components/seal-form/hooks/use-select-render.tsx b/src/components/seal-form/hooks/use-select-render.tsx new file mode 100644 index 00000000..18d45274 --- /dev/null +++ b/src/components/seal-form/hooks/use-select-render.tsx @@ -0,0 +1,25 @@ +import AutoTooltip from '@/components/auto-tooltip'; + +interface SelectRenderProps { + maxTagWidth?: number; +} + +export default function useSelectRender(config?: SelectRenderProps) { + const { maxTagWidth = 100 } = config || {}; + const TagRender = (props: any) => { + const { label } = props; + return ( + + {label} + + ); + }; + + return { + TagRender + }; +} diff --git a/src/locales/en-US/dashboard.ts b/src/locales/en-US/dashboard.ts index b412e11a..7ce06119 100644 --- a/src/locales/en-US/dashboard.ts +++ b/src/locales/en-US/dashboard.ts @@ -27,5 +27,8 @@ export default { 'dashboard.usage.export': 'Export Data', 'dashboard.usage.export.user': 'User', 'dashboard.usage.export.model': 'Model', - 'dashboard.usage.export.date': 'Date' + 'dashboard.usage.export.date': 'Date', + 'dashboard.usage.datePicker.last7days': 'Last 7 Days', + 'dashboard.usage.datePicker.last30days': 'Last 30 Days', + 'dashboard.usage.datePicker.last60days': 'Last 60 Days' }; diff --git a/src/locales/ja-JP/dashboard.ts b/src/locales/ja-JP/dashboard.ts index a9779c8d..6359ab73 100644 --- a/src/locales/ja-JP/dashboard.ts +++ b/src/locales/ja-JP/dashboard.ts @@ -27,7 +27,10 @@ export default { 'dashboard.usage.export': 'Export Data', 'dashboard.usage.export.user': 'User', 'dashboard.usage.export.model': 'Model', - 'dashboard.usage.export.date': 'Date' + 'dashboard.usage.export.date': 'Date', + 'dashboard.usage.datePicker.last7days': 'Last 7 Days', + 'dashboard.usage.datePicker.last30days': 'Last 30 Days', + 'dashboard.usage.datePicker.last60days': 'Last 60 Days' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== @@ -36,5 +39,8 @@ export default { // 3. 'dashboard.usage.export': 'Export Data', // 4. 'dashboard.usage.export.user': 'User', // 5. 'dashboard.usage.export.model': 'Model', -// 6. 'dashboard.usage.export.date': 'Date' +// 6. 'dashboard.usage.export.date': 'Date', +// 7. 'dashboard.usage.datePicker.last7days': 'Last 7 Days', +// 8.'dashboard.usage.datePicker.last30days': 'Last 30 Days', +// 9. 'dashboard.usage.datePicker.last60days': 'Last 60 Days' // ========== End of To-Do List ========== diff --git a/src/locales/ru-RU/dashboard.ts b/src/locales/ru-RU/dashboard.ts index 32f9978f..ccef3c37 100644 --- a/src/locales/ru-RU/dashboard.ts +++ b/src/locales/ru-RU/dashboard.ts @@ -27,9 +27,14 @@ export default { 'dashboard.usage.export': 'Экспорт данных', 'dashboard.usage.export.user': 'Пользователь', 'dashboard.usage.export.model': 'Модель', - 'dashboard.usage.export.date': 'Дата' + 'dashboard.usage.export.date': 'Дата', + 'dashboard.usage.datePicker.last7days': 'Last 7 Days', + 'dashboard.usage.datePicker.last30days': 'Last 30 Days', + 'dashboard.usage.datePicker.last60days': 'Last 60 Days' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== - +// 1. 'dashboard.usage.datePicker.last7days': 'Last 7 Days', +// 2. 'dashboard.usage.datePicker.last30days': 'Last 30 Days', +// 3. 'dashboard.usage.datePicker.last60days': 'Last 60 Days' // ========== End of To-Do List ========== diff --git a/src/locales/zh-CN/dashboard.ts b/src/locales/zh-CN/dashboard.ts index 66e1f115..25c364a7 100644 --- a/src/locales/zh-CN/dashboard.ts +++ b/src/locales/zh-CN/dashboard.ts @@ -27,5 +27,8 @@ export default { 'dashboard.usage.export': '导出数据', 'dashboard.usage.export.user': '用户', 'dashboard.usage.export.model': '模型', - 'dashboard.usage.export.date': '日期' + 'dashboard.usage.export.date': '日期', + 'dashboard.usage.datePicker.last7days': '最近 7 天', + 'dashboard.usage.datePicker.last30days': '最近 30 天', + 'dashboard.usage.datePicker.last60days': '最近 60 天' }; diff --git a/src/pages/dashboard/apis/index.ts b/src/pages/dashboard/apis/index.ts index 888f1432..aafa34f1 100644 --- a/src/pages/dashboard/apis/index.ts +++ b/src/pages/dashboard/apis/index.ts @@ -1,4 +1,5 @@ import { request } from '@umijs/max'; +import qs from 'query-string'; export const DASHBOARD_API = '/dashboard'; @@ -6,7 +7,20 @@ export async function queryDashboardData() { return request(DASHBOARD_API); } -export async function queryDashboardUsageData() { - // return request(`${DASHBOARD_API}/usage`); - return {}; +export async function queryDashboardUsageData( + params: { + start_date: string; + end_date: string; + model_ids?: number[]; + user_ids?: number[]; + raw?: boolean; + }, + options?: { + token?: any; + } +) { + return request(`${DASHBOARD_API}/usage?${qs.stringify(params)}`, { + method: 'GET', + cancelToken: options?.token + }); } diff --git a/src/pages/dashboard/components/usage-inner/export-data.tsx b/src/pages/dashboard/components/usage-inner/export-data.tsx index 289de33a..30727a2b 100644 --- a/src/pages/dashboard/components/usage-inner/export-data.tsx +++ b/src/pages/dashboard/components/usage-inner/export-data.tsx @@ -1,40 +1,112 @@ +import AutoTooltip from '@/components/auto-tooltip'; import ModalFooter from '@/components/modal-footer'; import ScrollerModal from '@/components/scroller-modal'; -import useTableFetch from '@/hooks/use-table-fetch'; +import { exportJsonToExcel } from '@/utils/excel-reader'; import { useIntl } from '@umijs/max'; -import { DatePicker, Select, Space, Table } from 'antd'; -import dayjs from 'dayjs'; -import React from 'react'; -import { queryDashboardUsageData } from '../../apis'; -import { exportTableColumns } from '../../config'; -import useRangePickerPreset from '../../hooks/use-rangepicker-preset'; +import { Table, TableColumnType } from 'antd'; +import React, { useEffect } from 'react'; +import { TableRow } from '../../config/types'; +import useUsageData from './use-usage-data'; const ExportData: React.FC<{ open: boolean; onCancel: () => void; }> = (props) => { - const { - dataSource, - rowSelection, - queryParams, - modalRef, - handleDelete, - handleDeleteBatch, - fetchData, - handlePageChange, - handleTableChange, - handleSearch, - handleNameChange - } = useTableFetch({ - fetchAPI: queryDashboardUsageData - }); const { open, onCancel } = props || {}; const intl = useIntl(); - const { disabledRangeDaysDate, rangePresets } = useRangePickerPreset({ - range: 60 - }); + const { FilterBar, loading, init, result, userList, modelList, query } = + useUsageData<{ + items: TableRow[]; + }>({ + raw: true + }); - const handleSubmit = () => {}; + const exportTableColumns: TableColumnType[] = [ + { + title: intl.formatMessage({ id: 'resources.table.index' }), + width: 60, + render(text: any, row: any, index: number) { + return index + 1; + } + }, + { + title: intl.formatMessage({ id: 'dashboard.usage.export.date' }), + dataIndex: 'date' + }, + { + title: intl.formatMessage({ id: 'dashboard.usage.export.user' }), + dataIndex: 'user_id', + render: (text: string) => { + return ( + + {userList.find((item) => item.value === text)?.label} + + ); + } + }, + { + title: intl.formatMessage({ id: 'dashboard.usage.export.model' }), + dataIndex: 'model_id', + render: (text: string) => { + return ( + + {modelList.find((item) => item.value === text)?.label || text} + + ); + } + }, + + { + title: 'Completion Tokens', + dataIndex: 'completion_token_count', + width: 170, + align: 'right' + }, + { + title: 'Prompt Tokens', + dataIndex: 'prompt_token_count', + align: 'right', + width: 150 + }, + { + title: 'API Requests', + dataIndex: 'request_count', + align: 'right', + width: 150 + } + ]; + const handleSubmit = () => { + const fileName = `usage-data_${query.start_date || ''}_${query.end_date || ''}.xlsx`; + exportJsonToExcel({ + jsonData: result.data?.items || [], + fileName: fileName, + fields: exportTableColumns + .map((col) => col.dataIndex) + .filter(Boolean) as string[], + fieldLabels: { + user_id: 'User', + model_id: 'Model', + date: 'Date', + prompt_token_count: 'Prompt Tokens', + completion_token_count: 'Completion Tokens', + request_count: 'API Requests' + }, + formatMap: { + user_id: (value: string) => { + return userList.find((item) => item.value === value)?.label || value; + }, + model_id: (value: string) => { + return modelList.find((item) => item.value === value)?.label || value; + } + } + }); + }; + + useEffect(() => { + if (open) { + init(); + } + }, [open]); return ( } > - - { - handleSearch(); - }} - > - - - + - {' '} - + virtual + scroll={{ y: 400 }} + pagination={false} + > ); }; diff --git a/src/pages/dashboard/components/usage-inner/index.tsx b/src/pages/dashboard/components/usage-inner/index.tsx index 11119922..3322fd75 100644 --- a/src/pages/dashboard/components/usage-inner/index.tsx +++ b/src/pages/dashboard/components/usage-inner/index.tsx @@ -1,65 +1,70 @@ -import { ExportOutlined } from '@ant-design/icons'; import { useIntl } from '@umijs/max'; -import { Button, Col, DatePicker, Row, Select, Tooltip } from 'antd'; -import dayjs from 'dayjs'; -import { FC, memo, useContext, useState } from 'react'; +import { Col, Row } from 'antd'; +import { FC, useContext, useEffect, useMemo } from 'react'; import styled from 'styled-components'; +import { baseColorMap } from '../../config'; import { DashboardContext } from '../../config/dashboard-context'; -import useRangePickerPreset from '../../hooks/use-rangepicker-preset'; +import { DashboardUsageData } from '../../config/types'; import ExportData from './export-data'; import RequestTokenInner from './request-token-inner'; import TopUser from './top-user'; import useUsageData from './use-usage-data'; -const FilterWrapper = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0px; - .selection { - display: flex; - align-items: center; - gap: 12px; - } -`; - const TitleWrapper = styled.div` margin: 26px 0px; margin-bottom: 38px; font-weight: 700; `; -const dataList = [ - { label: '100M', value: 'Completion Tokens' }, - { label: '50M', value: 'Prompt Tokens' }, - { label: '120K', value: 'API Requests' } -]; - const UsageInner: FC<{ paddingRight: string }> = ({ paddingRight }) => { const intl = useIntl(); + const { model_usage } = useContext(DashboardContext); - const [query, setQuery] = useState({ - startDate: dayjs().subtract(30, 'days').format('YYYY-MM-DD'), - endDate: dayjs().format('YYYY-MM-DD'), - user: [], - model: [] - }); + const { usageData, handleOnCancel, handleExport, FilterBar, init, open } = + useUsageData({ + raw: false, + defaultData: { + api_request_history: [], + completion_token_history: [], + prompt_token_history: [] + } + }); - const { disabledRangeDaysDate, rangePresets } = useRangePickerPreset({ - range: 60 - }); - const { model_usage } = useContext(DashboardContext); - const [open, setOpen] = useState(false); + const topUserData = useMemo(() => { + const topUsers = model_usage?.top_users || []; + + const topUserPrompt: any = { + name: 'Prompt tokens', + color: baseColorMap.baseR3, + data: [] as { name: string; value: number }[] + }; + const topUserCompletion: any = { + name: 'Completion tokens', + color: baseColorMap.base, + data: [] as { name: string; value: number }[] + }; - const { requestTokenData, topUserData } = useUsageData(model_usage || {}); + const topUserNames = topUsers.map((item: any) => { + topUserPrompt.data.push({ + name: item.username, + value: item.prompt_token_count + }); + topUserCompletion.data.push({ + name: item.username, + value: item.completion_token_count + }); + return item.username; + }); - const handleOnCancel = () => { - setOpen(false); - }; + return { + userData: [topUserCompletion, topUserPrompt], + topUserList: [...new Set(topUserNames)] as string[] + }; + }, [model_usage?.top_users]); - const handleExport = () => { - setOpen(true); - }; + useEffect(() => { + init(); + }, []); return ( @@ -83,47 +88,13 @@ const UsageInner: FC<{ paddingRight: string }> = ({ paddingRight }) => { {intl.formatMessage({ id: 'dashboard.usage' })} - - - - - - - } - onClick={handleExport} - > - - - + @@ -141,4 +112,4 @@ const UsageInner: FC<{ paddingRight: string }> = ({ paddingRight }) => { ); }; -export default memo(UsageInner); +export default UsageInner; diff --git a/src/pages/dashboard/components/usage-inner/request-token-inner.tsx b/src/pages/dashboard/components/usage-inner/request-token-inner.tsx index 0700485f..2450f3e1 100644 --- a/src/pages/dashboard/components/usage-inner/request-token-inner.tsx +++ b/src/pages/dashboard/components/usage-inner/request-token-inner.tsx @@ -1,10 +1,10 @@ import CardWrapper from '@/components/card-wrapper'; import { SimpleCard } from '@/components/card-wrapper/simple-card'; import MixLineBar from '@/components/echarts/mix-line-bar'; -import { useIntl } from '@umijs/max'; +import { formatLargeNumber } from '@/utils'; import { Button } from 'antd'; import dayjs from 'dayjs'; -import React from 'react'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; import { baseColorMap } from '../../config'; @@ -38,6 +38,11 @@ interface RequestTokenInnerProps { data: { time: string; value: number }[]; }[]; xAxisData: string[]; + overViewData?: { + requestCount: number; + completionCount: number; + promptCount: number; + }; } const labelFormatter = (v: any) => { @@ -46,20 +51,23 @@ const labelFormatter = (v: any) => { const dataList = [ { - label: '100M', + label: '0', value: 'Completion Tokens', + key: 'completionCount', iconType: 'roundRect', color: baseColorMap.base }, { - label: '50M', + label: '0', value: 'Prompt Tokens', + key: 'promptCount', iconType: 'roundRect', color: baseColorMap.baseR3 }, { - label: '120K', + label: '0', value: 'API Requests', + key: 'requestCount', iconType: 'circle', color: baseColorMap.baseR1 } @@ -73,20 +81,27 @@ const legendData = [ const RequestTokenInner: React.FC = (props) => { const { requestData, tokenData, xAxisData, onExport } = props; - const intl = useIntl(); + + const totalData = useMemo(() => { + const data: Record = { + requestCount: + requestData[0]?.data.reduce((sum, item) => sum + item.value, 0) || 0, + completionCount: + tokenData[0]?.data.reduce((sum, item) => sum + item.value, 0) || 0, + promptCount: + tokenData[1]?.data.reduce((sum, item) => sum + item.value, 0) || 0 + }; + + return dataList.map((item) => ({ + ...item, + label: formatLargeNumber(data[item.key] || 0) as string + })); + }, [requestData, tokenData]); return ( - {/* } - size="small" - onClick={onExport} - > - {intl.formatMessage({ id: 'common.button.export' })} - */} - + = (props) => { seriesData={[]} xAxisData={xAxisData} height={360} - smooth={true} + smooth={false} legendData={legendData} labelFormatter={labelFormatter} > @@ -104,4 +119,4 @@ const RequestTokenInner: React.FC = (props) => { ); }; -export default React.memo(RequestTokenInner); +export default RequestTokenInner; diff --git a/src/pages/dashboard/components/usage-inner/top-user.tsx b/src/pages/dashboard/components/usage-inner/top-user.tsx index cd563459..04455fff 100644 --- a/src/pages/dashboard/components/usage-inner/top-user.tsx +++ b/src/pages/dashboard/components/usage-inner/top-user.tsx @@ -1,6 +1,5 @@ import CardWrapper from '@/components/card-wrapper'; import HBar from '@/components/echarts/h-bar'; -import { useIntl } from '@umijs/max'; import React from 'react'; interface TopUserProps { @@ -9,7 +8,6 @@ interface TopUserProps { } const TopUser: React.FC = (props) => { const { userData, topUserList } = props; - const intl = useIntl(); return ( @@ -18,4 +16,4 @@ const TopUser: React.FC = (props) => { ); }; -export default React.memo(TopUser); +export default TopUser; diff --git a/src/pages/dashboard/components/usage-inner/use-usage-data.ts b/src/pages/dashboard/components/usage-inner/use-usage-data.ts deleted file mode 100644 index 4b9f4266..00000000 --- a/src/pages/dashboard/components/usage-inner/use-usage-data.ts +++ /dev/null @@ -1,144 +0,0 @@ -import dayjs from 'dayjs'; -import { useMemo } from 'react'; -import { baseColorMap } from '../../config'; - -interface RequestTokenData { - requestData: { - name: string; - color: string; - areaStyle: any; - data: { time: string; value: number }[]; - }[]; - tokenData: { - data: { time: string; value: number }[]; - }[]; - xAxisData: string[]; -} - -interface TopUserData { - userData: { name: string; value: number }[]; - topUserList: string[]; -} - -const getLast30Days = () => { - const dates: string[] = []; - - for (let i = 29; i >= 0; i--) { - const date = dayjs().subtract(i, 'day').format('YYYY-MM-DD'); - dates.push(date); - } - - return dates; -}; - -const generateValueMap = (list: { timestamp: number; value: number }[]) => { - return new Map( - list.map((item) => [ - dayjs(item.timestamp * 1000).format('YYYY-MM-DD'), - item.value - ]) - ); -}; - -const generateData = (dateRage: string[], valueMap: Map) => { - return dateRage.map((date) => { - const value = valueMap.get(date) || 0; - return { - time: date, - value: value - }; - }); -}; - -export default function useUseageData(data: any) { - const usageData = useMemo<{ - requestTokenData: RequestTokenData; - topUserData: TopUserData; - }>(() => { - const dateRange = getLast30Days(); - - const completionTokenHistory = data.completion_token_history || []; - const promptTokenHistory = data.prompt_token_history || []; - const apiRequestHistory = data.api_request_history || []; - const topUsers = data.top_users || []; - - if (!completionTokenHistory.length) { - return { - requestTokenData: { - requestData: [], - tokenData: [], - xAxisData: [] - }, - topUserData: { - userData: [], - topUserList: [] - } - }; - } - - // ========== API request ============== - const requestList: { - name: string; - color: string; - areaStyle: any; - data: { time: string; value: number }[]; - } = { - name: 'API requests', - areaStyle: { - color: 'rgba(13,171,219,0.15)' - }, - color: baseColorMap.baseR1, - data: generateData(dateRange, generateValueMap(apiRequestHistory)) - }; - - // =========== token usage data ============== - const completionData: any = { - name: 'Completion tokens', - color: baseColorMap.base, - data: generateData(dateRange, generateValueMap(completionTokenHistory)) - }; - const promptData: any = { - name: 'Prompt tokens', - color: baseColorMap.baseR3, - data: generateData(dateRange, generateValueMap(promptTokenHistory)) - }; - - // ========== top user data ============== - const topUserPrompt: any = { - name: 'Prompt tokens', - color: baseColorMap.baseR3, - data: [] as { name: string; value: number }[] - }; - const topUserCompletion: any = { - name: 'Completion tokens', - color: baseColorMap.base, - data: [] as { name: string; value: number }[] - }; - - const topUserNames = topUsers.map((item: any) => { - topUserPrompt.data.push({ - name: item.username, - value: item.prompt_token_count - }); - topUserCompletion.data.push({ - name: item.username, - value: item.completion_token_count - }); - return item.username; - }); - - return { - requestTokenData: { - requestData: [requestList], - tokenData: [completionData, promptData], - xAxisData: dateRange - }, - topUserData: { - userData: [topUserCompletion, topUserPrompt], - topUserList: [...new Set(topUserNames)] as string[] - } - }; - }, [data]); - - return usageData; -} diff --git a/src/pages/dashboard/components/usage-inner/use-usage-data.tsx b/src/pages/dashboard/components/usage-inner/use-usage-data.tsx new file mode 100644 index 00000000..0cf045c8 --- /dev/null +++ b/src/pages/dashboard/components/usage-inner/use-usage-data.tsx @@ -0,0 +1,379 @@ +import useSelectRender from '@/components/seal-form/hooks/use-select-render'; +import { queryModelsList } from '@/pages/llmodels/apis'; +import { ListItem as ModelListItem } from '@/pages/llmodels/config/types'; +import { queryUsersList } from '@/pages/users/apis'; +import { ExportOutlined } from '@ant-design/icons'; +import { useIntl } from '@umijs/max'; +import { Button, DatePicker, Select, Tooltip } from 'antd'; +import dayjs from 'dayjs'; +import _ from 'lodash'; +import { useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { queryDashboardUsageData } from '../../apis'; +import { baseColorMap } from '../../config'; +import useRangePickerPreset from '../../hooks/use-rangepicker-preset'; + +const FilterWrapper = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0px; + .selection { + display: flex; + align-items: center; + gap: 12px; + } +`; + +interface RequestTokenData { + requestData: { + name: string; + color: string; + areaStyle: any; + data: { time: string; value: number }[]; + }[]; + tokenData: { + data: { time: string; value: number }[]; + }[]; + xAxisData: string[]; +} + +interface TopUserData { + userData: { name: string; value: number }[]; + topUserList: string[]; +} + +const getLast30Days = () => { + const dates: string[] = []; + + for (let i = 29; i >= 0; i--) { + const date = dayjs().subtract(i, 'day').format('YYYY-MM-DD'); + dates.push(date); + } + + return dates; +}; + +const getAllDays = (start: string, end: string) => { + const startDate = dayjs(start); + const endDate = dayjs(end); + const days: string[] = []; + for ( + let d = startDate; + d.isBefore(endDate) || d.isSame(endDate, 'day'); + d = d.add(1, 'day') + ) { + days.push(d.format('YYYY-MM-DD')); + } + return days; +}; + +const generateValueMap = (list: { timestamp: number; value: number }[]) => { + return new Map( + list.map((item) => [ + dayjs(item.timestamp * 1000).format('YYYY-MM-DD'), + item.value + ]) + ); +}; + +const generateData = (dateRage: string[], valueMap: Map) => { + return dateRage.map((date) => { + const value = valueMap.get(date) || 0; + return { + time: date, + value: value + }; + }); +}; + +export default function useUseageData(config?: { + raw?: boolean; + defaultData?: T; +}) { + const { raw = false, defaultData } = config || {}; + const intl = useIntl(); + const { TagRender } = useSelectRender({ + maxTagWidth: 100 + }); + const { disabledRangeDaysDate, rangePresets } = useRangePickerPreset({ + range: 60 + }); + const [open, setOpen] = useState(false); + const [result, setResult] = useState<{ + start_date?: string; + end_date?: string; + data: T; + }>({ + start_date: dayjs().subtract(30, 'days').format('YYYY-MM-DD'), + end_date: dayjs().format('YYYY-MM-DD'), + data: defaultData as T + }); + + const [query, setQuery] = useState<{ + start_date: string; + end_date: string; + model_ids: number[]; + user_ids: number[]; + raw: boolean; + }>({ + raw: raw, + start_date: dayjs().subtract(30, 'days').format('YYYY-MM-DD'), + end_date: dayjs().format('YYYY-MM-DD'), + model_ids: [], + user_ids: [] + }); + + const [modelList, setModelList] = useState[]>([]); + const [userList, setUserList] = useState[]>([]); + const [loading, setLoading] = useState(false); + + const usageData = useMemo<{ + requestTokenData: RequestTokenData; + }>(() => { + if (raw) { + return { + requestTokenData: { + requestData: [], + tokenData: [], + xAxisData: [] + } + }; + } + const { start_date, end_date, data } = result as { + start_date?: string; + end_date?: string; + data: { + api_request_history: { timestamp: number; value: number }[]; + completion_token_history: { timestamp: number; value: number }[]; + prompt_token_history: { timestamp: number; value: number }[]; + }; + }; + + const dateRange = + start_date && end_date + ? getAllDays(start_date, end_date) + : getLast30Days(); + + const completionTokenHistory = data?.completion_token_history || []; + const promptTokenHistory = data?.prompt_token_history || []; + const apiRequestHistory = data?.api_request_history || []; + + if (!completionTokenHistory.length) { + return { + requestTokenData: { + requestData: [], + tokenData: [], + xAxisData: [] + } + }; + } + + // ========== API request ============== + const requestList: { + name: string; + color: string; + areaStyle: any; + data: { time: string; value: number }[]; + } = { + name: 'API requests', + areaStyle: { + color: 'rgba(13,171,219,0.15)' + }, + color: baseColorMap.baseR1, + data: generateData(dateRange, generateValueMap(apiRequestHistory)) + }; + + // =========== token usage data ============== + const completionData: any = { + name: 'Completion tokens', + color: baseColorMap.base, + data: generateData(dateRange, generateValueMap(completionTokenHistory)) + }; + const promptData: any = { + name: 'Prompt tokens', + color: baseColorMap.baseR3, + data: generateData(dateRange, generateValueMap(promptTokenHistory)) + }; + + return { + requestTokenData: { + requestData: [requestList], + tokenData: [completionData, promptData], + xAxisData: dateRange + } + }; + }, [result, raw]); + + const fetchModelsList = async () => { + try { + const params = { + page: 1, + page_size: 100 + }; + + const response = await queryModelsList(params); + const list = _.map(response.items || [], (item: ModelListItem) => { + return { + label: item.name, + value: item.id + }; + }); + setModelList(list); + } catch (error) { + setModelList([]); + } + }; + + const fetchUsersList = async () => { + try { + const params = { + page: 1, + page_size: 100 + }; + + const response = await queryUsersList(params); + const list = _.map(response.items || [], (item: any) => { + return { + label: item.username, + value: item.id + }; + }); + setUserList(list); + } catch (error) { + setUserList([]); + } + }; + + const fetchUsageData = async (queryParams: any) => { + try { + setLoading(true); + const response = await queryDashboardUsageData(queryParams); + setResult({ + start_date: queryParams.start_date, + end_date: queryParams.end_date, + data: response + }); + } catch (error) { + setResult({ + start_date: queryParams.start_date, + end_date: queryParams.end_date, + data: {} as T + }); + } finally { + setLoading(false); + } + }; + + const handleDateChange = (dates: any, dateString: string[]) => { + setQuery((pre) => { + return { + ...pre, + start_date: dateString[0], + end_date: dateString[1] + }; + }); + fetchUsageData({ + ...query, + start_date: dateString[0], + end_date: dateString[1] + }); + }; + + const handleOnCancel = () => { + setOpen(false); + }; + + const handleExport = () => { + setOpen(true); + }; + + const handleUsersChange = (value: number[]) => { + setQuery((pre) => { + return { + ...pre, + user_ids: value + }; + }); + fetchUsageData({ ...query, user_ids: value }); + }; + const handleModelsChange = (value: number[]) => { + setQuery((pre) => { + return { + ...pre, + model_ids: value + }; + }); + fetchUsageData({ ...query, model_ids: value }); + }; + + const init = () => { + fetchUsageData(query); + fetchModelsList(); + fetchUsersList(); + }; + + const FilterBar = () => { + return ( + + + + + + {!raw && ( + + } onClick={handleExport}> + + )} + + + ); + }; + + return { + usageData, + result, + open, + loading, + userList, + modelList, + query, + init, + setResult, + FilterBar, + handleOnCancel, + handleExport + }; +} diff --git a/src/pages/dashboard/config/index.ts b/src/pages/dashboard/config/index.ts index a58935d1..ca771d4e 100644 --- a/src/pages/dashboard/config/index.ts +++ b/src/pages/dashboard/config/index.ts @@ -1,6 +1,3 @@ -import { formatLargeNumber } from '@/utils'; -import dayjs from 'dayjs'; - export const overviewConfigs = [ { key: 'worker_count', @@ -25,52 +22,6 @@ export const overviewConfigs = [ } ]; -export const exportTableColumns = [ - { - title: 'Date', - dataIndex: 'date', - render: (text: string) => { - return dayjs(text).format('YYYY-MM-DD'); - } - }, - { - title: 'User', - dataIndex: 'user', - render: (text: string) => { - return text || 'admin'; - } - }, - { - title: 'Model', - dataIndex: 'model', - render: (text: string) => { - return text || 'All Models'; - } - }, - - { - title: 'Completion Tokens', - dataIndex: 'completion_tokens', - render: (text: number) => { - return formatLargeNumber(text) || 0; - } - }, - { - title: 'Prompt Tokens', - dataIndex: 'prompt_tokens', - render: (text: number) => { - return formatLargeNumber(text) || 0; - } - }, - { - title: 'API Requests', - dataIndex: 'request_count', - render: (text: number) => { - return formatLargeNumber(text) || 0; - } - } -]; - export const baseColorMap = { baseL2: 'rgba(13,171,219,0.8)', baseL1: 'rgba(0,34,255,0.8)', diff --git a/src/pages/dashboard/config/types.ts b/src/pages/dashboard/config/types.ts index bfe5030e..417d32e2 100644 --- a/src/pages/dashboard/config/types.ts +++ b/src/pages/dashboard/config/types.ts @@ -44,3 +44,20 @@ export interface DashboardProps { }; active_models: any[]; } + +export interface DashboardUsageData { + api_request_history: any[]; + completion_token_history: any[]; + prompt_token_history: any[]; +} + +export interface TableRow { + id: number; + prompt_token_count: number; + completion_token_count: number; + operation: string; + model_id: number; + date: string; + user_id: number; + request_count: number; +} diff --git a/src/pages/dashboard/hooks/use-rangepicker-preset.ts b/src/pages/dashboard/hooks/use-rangepicker-preset.ts index ba600358..c3adb2bd 100644 --- a/src/pages/dashboard/hooks/use-rangepicker-preset.ts +++ b/src/pages/dashboard/hooks/use-rangepicker-preset.ts @@ -1,3 +1,4 @@ +import { useIntl } from '@umijs/max'; import { DatePickerProps } from 'antd'; import dayjs, { type Dayjs } from 'dayjs'; @@ -14,6 +15,7 @@ export default function useRangePickerPreset(options?: RangePickerPreset): { range: number; } { const { range = 60 } = options || {}; + const intl = useIntl(); const getYearMonth = (date: Dayjs) => date.year() * 12 + date.month(); @@ -43,7 +45,7 @@ export default function useRangePickerPreset(options?: RangePickerPreset): { ); default: - return Math.abs(current.diff(from, 'days')) >= range; + return Math.abs(current.diff(from, 'days')) > range; } } @@ -54,9 +56,22 @@ export default function useRangePickerPreset(options?: RangePickerPreset): { label: React.ReactNode; value: [Dayjs, Dayjs] | (() => [Dayjs, Dayjs]); }[] = [ - { label: 'Last 7 Days', value: [dayjs().add(-7, 'd'), dayjs()] }, - { label: 'Last 30 Days', value: [dayjs().add(-30, 'd'), dayjs()] }, - { label: 'Last 60 Days', value: [dayjs().add(-60, 'd'), dayjs()] } + { + label: intl.formatMessage({ id: 'dashboard.usage.datePicker.last7days' }), + value: [dayjs().add(-7, 'd'), dayjs()] + }, + { + label: intl.formatMessage({ + id: 'dashboard.usage.datePicker.last30days' + }), + value: [dayjs().add(-30, 'd'), dayjs()] + }, + { + label: intl.formatMessage({ + id: 'dashboard.usage.datePicker.last60days' + }), + value: [dayjs().add(-60, 'd'), dayjs()] + } ]; return { diff --git a/src/pages/llmodels/apis/index.ts b/src/pages/llmodels/apis/index.ts index 3d9fc1d8..43219383 100644 --- a/src/pages/llmodels/apis/index.ts +++ b/src/pages/llmodels/apis/index.ts @@ -41,7 +41,7 @@ export async function queryModelsInstances( } export async function queryModelsList( params: Global.SearchParams, - options?: any + options?: Record ) { return request>( `${MODELS_API}?${qs.stringify(params)}`, diff --git a/src/utils/excel-reader.ts b/src/utils/excel-reader.ts index 32262f38..8d438fa1 100644 --- a/src/utils/excel-reader.ts +++ b/src/utils/excel-reader.ts @@ -1,3 +1,4 @@ +import { saveAs } from 'file-saver'; import XLSX from 'xlsx'; export default function readExcelContent(file: File): Promise { @@ -14,3 +15,57 @@ export default function readExcelContent(file: File): Promise { reader.readAsArrayBuffer(file); }); } + +interface FormatMap { + [key: string]: (value: any, row?: any) => any; +} + +/** + * @param jsonData raw JSON data to export + * @param fields export fields (keys in the JSON objects) + * @param fieldLabels custom the table header labels + * @param formatMap custom the cell format functions + * @param fileName file name for the exported Excel file + */ +export function exportJsonToExcel(data: { + jsonData: any[]; + fields: string[]; + fieldLabels?: Record; + formatMap?: FormatMap; + fileName: string; +}) { + const { + jsonData, + fields, + fieldLabels, + formatMap, + fileName = 'data.xlsx' + } = data; + // 1. Process data: filter fields and format values + const formattedData = jsonData.map((row) => { + const result: Record = {}; + for (const field of fields) { + const rawValue = row[field]; + const formatFn = formatMap?.[field]; + result[field] = formatFn ? formatFn(rawValue, row) : rawValue; + } + return result; + }); + + // 2. Convert to worksheet + const worksheet = XLSX.utils.json_to_sheet(formattedData, { header: fields }); + + // 3. Add headers if provided + if (fieldLabels) { + 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); +} diff --git a/typings.d.ts b/typings.d.ts index cf0a9e51..2791e617 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -25,5 +25,6 @@ declare module 'colorthief'; declare module 'vibrant'; declare module 'node-vibrant'; declare module 'lamejs'; +declare module 'file-saver'; declare const REACT_APP_ENV: 'test' | 'dev' | 'pre' | false;