refactor(usagesummary): fetch data

This commit is contained in:
jialin
2026-06-14 19:14:18 +08:00
committed by jialin
parent 6cf512cccd
commit 08ef2ffa9a
7 changed files with 281 additions and 149 deletions
+7 -3
View File
@@ -18,6 +18,7 @@ export function useQueryDataList<
Response = Array<ListItem> Response = Array<ListItem>
>(option: { >(option: {
key: string; key: string;
manual?: boolean;
responseType?: 'array' | 'object'; responseType?: 'array' | 'object';
fetchList: ( fetchList: (
params: Params, params: Params,
@@ -38,6 +39,7 @@ export function useQueryDataList<
fetchList, fetchList,
getLabel, getLabel,
getValue, getValue,
manual = true,
responseType = 'array', responseType = 'array',
errorMsg errorMsg
} = option; } = option;
@@ -70,7 +72,7 @@ export function useQueryDataList<
return responseType === 'array' ? res.items || [] : res; return responseType === 'array' ? res.items || [] : res;
}, },
{ {
manual: true, manual: manual,
debounceWait: option.debounceWait || 300, debounceWait: option.debounceWait || 300,
onSuccess: () => {}, onSuccess: () => {},
onError: (error) => { onError: (error) => {
@@ -105,16 +107,18 @@ export function useQueryDataList<
export function useQueryData<Detail, Params = any>(option: { export function useQueryData<Detail, Params = any>(option: {
key: string; key: string;
delay?: number; delay?: number;
manual?: boolean;
fetchDetail: (params: Params, options?: any) => Promise<Detail>; fetchDetail: (params: Params, options?: any) => Promise<Detail>;
getData?: (response: Detail, params?: any) => any; getData?: (response: Detail, params?: any) => any;
errorMsg?: string; errorMsg?: string;
}): { }): {
loading: boolean; loading: boolean;
detailData: Detail; detailData: Detail;
manual?: boolean;
cancelRequest: () => void; cancelRequest: () => void;
fetchData: (params: Params, extra?: any) => Promise<Detail>; fetchData: (params: Params, extra?: any) => Promise<Detail>;
} { } {
const { key, fetchDetail, getData, errorMsg, delay } = option; const { key, fetchDetail, getData, errorMsg, delay, manual = true } = option;
const axiosTokenRef = useRef<CancelTokenSource | null>(null); const axiosTokenRef = useRef<CancelTokenSource | null>(null);
const [detailData, setDetailData] = useState<Detail>({} as Detail); const [detailData, setDetailData] = useState<Detail>({} as Detail);
@@ -142,7 +146,7 @@ export function useQueryData<Detail, Params = any>(option: {
return res; return res;
}, },
{ {
manual: true, manual: manual,
onSuccess: () => {}, onSuccess: () => {},
onError: (error) => { onError: (error) => {
message.error( message.error(
+32 -8
View File
@@ -1,7 +1,7 @@
import useCoolColors from '@/hooks/use-cool-colors'; import useCoolColors from '@/hooks/use-cool-colors';
import { Chart } from '@gpustack/core-ui'; import { Chart } from '@gpustack/core-ui';
import { formatLargeNumber } from '@gpustack/core-ui/utils'; import { formatLargeNumber } from '@gpustack/core-ui/utils';
import { Empty, theme } from 'antd'; import { Empty, Spin, theme } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React, { useEffect, useMemo, useRef } from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
@@ -17,6 +17,7 @@ export interface BarChartProps {
xAxisData: string[]; xAxisData: string[];
height: number | string; height: number | string;
width?: number | string; width?: number | string;
loading?: boolean;
legendData?: { name: string; icon?: string }[]; legendData?: { name: string; icon?: string }[];
labelFormatter?: (val: any, index?: number) => string; labelFormatter?: (val: any, index?: number) => string;
tooltipValueFormatter?: (val: any) => string; tooltipValueFormatter?: (val: any) => string;
@@ -38,6 +39,7 @@ const BarChart: React.FC<BarChartProps> = (props) => {
height, height,
width, width,
legendData, legendData,
loading,
labelFormatter, labelFormatter,
tooltipValueFormatter, tooltipValueFormatter,
title, title,
@@ -277,18 +279,40 @@ const BarChart: React.FC<BarChartProps> = (props) => {
justifyContent: 'center' justifyContent: 'center'
}} }}
> >
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} /> {loading ? (
<Spin size="middle" />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div> </div>
); );
} }
return ( return (
<Chart <div style={{ width, height, position: 'relative' }}>
ref={chartRef as any} <Chart
options={options as any} ref={chartRef as any}
height={height} options={options as any}
width={width || '100%'} height={height}
/> width={width || '100%'}
/>
{loading && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--ant-color-bg-container)',
opacity: 0.6,
pointerEvents: 'none'
}}
>
<Spin size="middle" />
</div>
)}
</div>
); );
}; };
+171 -138
View File
@@ -27,17 +27,10 @@ import PieChart from '@/pages/_components/pie-chart';
import { formatLargeNumber } from '@/utils'; import { formatLargeNumber } from '@/utils';
import { CardWrapper } from '@gpustack/core-ui'; import { CardWrapper } from '@gpustack/core-ui';
import { useAccess, useIntl } from '@umijs/max'; 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 dayjs from 'dayjs';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { queryUsageTimeSeriesData } from '../apis';
import {
queryResourceBreakdown,
queryStorageBreakdown,
queryUsageSummary,
ResourceBreakdownResponse,
UsageSummaryResponse
} from '../apis/resource';
import ResourceFilterBar from '../components/resource-filter-bar'; import ResourceFilterBar from '../components/resource-filter-bar';
import useResourceMeta from '../hooks/use-resource-meta'; import useResourceMeta from '../hooks/use-resource-meta';
import { import {
@@ -45,9 +38,19 @@ import {
generateBucketRange, generateBucketRange,
Granularity Granularity
} from '../utils/time-buckets'; } 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 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). // 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 round2 = (n?: number) => Math.round((Number(n) || 0) * 100) / 100;
const fmt = (n?: number) => formatLargeNumber(round2(n)); 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 // row, then a donut (left, legend hugging it) beside a trend (right) that
// carries its own chart title — no wasteful caption rows. // carries its own chart title — no wasteful caption rows.
const DomainSection: React.FC<{ const DomainSection: React.FC<{
pieLoading?: boolean;
barLoading?: boolean;
title: string; title: string;
accent: string; accent: string;
headline: React.ReactNode; headline: React.ReactNode;
@@ -107,6 +112,8 @@ const DomainSection: React.FC<{
trendColor: string; trendColor: string;
trendGran: Granularity; trendGran: Granularity;
}> = ({ }> = ({
pieLoading,
barLoading,
title, title,
accent, accent,
headline, headline,
@@ -118,16 +125,14 @@ const DomainSection: React.FC<{
trendColor, trendColor,
trendGran trendGran
}) => { }) => {
const intl = useIntl();
const donutTotal = round2(donutData.reduce((s, d) => s + (d.value || 0), 0)); const donutTotal = round2(donutData.reduce((s, d) => s + (d.value || 0), 0));
const trendEmpty = trendData.every((v) => !v);
const empty = ( const seriesData = useMemo(() => {
<Empty return [{ name: trendTitle, data: trendData, color: trendColor }].filter(
image={Empty.PRESENTED_IMAGE_SIMPLE} (s) => s.data.some((v) => !!v)
description={intl.formatMessage({ id: 'usage.common.noData' })} );
style={{ margin: '32px 0' }} }, [trendData, trendColor]);
/>
);
return ( return (
<CardWrapper style={{ paddingBlock: 16 }}> <CardWrapper style={{ paddingBlock: 16 }}>
<div <div
@@ -166,37 +171,29 @@ const DomainSection: React.FC<{
}} }}
> >
<div style={{ width: 340, maxWidth: '100%', flexShrink: 0 }}> <div style={{ width: 340, maxWidth: '100%', flexShrink: 0 }}>
{donutTotal <= 0 ? ( <PieChart
empty loading={pieLoading}
) : ( data={donutData}
<PieChart height={180}
data={donutData} total={donutTotal}
height={180} totalLabel={donutTotalLabel}
total={donutTotal} />
totalLabel={donutTotalLabel}
/>
)}
</div> </div>
<div style={{ flex: 1, minWidth: 260 }}> <div style={{ flex: 1, minWidth: 260 }}>
{trendEmpty ? ( <BarChart
empty loading={barLoading}
) : ( seriesData={seriesData}
<BarChart xAxisData={trendXAxis}
seriesData={[ height={180}
{ name: trendTitle, data: trendData, color: trendColor } grid={{
]} bottom: 0
xAxisData={trendXAxis} }}
height={180} title={trendTitle}
grid={{ labelFormatter={trendLabel(trendGran)}
bottom: 0 tooltipValueFormatter={(v) =>
}} formatLargeNumber(round2(Number(v))) as string
title={trendTitle} }
labelFormatter={trendLabel(trendGran)} />
tooltipValueFormatter={(v) =>
formatLargeNumber(round2(Number(v))) as string
}
/>
)}
</div> </div>
</div> </div>
</CardWrapper> </CardWrapper>
@@ -216,95 +213,106 @@ const SummaryTab: React.FC = () => {
// Summary trends are fixed to a daily granularity (no granularity control). // Summary trends are fixed to a daily granularity (no granularity control).
const granularity: Granularity = 'day'; const granularity: Granularity = 'day';
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([ // Date range + user filter live together: every fetch keys off all three, so
dayjs().subtract(29, 'day'), // a single object keeps them in sync and trims the dependency arrays.
dayjs() const [queryParams, setQueryParams] = useState<{
]); start: string;
const [selectedUsers, setSelectedUsers] = useState<number[]>([]); end: string;
const [refreshKey, setRefreshKey] = useState(0); 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 { creators: userOptions } = useResourceMeta(scope);
const [summary, setSummary] = useState<UsageSummaryResponse | null>(null); const {
const [tokenSeries, setTokenSeries] = useState<any[]>([]); detailData: summary,
const [computeByDate, setComputeByDate] = loading: summaryLoading,
useState<ResourceBreakdownResponse | null>(null); fetchData: fetchSummary
const [storageByDate, setStorageByDate] = } = useQueryUsageSummary();
useState<ResourceBreakdownResponse | null>(null); const {
const [storageByType, setStorageByType] = detailData: tokenSeriesData,
useState<ResourceBreakdownResponse | null>(null); 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 fetchAll = async (params?: Partial<QueryParams>) => {
const end = dateRange[1].format('YYYY-MM-DD'); const currentParams = {
// Token usage (model_usages) is a daily rollup; the Summary trends are fixed ...queryParams,
// to ``day`` anyway, so the token trend shares the same granularity. ...params
const tokenGran: Granularity = granularity; };
// "filter by user" — restricts every resource fetch to these creator ids. const commonParams = {
const creatorFilter = selectedUsers.length start_date: currentParams.start,
? { creator_ids: selectedUsers } end_date: currentParams.end,
: undefined; scope
};
// Date+scope scoped fetches (granularity-independent). const paginationParams = {
useEffect(() => { ...commonParams,
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,
page: 1, page: 1,
perPage: 100 perPage: 100
}) };
.then(setStorageByType)
.catch(() => {});
}, [start, end, scope, selectedUsers, refreshKey]);
// Trend fetches (depend on granularity too). // "filter by user" — restricts every resource fetch to these creator ids.
useEffect(() => { const creatorFilter = currentParams.selectedUsers.length
queryUsageTimeSeriesData({ ? { creator_ids: currentParams.selectedUsers }
start_date: start, : undefined;
end_date: end,
scope, await Promise.all([
metric: 'total_tokens', fetchSummary({
group_by: ['date'], ...commonParams,
granularity: tokenGran, creator_ids: currentParams.selectedUsers.length
filters: {} ? currentParams.selectedUsers
}) : undefined
.then((res) => setTokenSeries(res.items || [])) }),
.catch(() => {});
queryResourceBreakdown({ fetchStorageByType({
start_date: start, ...paginationParams,
end_date: end, group_by: ['type'],
scope, filters: creatorFilter
group_by: ['date'], }),
granularity,
filters: creatorFilter, fetchTokenSeries({
page: 1, ...commonParams,
perPage: 100 metric: 'total_tokens',
}) group_by: ['date'],
.then(setComputeByDate) granularity,
.catch(() => {}); filters: {}
queryStorageBreakdown({ }),
start_date: start,
end_date: end, fetchComputeBreakdown({
scope, ...paginationParams,
group_by: ['date'], group_by: ['date'],
granularity, granularity,
filters: creatorFilter, filters: creatorFilter
page: 1, }),
perPage: 100
}) fetchStorageByDate({
.then(setStorageByDate) ...paginationParams,
.catch(() => {}); group_by: ['date'],
}, [start, end, scope, granularity, tokenGran, selectedUsers, refreshKey]); granularity,
filters: creatorFilter
})
]);
setQueryParams(currentParams);
};
// --- derived: donuts --- // --- derived: donuts ---
const tokenDonut = useMemo( const tokenDonut = useMemo(
@@ -337,16 +345,17 @@ const SummaryTab: React.FC = () => {
const tokenTrend = useMemo( const tokenTrend = useMemo(
() => () =>
buildTrend( buildTrend(
tokenSeries.map((it) => ({ (tokenSeriesData?.items || []).map((it: any) => ({
date: it?.date?.value, date: it?.date?.value,
value: Number(it?.total_tokens ?? 0) value: Number(it?.total_tokens ?? 0)
})), })),
tokenGran, granularity,
start, start,
end end
), ),
[tokenSeries, tokenGran, start, end] [tokenSeriesData, granularity, start, end]
); );
const computeTrend = useMemo( const computeTrend = useMemo(
() => () =>
buildTrend( buildTrend(
@@ -360,6 +369,7 @@ const SummaryTab: React.FC = () => {
), ),
[computeByDate, granularity, start, end] [computeByDate, granularity, start, end]
); );
const storageTrend = useMemo( const storageTrend = useMemo(
() => () =>
buildTrend( buildTrend(
@@ -377,16 +387,33 @@ const SummaryTab: React.FC = () => {
const computeSum = computeByDate?.summary; const computeSum = computeByDate?.summary;
const storageSum = storageByDate?.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 ( return (
<div> <div>
<ResourceFilterBar <ResourceFilterBar
value={dateRange} value={[dayjs(start), dayjs(end)]}
onChange={(dates) => setDateRange(dates)} onChange={handleDateRangeChange}
canManageUsers={canManageUsers} canManageUsers={canManageUsers}
userOptions={userOptions} userOptions={userOptions}
selectedUsers={selectedUsers} selectedUsers={selectedUsers}
onUsersChange={setSelectedUsers} onUsersChange={handleUserFilterChange}
onRefresh={() => setRefreshKey((k) => k + 1)} onRefresh={onRefresh}
/> />
<div style={{ height: 24 }} /> <div style={{ height: 24 }} />
@@ -400,8 +427,10 @@ const SummaryTab: React.FC = () => {
trendTitle={t('usage.summary.tokensOverTime')} trendTitle={t('usage.summary.tokensOverTime')}
trendXAxis={tokenTrend.xAxis} trendXAxis={tokenTrend.xAxis}
trendData={tokenTrend.data} trendData={tokenTrend.data}
pieLoading={summaryLoading}
barLoading={tokenSeriesLoading}
trendColor={coolColors[0]} trendColor={coolColors[0]}
trendGran={tokenGran} trendGran={granularity}
headline={ headline={
<> <>
<Stat <Stat
@@ -436,6 +465,8 @@ const SummaryTab: React.FC = () => {
trendData={computeTrend.data} trendData={computeTrend.data}
trendColor={coolColors[1]} trendColor={coolColors[1]}
trendGran={granularity} trendGran={granularity}
pieLoading={summaryLoading}
barLoading={computeLoading}
headline={ headline={
<> <>
<Stat <Stat
@@ -466,6 +497,8 @@ const SummaryTab: React.FC = () => {
trendData={storageTrend.data} trendData={storageTrend.data}
trendColor={coolColors[3]} trendColor={coolColors[3]}
trendGran={granularity} trendGran={granularity}
pieLoading={storageByTypeLoading}
barLoading={storageByDateLoading}
headline={ headline={
<> <>
<Stat <Stat
@@ -0,0 +1,19 @@
import { useQueryData } from '@/hooks/use-query-data-list';
import {
queryResourceBreakdown,
ResourceBreakdownResponse
} from '../../apis/resource';
export default function useQueryResourceBreakdown(option?: { key?: string }) {
const { detailData, loading, cancelRequest, fetchData } =
useQueryData<ResourceBreakdownResponse>({
fetchDetail: queryResourceBreakdown,
key: option?.key || 'resourceBreakdown'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -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<ResourceBreakdownResponse>({
fetchDetail: queryStorageBreakdown,
key: option?.key || 'storageBreakdown'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -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<UsageBreakdownResponse>({
fetchDetail: queryUsageTimeSeriesData,
key: option?.key || 'tokenTimeSeries'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -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<UsageSummaryResponse>({
fetchDetail: queryUsageSummary,
key: option?.key || 'usageSummary'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}