refactor: dashboard usage

This commit is contained in:
jialin
2026-05-06 12:27:22 +08:00
committed by jialin
parent 705b33e307
commit 898c7f1be2
23 changed files with 901 additions and 35 deletions
@@ -1,9 +1,11 @@
import { activeModelsAtom } from '@/atoms/dashboard';
import { modelCategoriesMap } from '@/pages/llmodels/config';
import { convertFileSize } from '@/utils';
import { AutoTooltip, PageTools } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Col, Row, Table } from 'antd';
import { useContext } from 'react';
import { useSetAtom } from 'jotai';
import { useContext, useEffect } from 'react';
import { DashboardContext } from '../config/dashboard-context';
const NACategories = [
modelCategoriesMap.llm,
@@ -14,6 +16,12 @@ const NACategories = [
const ActiveTable = () => {
const intl = useIntl();
const data = useContext(DashboardContext).active_models || [];
const setActiveModels = useSetAtom(activeModelsAtom);
useEffect(() => {
setActiveModels(data);
}, [data, setActiveModels]);
const modelColumns = [
{
title: intl.formatMessage({ id: 'common.table.name' }),
@@ -0,0 +1,157 @@
import { activeModelsAtom } from '@/atoms/dashboard';
import useQueryBreakdownList from '@/pages/usage/services/use-query-breakdown-list';
import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data';
import { formatLargeNumber } from '@/utils';
import { PageTools } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Col, Row } from 'antd';
import dayjs from 'dayjs';
import { useAtomValue } from 'jotai';
import { useContext, useEffect, useMemo } from 'react';
import styled from 'styled-components';
import {
baseColorMap,
DashboardUsageCommonParams,
getUsageSummary
} from '../config';
import { DashboardContext } from '../config/dashboard-context';
import ApiRequestsByModel from './usage-charts/api-requests-by-model';
import TokenUsageByModel from './usage-charts/token-usage-by-model';
import TopTokenUsageByApiKey from './usage-charts/top-token-usage-by-api-key';
import TopTokenUsageByUser from './usage-charts/top-token-usage-by-user';
const Section = styled.div`
margin-top: 16px;
`;
const NewUsage = () => {
const intl = useIntl();
const activeModelsFromAtom = useAtomValue(activeModelsAtom);
const { active_models: activeModelsFromContext = [] } =
useContext(DashboardContext);
const tokenByModelQuery = useQueryTimeSeriesData({
key: 'tokenUsageByModelData'
});
const activeUsersQuery = useQueryBreakdownList({
key: 'dashboardActiveUsersData'
});
const dateRange = useMemo(
() => ({
start_date: dayjs().subtract(29, 'days').format('YYYY-MM-DD'),
end_date: dayjs().format('YYYY-MM-DD')
}),
[]
);
const commonParams = useMemo<DashboardUsageCommonParams>(
() => ({
...dateRange,
scope: 'all',
granularity: 'day',
filters: {}
}),
[dateRange]
);
const activeModels = activeModelsFromAtom.length
? activeModelsFromAtom
: activeModelsFromContext;
useEffect(() => {
const fetchData = async () => {
await Promise.all([
tokenByModelQuery.fetchData({
...commonParams,
metric: 'total_tokens',
group_by: ['date', 'model'],
sort_by: '-total_tokens'
}),
activeUsersQuery.fetchData({
...dateRange,
scope: 'all',
group_by: ['user'],
page: 1,
perPage: 10,
sort_by: '-total_tokens',
filters: {}
})
]);
};
fetchData().catch(() => {});
}, [commonParams, dateRange]);
const summaryCards = useMemo(() => {
const summary = getUsageSummary(tokenByModelQuery.detailData) || {
total_tokens: 0,
api_requests: 0
};
return [
{
label: formatLargeNumber(summary.total_tokens) as string,
value: intl.formatMessage({ id: 'dashboard.tokens' }),
color: baseColorMap.base
},
{
label: formatLargeNumber(summary.api_requests) as string,
value: intl.formatMessage({ id: 'dashboard.apirequest' }),
color: baseColorMap.baseR1
},
{
label: formatLargeNumber(activeModels.length) as string,
value: intl.formatMessage({ id: 'dashboard.activeModels' }),
color: baseColorMap.baseR2
},
{
label: formatLargeNumber(activeUsersQuery.dataSource.total) as string,
value: intl.formatMessage({ id: 'dashboard.activeUsers' }),
color: baseColorMap.baseR3
}
];
}, [
activeModels.length,
activeUsersQuery.dataSource.total,
intl,
tokenByModelQuery.detailData
]);
return (
<>
<PageTools
style={{ margin: '24px 0 0' }}
left={
<span className="font-700">
{intl.formatMessage({ id: 'dashboard.usage' })}
</span>
}
/>
<Section>
<Row gutter={[20, 20]}>
<Col xs={24} sm={24} md={24} lg={12}>
<TokenUsageByModel
data={tokenByModelQuery.detailData}
loading={tokenByModelQuery.loading}
/>
</Col>
<Col xs={24} sm={24} md={24} lg={12}>
<ApiRequestsByModel commonParams={commonParams} />
</Col>
</Row>
</Section>
<Section>
<Row gutter={[20, 20]}>
<Col xs={24} sm={24} md={24} lg={12}>
<TopTokenUsageByUser commonParams={commonParams} height={450} />
</Col>
<Col xs={24} sm={24} md={24} lg={12}>
<TopTokenUsageByApiKey commonParams={commonParams} height={450} />
</Col>
</Row>
</Section>
</>
);
};
export default NewUsage;
@@ -0,0 +1,63 @@
import PieChart from '@/pages/_components/pie-chart';
import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data';
import { useIntl } from '@umijs/max';
import { useEffect, useMemo } from 'react';
import {
DashboardUsageCommonParams,
getUsageSummary,
toUsagePieData,
usageChartHeight
} from '../../config';
import UsageChartCard from './shared';
interface ApiRequestsByModelProps {
commonParams: DashboardUsageCommonParams;
}
const ApiRequestsByModel: React.FC<ApiRequestsByModelProps> = ({
commonParams
}) => {
const intl = useIntl();
const query = useQueryTimeSeriesData({
key: 'apiRequestsByModelData'
});
useEffect(() => {
query
.fetchData({
...commonParams,
metric: 'api_requests',
group_by: ['date', 'model'],
page: 1,
perPage: 10,
sort_by: '-api_requests'
})
.catch(() => {});
}, [commonParams]);
const chartData = useMemo(
() => toUsagePieData(query.detailData, 'model', 'api_requests'),
[query.detailData]
);
const total = useMemo(() => {
const summary = getUsageSummary(query.detailData);
return Number(summary?.api_requests ?? 0);
}, [query.detailData]);
return (
<UsageChartCard
title={intl.formatMessage({ id: 'dashboard.apiRequestsByModel' })}
>
<PieChart
data={chartData}
height={usageChartHeight}
loading={query.loading}
colorOffset={1}
total={total}
/>
</UsageChartCard>
);
};
export default ApiRequestsByModel;
@@ -0,0 +1,42 @@
import { CardWrapper } from '@gpustack/core-ui';
import { Spin } from 'antd';
import React from 'react';
import styled from 'styled-components';
import { usageChartCardHeight, usageChartHeight } from '../../config';
export const ChartTitle = styled.div`
font-weight: var(--font-weight-bold);
margin-bottom: 12px;
`;
const UsageChartCard: React.FC<{
title: React.ReactNode;
children: React.ReactNode;
height?: number | string;
}> = ({ title, children, height = usageChartCardHeight }) => {
return (
<CardWrapper style={{ height }}>
<ChartTitle>{title}</ChartTitle>
{children}
</CardWrapper>
);
};
export default UsageChartCard;
export const ChartLoadingBox: React.FC<{ height?: number | string }> = ({
height = usageChartHeight
}) => {
return (
<div
style={{
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Spin size="middle" />
</div>
);
};
@@ -0,0 +1,48 @@
import PieChart from '@/pages/_components/pie-chart';
import { UsageBreakdownResponse } from '@/pages/usage/config/types';
import { useIntl } from '@umijs/max';
import { useMemo } from 'react';
import {
getUsageSummary,
toUsagePieData,
usageChartHeight
} from '../../config';
import UsageChartCard from './shared';
interface TokenUsageByModelProps {
data: UsageBreakdownResponse | null | undefined;
loading?: boolean;
}
const TokenUsageByModel: React.FC<TokenUsageByModelProps> = ({
data,
loading = false
}) => {
const intl = useIntl();
const chartData = useMemo(
() => toUsagePieData(data, 'model', 'total_tokens'),
[data]
);
const total = useMemo(() => {
const summary = getUsageSummary(data);
return Number(summary?.total_tokens ?? 0);
}, [data]);
return (
<UsageChartCard
title={intl.formatMessage({ id: 'dashboard.tokenUsageByModel' })}
>
<PieChart
data={chartData}
height={usageChartHeight}
loading={loading}
colorOffset={3}
total={total}
/>
</UsageChartCard>
);
};
export default TokenUsageByModel;
@@ -0,0 +1,76 @@
import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data';
import { HBarChart } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useEffect, useMemo } from 'react';
import {
baseColorMap,
DashboardUsageCommonParams,
toUsageRankData,
usageChartHeight
} from '../../config';
import UsageChartCard, { ChartLoadingBox } from './shared';
interface TopTokenUsageByApiKeyProps {
commonParams: DashboardUsageCommonParams;
height?: number;
}
const TopTokenUsageByApiKey: React.FC<TopTokenUsageByApiKeyProps> = ({
commonParams,
height = usageChartHeight
}) => {
const intl = useIntl();
const query = useQueryTimeSeriesData({
key: 'topTokenUsageByApiKeyData'
});
const tokenUsageText = intl.formatMessage({ id: 'dashboard.tokens' });
useEffect(() => {
query
.fetchData({
...commonParams,
metric: 'total_tokens',
group_by: ['date', 'api_key'],
page: 1,
perPage: 10,
sort_by: '-total_tokens'
})
.catch(() => {});
}, [commonParams]);
const rankData = useMemo(() => {
const data = toUsageRankData(
query.detailData,
'api_key',
tokenUsageText,
baseColorMap.baseR3
);
return {
names: data.names.filter((name) => name !== '-'),
series: data.series.map((item) => ({
...item,
data: item.data.filter((point) => point.name !== '-')
}))
};
}, [query.detailData, tokenUsageText]);
return (
<UsageChartCard
title={intl.formatMessage({ id: 'dashboard.topTokenUsageByApiKey' })}
height={height + 52}
>
{query.loading && rankData.names.length === 0 ? (
<ChartLoadingBox height={height} />
) : (
<HBarChart
seriesData={rankData.series}
xAxisData={rankData.names}
height={height}
maxItems={10}
/>
)}
</UsageChartCard>
);
};
export default TopTokenUsageByApiKey;
@@ -0,0 +1,71 @@
import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data';
import { HBarChart } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useEffect, useMemo } from 'react';
import {
baseColorMap,
DashboardUsageCommonParams,
toUsageRankData,
usageChartHeight
} from '../../config';
import UsageChartCard, { ChartLoadingBox } from './shared';
interface TopTokenUsageByUserProps {
commonParams: DashboardUsageCommonParams;
height?: number;
}
const TopTokenUsageByUser: React.FC<TopTokenUsageByUserProps> = ({
commonParams,
height = usageChartHeight
}) => {
const intl = useIntl();
const query = useQueryTimeSeriesData({
key: 'topTokenUsageByUserData'
});
const tokenUsageText = intl.formatMessage({ id: 'dashboard.tokens' });
useEffect(() => {
query
.fetchData({
...commonParams,
metric: 'total_tokens',
group_by: ['date', 'user'],
page: 1,
perPage: 10,
sort_by: '-total_tokens'
})
.catch(() => {});
}, [commonParams]);
const rankData = useMemo(
() =>
toUsageRankData(
query.detailData,
'user',
tokenUsageText,
baseColorMap.base
),
[query.detailData, tokenUsageText]
);
return (
<UsageChartCard
title={intl.formatMessage({ id: 'dashboard.topTokenUsageByUser' })}
height={height + 52}
>
{query.loading && rankData.names.length === 0 ? (
<ChartLoadingBox height={height} />
) : (
<HBarChart
seriesData={rankData.series}
xAxisData={rankData.names}
height={height}
maxItems={10}
/>
)}
</UsageChartCard>
);
};
export default TopTokenUsageByUser;
+2 -13
View File
@@ -1,18 +1,7 @@
import breakpoints from '@/config/breakpoints';
import useWindowResize from '@/hooks/use-window-resize';
import { useEffect, useState } from 'react';
import UserInner from './usage-inner';
import NewUsage from './new-usage';
const Usage = () => {
const {
size: { width }
} = useWindowResize();
const [maxWdith, setMaxWidth] = useState<number>(breakpoints.xl);
useEffect(() => {
setMaxWidth(width);
}, [width]);
return <UserInner maxWidth={maxWdith}></UserInner>;
return <NewUsage />;
};
export default Usage;
+189
View File
@@ -1,3 +1,8 @@
import {
BreakdownItem,
UsageBreakdownResponse
} from '@/pages/usage/config/types';
export const overviewConfigs = [
{
key: 'cluster_count',
@@ -35,3 +40,187 @@ export const baseColorMap = {
baseR2: 'rgba(48,0,255,0.8)',
baseR3: 'rgba(85,167,255,0.8)'
};
export type UsageGroupBy = 'model' | 'user' | 'api_key';
export type UsageMetric = 'total_tokens' | 'api_requests';
export interface UsageChartDatum {
name: string;
value: number;
}
export interface DashboardUsageCommonParams {
start_date: string;
end_date: string;
scope: string;
granularity: string;
filters: Record<string, any>;
}
export const usageChartHeight = 300;
export const usageChartCardHeight = usageChartHeight + 52;
export const buildUsageLabel = (item: BreakdownItem, groupBy: UsageGroupBy) => {
const rawItem = item as any;
const groupItem = rawItem[groupBy];
const identityValue = groupItem?.identity?.value;
const groupValue = groupItem?.value;
if (typeof groupItem === 'string') {
return groupItem;
}
if (groupBy === 'model') {
const providerName =
identityValue?.provider_name || groupValue?.provider_name;
const modelName =
identityValue?.model_name ||
groupValue?.model_name ||
rawItem.model_name ||
rawItem.model;
if (providerName && modelName) {
return `${providerName}/${modelName}`;
}
return groupItem?.label || modelName || '-';
}
return (
groupItem?.label ||
identityValue?.user_name ||
identityValue?.api_key_name ||
identityValue?.access_key ||
groupValue?.user_name ||
groupValue?.api_key_name ||
groupValue?.access_key ||
rawItem.user_name ||
rawItem.api_key_name ||
rawItem.access_key ||
rawItem[groupBy] ||
'-'
);
};
export const getUsageResponseItems = (
data: UsageBreakdownResponse | null | undefined
) => {
const response = data as any;
return response?.items || response?.data?.items || [];
};
export const getUsageResponseSeries = (
data: UsageBreakdownResponse | null | undefined
) => {
const response = data as any;
return response?.series || response?.data?.series || [];
};
export const getUsageMetricValue = (item: any, metric: UsageMetric) => {
return Number(
item?.[metric] ??
item?.metrics?.[metric] ??
item?.summary?.[metric] ??
item?.value ??
0
);
};
export const aggregateUsageByGroup = (
data: UsageBreakdownResponse | null | undefined,
groupBy: UsageGroupBy,
metric: UsageMetric
): UsageChartDatum[] => {
const itemMap = new Map<string, number>();
const items = getUsageResponseItems(data);
items.forEach((item: BreakdownItem) => {
const name = buildUsageLabel(item, groupBy);
itemMap.set(
name,
(itemMap.get(name) || 0) + getUsageMetricValue(item, metric)
);
});
if (!items.length) {
getUsageResponseSeries(data).forEach((item: any) => {
const name = item.label || buildUsageLabel(item, groupBy);
const value = (item.timeline || []).reduce(
(sum: number, point: any) => {
return sum + getUsageMetricValue(point, metric);
},
getUsageMetricValue(item, metric)
);
itemMap.set(name, (itemMap.get(name) || 0) + value);
});
}
if (!itemMap.size) {
return [];
}
const hasPositiveValue = Array.from(itemMap.values()).some(
(value) => value > 0
);
if (!hasPositiveValue) {
return [];
}
return Array.from(itemMap.entries())
.filter(([, value]) => value > 0)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 10);
};
export const getUsageSummaryData = (
dataList: Array<UsageBreakdownResponse | null | undefined>
) => {
return dataList.find((item) => {
const summary = item?.summary || (item as any)?.data?.summary;
return summary;
});
};
export const getUsageSummary = (
data: UsageBreakdownResponse | null | undefined
) => {
return data?.summary || (data as any)?.data?.summary;
};
export const toUsagePieData = (
data: UsageBreakdownResponse | null | undefined,
groupBy: UsageGroupBy,
metric: UsageMetric
): UsageChartDatum[] => {
return aggregateUsageByGroup(data, groupBy, metric);
};
export const toUsageRankData = (
data: UsageBreakdownResponse | null | undefined,
groupBy: UsageGroupBy,
seriesName: string,
color: string
) => {
const items = aggregateUsageByGroup(data, groupBy, 'total_tokens');
const names = items.map((item) => item.name);
return {
names,
series: [
{
name: seriesName,
color,
data: items.map((item) => ({
name: item.name,
value: item.value,
itemStyle: {
borderRadius: [2, 2, 2, 2]
}
}))
}
]
};
};