From a19fea0c6c6d740b15901b2c3320f33a2f2c2eef Mon Sep 17 00:00:00 2001 From: jialin Date: Tue, 30 Jun 2026 16:57:17 +0800 Subject: [PATCH] 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 --- src/pages/usage/config/types.ts | 9 +++ src/pages/usage/hooks/use-usage-filters.ts | 18 ++++-- src/pages/usage/summary-tab/index.tsx | 58 ++++++++++++++++++- .../token-tab/components/breakdown-tabs.tsx | 22 ++----- .../usage/token-tab/tables/apikeys-table.tsx | 14 ++--- .../usage/token-tab/tables/models-table.tsx | 15 +++-- .../usage/token-tab/tables/users-table.tsx | 16 ++--- 7 files changed, 105 insertions(+), 47 deletions(-) diff --git a/src/pages/usage/config/types.ts b/src/pages/usage/config/types.ts index 9e8d4c3d..75a2c01e 100644 --- a/src/pages/usage/config/types.ts +++ b/src/pages/usage/config/types.ts @@ -96,3 +96,12 @@ export interface UsageMeta { } export type FilterOptionType = Omit; + +// 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[]; +}; diff --git a/src/pages/usage/hooks/use-usage-filters.ts b/src/pages/usage/hooks/use-usage-filters.ts index 1dddc2bd..759977e9 100644 --- a/src/pages/usage/hooks/use-usage-filters.ts +++ b/src/pages/usage/hooks/use-usage-filters.ts @@ -1,6 +1,7 @@ import { exportJsonToExcel } from '@gpustack/core-ui/excel'; import dayjs from 'dayjs'; -import { useEffect, useMemo, useState } from 'react'; +import _ from 'lodash'; +import { useEffect, useRef, useState } from 'react'; import { GroupOption } from '../config'; import { BreakdownItem, UsageFilterItem } from '../config/types'; import useQueryTimeSeriesData from '../services/use-query-timeseries-data'; @@ -181,10 +182,17 @@ export const useUsageFilters = ({ return filters; }; - const filters = useMemo( - () => buildFilters(commonFilters), - [commonFilters, routeOptions, userOptions, apiKeyOptions] - ); + // Keep a stable reference while the content is unchanged. ``buildFilters`` + // returns a fresh object every render — and again when the meta options + // 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>({}); + const nextFilters = buildFilters(commonFilters); + if (!_.isEqual(nextFilters, filtersRef.current)) { + filtersRef.current = nextFilters; + } + const filters = filtersRef.current; const fetchData = ( currentSelectedFilters = commonFilters, diff --git a/src/pages/usage/summary-tab/index.tsx b/src/pages/usage/summary-tab/index.tsx index 8c3328a9..93ca1c5d 100644 --- a/src/pages/usage/summary-tab/index.tsx +++ b/src/pages/usage/summary-tab/index.tsx @@ -31,7 +31,9 @@ import { Col, Row } from 'antd'; import dayjs from 'dayjs'; import React, { useEffect, useMemo, useState } from 'react'; 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 { bucketKey, generateBucketRange, @@ -231,7 +233,38 @@ const SummaryTab: React.FC = () => { selectedUsers: [] }); 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(() => { + const map = new Map(); + 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(); + (tokenMeta?.users || []).forEach((u) => { + const id = u.identity.current?.user_id; + if (id != null) map.set(id, { identity: u.identity }); + }); + return map; + }, [tokenMeta]); const { detailData: summary, @@ -285,6 +318,24 @@ const SummaryTab: React.FC = () => { ? { creator_ids: currentParams.selectedUsers } : 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([ fetchSummary({ ...commonParams, @@ -308,7 +359,7 @@ const SummaryTab: React.FC = () => { group_by: ['date'], granularity, page: -1, - filters: {} + filters: tokenUserFilter }), fetchComputeBreakdown({ @@ -418,6 +469,7 @@ const SummaryTab: React.FC = () => { }; useEffect(() => { + fetchTokenMeta(); fetchAll(); }, []); diff --git a/src/pages/usage/token-tab/components/breakdown-tabs.tsx b/src/pages/usage/token-tab/components/breakdown-tabs.tsx index 616b9526..7aabf294 100644 --- a/src/pages/usage/token-tab/components/breakdown-tabs.tsx +++ b/src/pages/usage/token-tab/components/breakdown-tabs.tsx @@ -1,14 +1,11 @@ import { useIntl } from '@umijs/max'; import { Tabs } from 'antd'; import React, { useMemo } from 'react'; -import { UsageFilterItem } from '../../config/types'; +import { BreakdownFilters } from '../../config/types'; import ApiKeysTable from '../tables/apikeys-table'; import ModelsTable from '../tables/models-table'; import UsersTable from '../tables/users-table'; -type FilterOptionType = Omit; -const EMPTY_FILTERS: FilterOptionType[] = []; - const BreakdownTabs: React.FC<{ dateRange: { start_date: string; @@ -17,16 +14,9 @@ const BreakdownTabs: React.FC<{ scope: string; pageResetKey?: number; refreshKey?: number; - filters: { - routes?: FilterOptionType[]; - users?: FilterOptionType[]; - api_keys?: FilterOptionType[]; - }; + filters: BreakdownFilters; }> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => { 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(() => { return [ @@ -37,7 +27,7 @@ const BreakdownTabs: React.FC<{ children: ( diff --git a/src/pages/usage/token-tab/tables/apikeys-table.tsx b/src/pages/usage/token-tab/tables/apikeys-table.tsx index c1d94e8a..a47addf3 100644 --- a/src/pages/usage/token-tab/tables/apikeys-table.tsx +++ b/src/pages/usage/token-tab/tables/apikeys-table.tsx @@ -3,18 +3,18 @@ import PageBox from '@/pages/_components/page-box'; import { useIntl } from '@umijs/max'; import { Table } from 'antd'; import { useEffect, useRef, useState } from 'react'; -import { FilterOptionType } from '../../config/types'; +import { BreakdownFilters } from '../../config/types'; import useAPIKeys from '../../hooks/use-apikeys-columns'; import useQueryBreakdownList from '../../services/use-query-breakdown-list'; import getBreakdownRowKey from '../../utils/get-breakdown-row-key'; const APIKeys: React.FC<{ - apiKeys: FilterOptionType[]; + filters: BreakdownFilters; dateRange: { start_date: string; end_date: string }; scope: string; pageResetKey?: number; refreshKey?: number; -}> = ({ apiKeys, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => { +}> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => { const intl = useIntl(); const { loading, dataSource, fetchData } = useQueryBreakdownList({ @@ -69,16 +69,16 @@ const APIKeys: React.FC<{ fetchData({ ...queryParams, group_by: ['api_key'], - filters: { - api_keys: apiKeys - }, + // Send the full filter set (route / user / api_key), not just the + // table's own dimension, so the breakdown matches the trend chart. + filters, scope: scope, ...dateRange }); }, [ - apiKeys, dateRange.end_date, dateRange.start_date, + filters, queryParams.page, queryParams.perPage, queryParams.sort_by, diff --git a/src/pages/usage/token-tab/tables/models-table.tsx b/src/pages/usage/token-tab/tables/models-table.tsx index fb3fea46..73ec33c2 100644 --- a/src/pages/usage/token-tab/tables/models-table.tsx +++ b/src/pages/usage/token-tab/tables/models-table.tsx @@ -4,18 +4,18 @@ import PageBox from '@/pages/_components/page-box'; import { useIntl } from '@umijs/max'; import { Table } from 'antd'; 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 useQueryBreakdownList from '../../services/use-query-breakdown-list'; import getBreakdownRowKey from '../../utils/get-breakdown-row-key'; const Models: React.FC<{ - routes: FilterOptionType[]; + filters: BreakdownFilters; dateRange: { start_date: string; end_date: string }; scope: string; pageResetKey?: number; refreshKey?: number; -}> = ({ routes, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => { +}> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => { const intl = useIntl(); const { loading, dataSource, fetchData } = useQueryBreakdownList({ @@ -33,7 +33,6 @@ const Models: React.FC<{ const pendingPageResetRef = useRef(false); const handleTableChange = (pagination: any, filters: any, sorter: any) => { - console.log('pagination, filters, sorter: ', pagination, filters, sorter); const sort_by = sorter.order === 'descend' ? `-${sorter.field}` : sorter.field; setQueryParams((prev) => ({ @@ -72,16 +71,16 @@ const Models: React.FC<{ fetchData({ ...queryParams, group_by: ['route'], - filters: { - routes - }, + // Send the full filter set (route / user / api_key), not just the + // table's own dimension, so the breakdown matches the trend chart. + filters, scope: scope, ...dateRange }); }, [ dateRange.end_date, dateRange.start_date, - routes, + filters, queryParams.page, queryParams.perPage, queryParams.sort_by, diff --git a/src/pages/usage/token-tab/tables/users-table.tsx b/src/pages/usage/token-tab/tables/users-table.tsx index d1a43687..30caf119 100644 --- a/src/pages/usage/token-tab/tables/users-table.tsx +++ b/src/pages/usage/token-tab/tables/users-table.tsx @@ -3,18 +3,18 @@ import PageBox from '@/pages/_components/page-box'; import { useIntl } from '@umijs/max'; import { Table } from 'antd'; import { useEffect, useRef, useState } from 'react'; -import { FilterOptionType } from '../../config/types'; +import { BreakdownFilters } from '../../config/types'; import useUsersColumns from '../../hooks/use-users-columns'; import useQueryBreakdownList from '../../services/use-query-breakdown-list'; import getBreakdownRowKey from '../../utils/get-breakdown-row-key'; const Users: React.FC<{ - users: FilterOptionType[]; + filters: BreakdownFilters; dateRange: { start_date: string; end_date: string }; scope: string; pageResetKey?: number; refreshKey?: number; -}> = ({ users, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => { +}> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => { const intl = useIntl(); const { loading, dataSource, fetchData } = useQueryBreakdownList({ @@ -71,9 +71,9 @@ const Users: React.FC<{ fetchData({ ...queryParams, group_by: ['user'], - filters: { - users - }, + // Send the full filter set (route / user / api_key), not just the + // table's own dimension, so the breakdown matches the trend chart. + filters, scope: scope, ...dateRange }); @@ -81,12 +81,12 @@ const Users: React.FC<{ }, [ dateRange.end_date, dateRange.start_date, + filters, queryParams.page, queryParams.perPage, queryParams.sort_by, refreshKey, - scope, - users + scope ]); return (