Compare commits

...
2 Commits
Author SHA1 Message Date
jialin a19fea0c6c fix(usage): carry full filter set in breakdown tables and fix double fetch
- breakdown sub-tables now send all active filters (route/user/api_key), matching the trend chart
- summary tab filters the token trend by user and unions user options from both meta APIs (deduped by id)
- stabilize the filters reference so meta load no longer retriggers a second fetch on mount
2026-06-30 16:57:17 +08:00
jialinandjialin a516e6ce72 feat(dashboard): split top user token usage into prompt/completion 2026-06-30 14:38:35 +08:00
9 changed files with 185 additions and 76 deletions
+66 -18
View File
@@ -225,29 +225,77 @@ export const toUsagePieData = (
return aggregateUsageByGroup(data, groupBy, metric); return aggregateUsageByGroup(data, groupBy, metric);
}; };
export const toUsageRankData = ( export type UsageTokenMetric = 'input_tokens' | 'output_tokens';
export interface UsageTokenSeriesDef {
name: string;
key: UsageTokenMetric;
color: string;
}
// Aggregates each group's input/output tokens into a stacked HBarChart shape,
// so a single bar shows prompt (input) and completion (output) tokens side by
// side. Rows sharing a group (e.g. the same user across dates) are summed, then
// ranked by combined tokens and capped at the top 10.
export const toUsageTokenBreakdownData = (
data: UsageBreakdownResponse | null | undefined, data: UsageBreakdownResponse | null | undefined,
groupBy: UsageGroupBy, groupBy: UsageGroupBy,
seriesName: string, seriesDefs: UsageTokenSeriesDef[]
color: string
) => { ) => {
const items = aggregateUsageByGroup(data, groupBy, 'total_tokens'); const itemMap = new Map<string, { total: number; values: number[] }>();
const names = items.map((item) => item.name); const items = getUsageResponseItems(data);
items.forEach((item: BreakdownItem) => {
const name = buildUsageLabel(item, groupBy);
const entry = itemMap.get(name) || {
total: 0,
values: seriesDefs.map(() => 0)
};
seriesDefs.forEach((def, index) => {
const value = Number((item as any)?.[def.key] ?? 0);
entry.values[index] += value;
entry.total += value;
});
itemMap.set(name, entry);
});
const ranked = Array.from(itemMap.entries())
.filter(([, entry]) => entry.total > 0)
.sort((a, b) => b[1].total - a[1].total)
.slice(0, 10);
const names = ranked.map(([name]) => name);
// Round only the outer ends of the stacked bar; the seam where the input and
// output segments meet stays square ([topLeft, topRight, bottomRight, bottomLeft]).
const radius = 2;
const getBorderRadius = (index: number, count: number) => {
if (count <= 1) {
return [radius, radius, radius, radius];
}
if (index === 0) {
return [radius, 0, 0, radius];
}
if (index === count - 1) {
return [0, radius, radius, 0];
}
return [0, 0, 0, 0];
};
return { return {
names, names,
series: [ series: seriesDefs.map((def, index) => ({
{ name: def.name,
name: seriesName, color: def.color,
color, data: ranked.map(([name, entry]) => ({
data: items.map((item) => ({ name,
name: item.name, value: entry.values[index],
value: item.value, itemStyle: {
itemStyle: { borderRadius: getBorderRadius(index, seriesDefs.length)
borderRadius: [2, 2, 2, 2] }
} }))
})) }))
}
]
}; };
}; };
@@ -1,20 +1,17 @@
import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data'; import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data';
import { useIntl } from '@umijs/max';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { import {
baseColorMap, baseColorMap,
DashboardUsageCommonParams, DashboardUsageCommonParams,
toUsageRankData toUsageTokenBreakdownData
} from '../config'; } from '../config';
export default function useTopTokenUsageByUser( export default function useTopTokenUsageByUser(
commonParams: DashboardUsageCommonParams commonParams: DashboardUsageCommonParams
) { ) {
const intl = useIntl();
const query = useQueryTimeSeriesData({ const query = useQueryTimeSeriesData({
key: 'topTokenUsageByUserData' key: 'topTokenUsageByUserData'
}); });
const tokenUsageText = intl.formatMessage({ id: 'dashboard.tokens' });
useEffect(() => { useEffect(() => {
query query
@@ -31,13 +28,19 @@ export default function useTopTokenUsageByUser(
const rankData = useMemo( const rankData = useMemo(
() => () =>
toUsageRankData( toUsageTokenBreakdownData(query.detailData, 'user', [
query.detailData, {
'user', name: 'Prompt Tokens',
tokenUsageText, key: 'input_tokens',
baseColorMap.base color: baseColorMap.base
), },
[query.detailData, tokenUsageText] {
name: 'Completion Tokens',
key: 'output_tokens',
color: baseColorMap.baseR3
}
]),
[query.detailData]
); );
return { return {
+9
View File
@@ -96,3 +96,12 @@ export interface UsageMeta {
} }
export type FilterOptionType = Omit<UsageFilterItem, 'label' | 'deleted'>; export type FilterOptionType = Omit<UsageFilterItem, 'label' | 'deleted'>;
// The full breakdown filter set (route / user / api_key). Every breakdown
// table sends all active dimensions — matching the trend chart — so e.g. a
// user filter narrows the Models table too, not only the Users table.
export type BreakdownFilters = {
routes?: FilterOptionType[];
users?: FilterOptionType[];
api_keys?: FilterOptionType[];
};
+13 -5
View File
@@ -1,6 +1,7 @@
import { exportJsonToExcel } from '@gpustack/core-ui/excel'; import { exportJsonToExcel } from '@gpustack/core-ui/excel';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useEffect, useMemo, useState } from 'react'; import _ from 'lodash';
import { useEffect, useRef, useState } from 'react';
import { GroupOption } from '../config'; import { GroupOption } from '../config';
import { BreakdownItem, UsageFilterItem } from '../config/types'; import { BreakdownItem, UsageFilterItem } from '../config/types';
import useQueryTimeSeriesData from '../services/use-query-timeseries-data'; import useQueryTimeSeriesData from '../services/use-query-timeseries-data';
@@ -181,10 +182,17 @@ export const useUsageFilters = ({
return filters; return filters;
}; };
const filters = useMemo( // Keep a stable reference while the content is unchanged. ``buildFilters``
() => buildFilters(commonFilters), // returns a fresh object every render — and again when the meta options
[commonFilters, routeOptions, userOptions, apiKeyOptions] // resolve after mount — which would otherwise retrigger every breakdown
); // table's fetch effect a second time on first load. Only a real selection
// change (or options resolving a previously-selected id) should swap it.
const filtersRef = useRef<ReturnType<typeof buildFilters>>({});
const nextFilters = buildFilters(commonFilters);
if (!_.isEqual(nextFilters, filtersRef.current)) {
filtersRef.current = nextFilters;
}
const filters = filtersRef.current;
const fetchData = ( const fetchData = (
currentSelectedFilters = commonFilters, currentSelectedFilters = commonFilters,
+55 -3
View File
@@ -31,7 +31,9 @@ import { Col, Row } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import ResourceFilterBar from '../components/resource-filter-bar'; import ResourceFilterBar from '../components/resource-filter-bar';
import useResourceMeta from '../hooks/use-resource-meta'; import { FilterOptionType } from '../config/types';
import useResourceMeta, { SelectOption } from '../hooks/use-resource-meta';
import useQueryUsageMetaData from '../services/use-query-meta-data';
import { import {
bucketKey, bucketKey,
generateBucketRange, generateBucketRange,
@@ -231,7 +233,38 @@ const SummaryTab: React.FC = () => {
selectedUsers: [] selectedUsers: []
}); });
const { start, end, selectedUsers } = queryParams; const { start, end, selectedUsers } = queryParams;
const { creators: userOptions } = useResourceMeta(scope); const { creators: resourceUsers } = useResourceMeta(scope);
const { detailData: tokenMeta, fetchData: fetchTokenMeta } =
useQueryUsageMetaData();
// The user filter unions two sources: resource creators (GPU / storage
// usage) and the token-usage users (/usage/meta) — a user may appear in only
// one. Deduped by user id. The token meta also carries the per-user identity
// the token-series endpoint filters on (see ``tokenUserById``).
const userOptions = useMemo<SelectOption[]>(() => {
const map = new Map<number, SelectOption>();
resourceUsers.forEach((u) =>
map.set(u.value, { value: u.value, label: u.label, deleted: u.deleted })
);
(tokenMeta?.users || []).forEach((u) => {
const id = u.identity.current?.user_id;
if (id != null && !map.has(id)) {
map.set(id, { value: id, label: u.label });
}
});
return Array.from(map.values());
}, [resourceUsers, tokenMeta]);
// user id → the identity object the token series filters by. Built from the
// token meta so the trend's ``users`` filter carries the real identity.
const tokenUserById = useMemo(() => {
const map = new Map<number, FilterOptionType>();
(tokenMeta?.users || []).forEach((u) => {
const id = u.identity.current?.user_id;
if (id != null) map.set(id, { identity: u.identity });
});
return map;
}, [tokenMeta]);
const { const {
detailData: summary, detailData: summary,
@@ -285,6 +318,24 @@ const SummaryTab: React.FC = () => {
? { creator_ids: currentParams.selectedUsers } ? { creator_ids: currentParams.selectedUsers }
: undefined; : undefined;
// The token series hits /usage/breakdown, which filters users by identity
// rather than the creator_ids the resource endpoints take — so the token
// trend honors the user filter like the totals do. Resolve each id to its
// token-meta identity, falling back to a minimal current.user_id object for
// users present only in the resource meta.
const tokenUserFilter: { users?: FilterOptionType[] } = currentParams
.selectedUsers.length
? {
users: currentParams.selectedUsers.map(
(id) =>
tokenUserById.get(id) ??
({
identity: { current: { user_id: id } }
} as unknown as FilterOptionType)
)
}
: {};
await Promise.all([ await Promise.all([
fetchSummary({ fetchSummary({
...commonParams, ...commonParams,
@@ -308,7 +359,7 @@ const SummaryTab: React.FC = () => {
group_by: ['date'], group_by: ['date'],
granularity, granularity,
page: -1, page: -1,
filters: {} filters: tokenUserFilter
}), }),
fetchComputeBreakdown({ fetchComputeBreakdown({
@@ -418,6 +469,7 @@ const SummaryTab: React.FC = () => {
}; };
useEffect(() => { useEffect(() => {
fetchTokenMeta();
fetchAll(); fetchAll();
}, []); }, []);
@@ -1,14 +1,11 @@
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Tabs } from 'antd'; import { Tabs } from 'antd';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { UsageFilterItem } from '../../config/types'; import { BreakdownFilters } from '../../config/types';
import ApiKeysTable from '../tables/apikeys-table'; import ApiKeysTable from '../tables/apikeys-table';
import ModelsTable from '../tables/models-table'; import ModelsTable from '../tables/models-table';
import UsersTable from '../tables/users-table'; import UsersTable from '../tables/users-table';
type FilterOptionType = Omit<UsageFilterItem, 'label' | 'deleted'>;
const EMPTY_FILTERS: FilterOptionType[] = [];
const BreakdownTabs: React.FC<{ const BreakdownTabs: React.FC<{
dateRange: { dateRange: {
start_date: string; start_date: string;
@@ -17,16 +14,9 @@ const BreakdownTabs: React.FC<{
scope: string; scope: string;
pageResetKey?: number; pageResetKey?: number;
refreshKey?: number; refreshKey?: number;
filters: { filters: BreakdownFilters;
routes?: FilterOptionType[];
users?: FilterOptionType[];
api_keys?: FilterOptionType[];
};
}> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => { }> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => {
const intl = useIntl(); const intl = useIntl();
const routes = filters.routes || EMPTY_FILTERS;
const users = filters.users || EMPTY_FILTERS;
const apiKeys = filters.api_keys || EMPTY_FILTERS;
const items = useMemo(() => { const items = useMemo(() => {
return [ return [
@@ -37,7 +27,7 @@ const BreakdownTabs: React.FC<{
children: ( children: (
<ModelsTable <ModelsTable
key="models" key="models"
routes={routes} filters={filters}
dateRange={dateRange} dateRange={dateRange}
scope={scope} scope={scope}
pageResetKey={pageResetKey} pageResetKey={pageResetKey}
@@ -52,7 +42,7 @@ const BreakdownTabs: React.FC<{
children: ( children: (
<UsersTable <UsersTable
key="users" key="users"
users={users} filters={filters}
dateRange={dateRange} dateRange={dateRange}
scope={scope} scope={scope}
pageResetKey={pageResetKey} pageResetKey={pageResetKey}
@@ -67,7 +57,7 @@ const BreakdownTabs: React.FC<{
children: ( children: (
<ApiKeysTable <ApiKeysTable
key="api_keys" key="api_keys"
apiKeys={apiKeys} filters={filters}
dateRange={dateRange} dateRange={dateRange}
scope={scope} scope={scope}
pageResetKey={pageResetKey} pageResetKey={pageResetKey}
@@ -81,7 +71,7 @@ const BreakdownTabs: React.FC<{
} }
return true; return true;
}); });
}, [apiKeys, dateRange, routes, pageResetKey, refreshKey, scope, users]); }, [filters, dateRange, pageResetKey, refreshKey, scope]);
return ( return (
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
@@ -3,18 +3,18 @@ import PageBox from '@/pages/_components/page-box';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Table } from 'antd'; import { Table } from 'antd';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { FilterOptionType } from '../../config/types'; import { BreakdownFilters } from '../../config/types';
import useAPIKeys from '../../hooks/use-apikeys-columns'; import useAPIKeys from '../../hooks/use-apikeys-columns';
import useQueryBreakdownList from '../../services/use-query-breakdown-list'; import useQueryBreakdownList from '../../services/use-query-breakdown-list';
import getBreakdownRowKey from '../../utils/get-breakdown-row-key'; import getBreakdownRowKey from '../../utils/get-breakdown-row-key';
const APIKeys: React.FC<{ const APIKeys: React.FC<{
apiKeys: FilterOptionType[]; filters: BreakdownFilters;
dateRange: { start_date: string; end_date: string }; dateRange: { start_date: string; end_date: string };
scope: string; scope: string;
pageResetKey?: number; pageResetKey?: number;
refreshKey?: number; refreshKey?: number;
}> = ({ apiKeys, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => { }> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => {
const intl = useIntl(); const intl = useIntl();
const { loading, dataSource, fetchData } = useQueryBreakdownList({ const { loading, dataSource, fetchData } = useQueryBreakdownList({
@@ -69,16 +69,16 @@ const APIKeys: React.FC<{
fetchData({ fetchData({
...queryParams, ...queryParams,
group_by: ['api_key'], group_by: ['api_key'],
filters: { // Send the full filter set (route / user / api_key), not just the
api_keys: apiKeys // table's own dimension, so the breakdown matches the trend chart.
}, filters,
scope: scope, scope: scope,
...dateRange ...dateRange
}); });
}, [ }, [
apiKeys,
dateRange.end_date, dateRange.end_date,
dateRange.start_date, dateRange.start_date,
filters,
queryParams.page, queryParams.page,
queryParams.perPage, queryParams.perPage,
queryParams.sort_by, queryParams.sort_by,
@@ -4,18 +4,18 @@ import PageBox from '@/pages/_components/page-box';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Table } from 'antd'; import { Table } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { BreakdownItem, FilterOptionType } from '../../config/types'; import { BreakdownFilters, BreakdownItem } from '../../config/types';
import useModelsColumns from '../../hooks/use-models-columns'; import useModelsColumns from '../../hooks/use-models-columns';
import useQueryBreakdownList from '../../services/use-query-breakdown-list'; import useQueryBreakdownList from '../../services/use-query-breakdown-list';
import getBreakdownRowKey from '../../utils/get-breakdown-row-key'; import getBreakdownRowKey from '../../utils/get-breakdown-row-key';
const Models: React.FC<{ const Models: React.FC<{
routes: FilterOptionType[]; filters: BreakdownFilters;
dateRange: { start_date: string; end_date: string }; dateRange: { start_date: string; end_date: string };
scope: string; scope: string;
pageResetKey?: number; pageResetKey?: number;
refreshKey?: number; refreshKey?: number;
}> = ({ routes, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => { }> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => {
const intl = useIntl(); const intl = useIntl();
const { loading, dataSource, fetchData } = useQueryBreakdownList({ const { loading, dataSource, fetchData } = useQueryBreakdownList({
@@ -33,7 +33,6 @@ const Models: React.FC<{
const pendingPageResetRef = useRef(false); const pendingPageResetRef = useRef(false);
const handleTableChange = (pagination: any, filters: any, sorter: any) => { const handleTableChange = (pagination: any, filters: any, sorter: any) => {
console.log('pagination, filters, sorter: ', pagination, filters, sorter);
const sort_by = const sort_by =
sorter.order === 'descend' ? `-${sorter.field}` : sorter.field; sorter.order === 'descend' ? `-${sorter.field}` : sorter.field;
setQueryParams((prev) => ({ setQueryParams((prev) => ({
@@ -72,16 +71,16 @@ const Models: React.FC<{
fetchData({ fetchData({
...queryParams, ...queryParams,
group_by: ['route'], group_by: ['route'],
filters: { // Send the full filter set (route / user / api_key), not just the
routes // table's own dimension, so the breakdown matches the trend chart.
}, filters,
scope: scope, scope: scope,
...dateRange ...dateRange
}); });
}, [ }, [
dateRange.end_date, dateRange.end_date,
dateRange.start_date, dateRange.start_date,
routes, filters,
queryParams.page, queryParams.page,
queryParams.perPage, queryParams.perPage,
queryParams.sort_by, queryParams.sort_by,
@@ -3,18 +3,18 @@ import PageBox from '@/pages/_components/page-box';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Table } from 'antd'; import { Table } from 'antd';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { FilterOptionType } from '../../config/types'; import { BreakdownFilters } from '../../config/types';
import useUsersColumns from '../../hooks/use-users-columns'; import useUsersColumns from '../../hooks/use-users-columns';
import useQueryBreakdownList from '../../services/use-query-breakdown-list'; import useQueryBreakdownList from '../../services/use-query-breakdown-list';
import getBreakdownRowKey from '../../utils/get-breakdown-row-key'; import getBreakdownRowKey from '../../utils/get-breakdown-row-key';
const Users: React.FC<{ const Users: React.FC<{
users: FilterOptionType[]; filters: BreakdownFilters;
dateRange: { start_date: string; end_date: string }; dateRange: { start_date: string; end_date: string };
scope: string; scope: string;
pageResetKey?: number; pageResetKey?: number;
refreshKey?: number; refreshKey?: number;
}> = ({ users, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => { }> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => {
const intl = useIntl(); const intl = useIntl();
const { loading, dataSource, fetchData } = useQueryBreakdownList({ const { loading, dataSource, fetchData } = useQueryBreakdownList({
@@ -71,9 +71,9 @@ const Users: React.FC<{
fetchData({ fetchData({
...queryParams, ...queryParams,
group_by: ['user'], group_by: ['user'],
filters: { // Send the full filter set (route / user / api_key), not just the
users // table's own dimension, so the breakdown matches the trend chart.
}, filters,
scope: scope, scope: scope,
...dateRange ...dateRange
}); });
@@ -81,12 +81,12 @@ const Users: React.FC<{
}, [ }, [
dateRange.end_date, dateRange.end_date,
dateRange.start_date, dateRange.start_date,
filters,
queryParams.page, queryParams.page,
queryParams.perPage, queryParams.perPage,
queryParams.sort_by, queryParams.sort_by,
refreshKey, refreshKey,
scope, scope
users
]); ]);
return ( return (