diff --git a/src/hooks/use-query-data-list.ts b/src/hooks/use-query-data-list.ts index fa0b2c4e..6443f0af 100644 --- a/src/hooks/use-query-data-list.ts +++ b/src/hooks/use-query-data-list.ts @@ -18,6 +18,7 @@ export function useQueryDataList< Response = Array >(option: { key: string; + manual?: boolean; responseType?: 'array' | 'object'; fetchList: ( params: Params, @@ -38,6 +39,7 @@ export function useQueryDataList< fetchList, getLabel, getValue, + manual = true, responseType = 'array', errorMsg } = option; @@ -70,7 +72,7 @@ export function useQueryDataList< return responseType === 'array' ? res.items || [] : res; }, { - manual: true, + manual: manual, debounceWait: option.debounceWait || 300, onSuccess: () => {}, onError: (error) => { @@ -105,16 +107,18 @@ export function useQueryDataList< export function useQueryData(option: { key: string; delay?: number; + manual?: boolean; fetchDetail: (params: Params, options?: any) => Promise; getData?: (response: Detail, params?: any) => any; errorMsg?: string; }): { loading: boolean; detailData: Detail; + manual?: boolean; cancelRequest: () => void; fetchData: (params: Params, extra?: any) => Promise; } { - const { key, fetchDetail, getData, errorMsg, delay } = option; + const { key, fetchDetail, getData, errorMsg, delay, manual = true } = option; const axiosTokenRef = useRef(null); const [detailData, setDetailData] = useState({} as Detail); @@ -142,7 +146,7 @@ export function useQueryData(option: { return res; }, { - manual: true, + manual: manual, onSuccess: () => {}, onError: (error) => { message.error( diff --git a/src/pages/_components/bar-chart/index.tsx b/src/pages/_components/bar-chart/index.tsx index 80473fd3..a8643124 100644 --- a/src/pages/_components/bar-chart/index.tsx +++ b/src/pages/_components/bar-chart/index.tsx @@ -1,7 +1,7 @@ import useCoolColors from '@/hooks/use-cool-colors'; import { Chart } from '@gpustack/core-ui'; import { formatLargeNumber } from '@gpustack/core-ui/utils'; -import { Empty, theme } from 'antd'; +import { Empty, Spin, theme } from 'antd'; import _ from 'lodash'; import React, { useEffect, useMemo, useRef } from 'react'; @@ -17,6 +17,7 @@ export interface BarChartProps { xAxisData: string[]; height: number | string; width?: number | string; + loading?: boolean; legendData?: { name: string; icon?: string }[]; labelFormatter?: (val: any, index?: number) => string; tooltipValueFormatter?: (val: any) => string; @@ -38,6 +39,7 @@ const BarChart: React.FC = (props) => { height, width, legendData, + loading, labelFormatter, tooltipValueFormatter, title, @@ -277,18 +279,40 @@ const BarChart: React.FC = (props) => { justifyContent: 'center' }} > - + {loading ? ( + + ) : ( + + )} ); } return ( - +
+ + {loading && ( +
+ +
+ )} +
); }; diff --git a/src/pages/usage/summary-tab/index.tsx b/src/pages/usage/summary-tab/index.tsx index 339c2b35..07ac091d 100644 --- a/src/pages/usage/summary-tab/index.tsx +++ b/src/pages/usage/summary-tab/index.tsx @@ -27,17 +27,10 @@ import PieChart from '@/pages/_components/pie-chart'; import { formatLargeNumber } from '@/utils'; import { CardWrapper } from '@gpustack/core-ui'; import { useAccess, useIntl } from '@umijs/max'; -import { Col, Empty, Row } from 'antd'; +import { useRequest } from 'ahooks'; +import { Col, Row } from 'antd'; import dayjs from 'dayjs'; -import React, { useEffect, useMemo, useState } from 'react'; -import { queryUsageTimeSeriesData } from '../apis'; -import { - queryResourceBreakdown, - queryStorageBreakdown, - queryUsageSummary, - ResourceBreakdownResponse, - UsageSummaryResponse -} from '../apis/resource'; +import React, { useMemo, useState } from 'react'; import ResourceFilterBar from '../components/resource-filter-bar'; import useResourceMeta from '../hooks/use-resource-meta'; import { @@ -45,9 +38,19 @@ import { generateBucketRange, Granularity } from '../utils/time-buckets'; +import useQueryResourceBreakdown from './services/use-query-resource-breakdown'; +import useQueryStorageBreakdown from './services/use-query-storage-breakdown'; +import useQueryTimeSeriesData from './services/use-query-timeseries-data'; +import useQueryUsageSummary from './services/use-query-usage-summary'; type Scope = 'self' | 'all'; +type QueryParams = { + start: string; + end: string; + selectedUsers: number[]; +}; + // Round to at most 2 decimals everywhere (avoid 1.60999999… in the donut center). const round2 = (n?: number) => Math.round((Number(n) || 0) * 100) / 100; const fmt = (n?: number) => formatLargeNumber(round2(n)); @@ -96,6 +99,8 @@ const Stat: React.FC<{ value: React.ReactNode; label: string }> = ({ // row, then a donut (left, legend hugging it) beside a trend (right) that // carries its own chart title — no wasteful caption rows. const DomainSection: React.FC<{ + pieLoading?: boolean; + barLoading?: boolean; title: string; accent: string; headline: React.ReactNode; @@ -107,6 +112,8 @@ const DomainSection: React.FC<{ trendColor: string; trendGran: Granularity; }> = ({ + pieLoading, + barLoading, title, accent, headline, @@ -118,16 +125,14 @@ const DomainSection: React.FC<{ trendColor, trendGran }) => { - const intl = useIntl(); const donutTotal = round2(donutData.reduce((s, d) => s + (d.value || 0), 0)); - const trendEmpty = trendData.every((v) => !v); - const empty = ( - - ); + + const seriesData = useMemo(() => { + return [{ name: trendTitle, data: trendData, color: trendColor }].filter( + (s) => s.data.some((v) => !!v) + ); + }, [trendData, trendColor]); + return (
- {donutTotal <= 0 ? ( - empty - ) : ( - - )} +
- {trendEmpty ? ( - empty - ) : ( - - formatLargeNumber(round2(Number(v))) as string - } - /> - )} + + formatLargeNumber(round2(Number(v))) as string + } + />
@@ -216,95 +213,106 @@ const SummaryTab: React.FC = () => { // Summary trends are fixed to a daily granularity (no granularity control). const granularity: Granularity = 'day'; - const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([ - dayjs().subtract(29, 'day'), - dayjs() - ]); - const [selectedUsers, setSelectedUsers] = useState([]); - const [refreshKey, setRefreshKey] = useState(0); + // Date range + user filter live together: every fetch keys off all three, so + // a single object keeps them in sync and trims the dependency arrays. + const [queryParams, setQueryParams] = useState<{ + start: string; + end: string; + selectedUsers: number[]; + }>({ + start: dayjs().subtract(29, 'day').format('YYYY-MM-DD'), + end: dayjs().format('YYYY-MM-DD'), + selectedUsers: [] + }); + const { start, end, selectedUsers } = queryParams; const { creators: userOptions } = useResourceMeta(scope); - const [summary, setSummary] = useState(null); - const [tokenSeries, setTokenSeries] = useState([]); - const [computeByDate, setComputeByDate] = - useState(null); - const [storageByDate, setStorageByDate] = - useState(null); - const [storageByType, setStorageByType] = - useState(null); + const { + detailData: summary, + loading: summaryLoading, + fetchData: fetchSummary + } = useQueryUsageSummary(); + const { + detailData: tokenSeriesData, + loading: tokenSeriesLoading, + fetchData: fetchTokenSeries + } = useQueryTimeSeriesData(); + const { + detailData: computeByDate, + loading: computeLoading, + fetchData: fetchComputeBreakdown + } = useQueryResourceBreakdown(); + const { + detailData: storageByDate, + loading: storageByDateLoading, + fetchData: fetchStorageByDate + } = useQueryStorageBreakdown({ key: 'storageByDate' }); + const { + detailData: storageByType, + loading: storageByTypeLoading, + fetchData: fetchStorageByType + } = useQueryStorageBreakdown({ key: 'storageByType' }); - const start = dateRange[0].format('YYYY-MM-DD'); - const end = dateRange[1].format('YYYY-MM-DD'); - // Token usage (model_usages) is a daily rollup; the Summary trends are fixed - // to ``day`` anyway, so the token trend shares the same granularity. - const tokenGran: Granularity = granularity; + const fetchAll = async (params?: Partial) => { + const currentParams = { + ...queryParams, + ...params + }; - // "filter by user" — restricts every resource fetch to these creator ids. - const creatorFilter = selectedUsers.length - ? { creator_ids: selectedUsers } - : undefined; - - // Date+scope scoped fetches (granularity-independent). - useEffect(() => { - queryUsageSummary({ - start_date: start, - end_date: end, - scope, - creator_ids: selectedUsers.length ? selectedUsers : undefined - }) - .then(setSummary) - .catch(() => {}); - queryStorageBreakdown({ - start_date: start, - end_date: end, - scope, - group_by: ['type'], - filters: creatorFilter, + const commonParams = { + start_date: currentParams.start, + end_date: currentParams.end, + scope + }; + const paginationParams = { + ...commonParams, page: 1, perPage: 100 - }) - .then(setStorageByType) - .catch(() => {}); - }, [start, end, scope, selectedUsers, refreshKey]); + }; - // Trend fetches (depend on granularity too). - useEffect(() => { - queryUsageTimeSeriesData({ - start_date: start, - end_date: end, - scope, - metric: 'total_tokens', - group_by: ['date'], - granularity: tokenGran, - filters: {} - }) - .then((res) => setTokenSeries(res.items || [])) - .catch(() => {}); - queryResourceBreakdown({ - start_date: start, - end_date: end, - scope, - group_by: ['date'], - granularity, - filters: creatorFilter, - page: 1, - perPage: 100 - }) - .then(setComputeByDate) - .catch(() => {}); - queryStorageBreakdown({ - start_date: start, - end_date: end, - scope, - group_by: ['date'], - granularity, - filters: creatorFilter, - page: 1, - perPage: 100 - }) - .then(setStorageByDate) - .catch(() => {}); - }, [start, end, scope, granularity, tokenGran, selectedUsers, refreshKey]); + // "filter by user" — restricts every resource fetch to these creator ids. + const creatorFilter = currentParams.selectedUsers.length + ? { creator_ids: currentParams.selectedUsers } + : undefined; + + await Promise.all([ + fetchSummary({ + ...commonParams, + creator_ids: currentParams.selectedUsers.length + ? currentParams.selectedUsers + : undefined + }), + + fetchStorageByType({ + ...paginationParams, + group_by: ['type'], + filters: creatorFilter + }), + + fetchTokenSeries({ + ...commonParams, + metric: 'total_tokens', + group_by: ['date'], + granularity, + filters: {} + }), + + fetchComputeBreakdown({ + ...paginationParams, + group_by: ['date'], + granularity, + filters: creatorFilter + }), + + fetchStorageByDate({ + ...paginationParams, + group_by: ['date'], + granularity, + filters: creatorFilter + }) + ]); + setQueryParams(currentParams); + }; // --- derived: donuts --- const tokenDonut = useMemo( @@ -337,16 +345,17 @@ const SummaryTab: React.FC = () => { const tokenTrend = useMemo( () => buildTrend( - tokenSeries.map((it) => ({ + (tokenSeriesData?.items || []).map((it: any) => ({ date: it?.date?.value, value: Number(it?.total_tokens ?? 0) })), - tokenGran, + granularity, start, end ), - [tokenSeries, tokenGran, start, end] + [tokenSeriesData, granularity, start, end] ); + const computeTrend = useMemo( () => buildTrend( @@ -360,6 +369,7 @@ const SummaryTab: React.FC = () => { ), [computeByDate, granularity, start, end] ); + const storageTrend = useMemo( () => buildTrend( @@ -377,16 +387,33 @@ const SummaryTab: React.FC = () => { const computeSum = computeByDate?.summary; const storageSum = storageByDate?.summary; + const handleDateRangeChange = (dates: [dayjs.Dayjs, dayjs.Dayjs]) => { + fetchAll({ + start: dates[0].format('YYYY-MM-DD'), + end: dates[1].format('YYYY-MM-DD') + }); + }; + + const handleUserFilterChange = (users: number[]) => { + fetchAll({ selectedUsers: users }); + }; + + const onRefresh = () => { + fetchAll(); + }; + + useRequest(fetchAll); + return (
setDateRange(dates)} + value={[dayjs(start), dayjs(end)]} + onChange={handleDateRangeChange} canManageUsers={canManageUsers} userOptions={userOptions} selectedUsers={selectedUsers} - onUsersChange={setSelectedUsers} - onRefresh={() => setRefreshKey((k) => k + 1)} + onUsersChange={handleUserFilterChange} + onRefresh={onRefresh} />
@@ -400,8 +427,10 @@ const SummaryTab: React.FC = () => { trendTitle={t('usage.summary.tokensOverTime')} trendXAxis={tokenTrend.xAxis} trendData={tokenTrend.data} + pieLoading={summaryLoading} + barLoading={tokenSeriesLoading} trendColor={coolColors[0]} - trendGran={tokenGran} + trendGran={granularity} headline={ <> { trendData={computeTrend.data} trendColor={coolColors[1]} trendGran={granularity} + pieLoading={summaryLoading} + barLoading={computeLoading} headline={ <> { trendData={storageTrend.data} trendColor={coolColors[3]} trendGran={granularity} + pieLoading={storageByTypeLoading} + barLoading={storageByDateLoading} headline={ <> ({ + fetchDetail: queryResourceBreakdown, + key: option?.key || 'resourceBreakdown' + }); + return { + detailData, + loading, + cancelRequest, + fetchData + }; +} diff --git a/src/pages/usage/summary-tab/services/use-query-storage-breakdown.ts b/src/pages/usage/summary-tab/services/use-query-storage-breakdown.ts new file mode 100644 index 00000000..8eda9b06 --- /dev/null +++ b/src/pages/usage/summary-tab/services/use-query-storage-breakdown.ts @@ -0,0 +1,19 @@ +import { useQueryData } from '@/hooks/use-query-data-list'; +import { + queryStorageBreakdown, + ResourceBreakdownResponse +} from '../../apis/resource'; + +export default function useQueryStorageBreakdown(option?: { key?: string }) { + const { detailData, loading, cancelRequest, fetchData } = + useQueryData({ + fetchDetail: queryStorageBreakdown, + key: option?.key || 'storageBreakdown' + }); + return { + detailData, + loading, + cancelRequest, + fetchData + }; +} diff --git a/src/pages/usage/summary-tab/services/use-query-timeseries-data.ts b/src/pages/usage/summary-tab/services/use-query-timeseries-data.ts new file mode 100644 index 00000000..2c8fc366 --- /dev/null +++ b/src/pages/usage/summary-tab/services/use-query-timeseries-data.ts @@ -0,0 +1,17 @@ +import { useQueryData } from '@/hooks/use-query-data-list'; +import { queryUsageTimeSeriesData } from '../../apis'; +import { UsageBreakdownResponse } from '../../config/types'; + +export default function useQueryTimeSeriesData(option?: { key?: string }) { + const { detailData, loading, cancelRequest, fetchData } = + useQueryData({ + fetchDetail: queryUsageTimeSeriesData, + key: option?.key || 'tokenTimeSeries' + }); + return { + detailData, + loading, + cancelRequest, + fetchData + }; +} diff --git a/src/pages/usage/summary-tab/services/use-query-usage-summary.ts b/src/pages/usage/summary-tab/services/use-query-usage-summary.ts new file mode 100644 index 00000000..e1bdc489 --- /dev/null +++ b/src/pages/usage/summary-tab/services/use-query-usage-summary.ts @@ -0,0 +1,16 @@ +import { useQueryData } from '@/hooks/use-query-data-list'; +import { queryUsageSummary, UsageSummaryResponse } from '../../apis/resource'; + +export default function useQueryUsageSummary(option?: { key?: string }) { + const { detailData, loading, cancelRequest, fetchData } = + useQueryData({ + fetchDetail: queryUsageSummary, + key: option?.key || 'usageSummary' + }); + return { + detailData, + loading, + cancelRequest, + fetchData + }; +}