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
This commit is contained in:
jialin
2026-06-30 16:57:17 +08:00
parent a516e6ce72
commit a19fea0c6c
7 changed files with 105 additions and 47 deletions
+9
View File
@@ -96,3 +96,12 @@ export interface UsageMeta {
}
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 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<ReturnType<typeof buildFilters>>({});
const nextFilters = buildFilters(commonFilters);
if (!_.isEqual(nextFilters, filtersRef.current)) {
filtersRef.current = nextFilters;
}
const filters = filtersRef.current;
const fetchData = (
currentSelectedFilters = commonFilters,
+55 -3
View File
@@ -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<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 {
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();
}, []);
@@ -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<UsageFilterItem, 'label' | 'deleted'>;
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: (
<ModelsTable
key="models"
routes={routes}
filters={filters}
dateRange={dateRange}
scope={scope}
pageResetKey={pageResetKey}
@@ -52,7 +42,7 @@ const BreakdownTabs: React.FC<{
children: (
<UsersTable
key="users"
users={users}
filters={filters}
dateRange={dateRange}
scope={scope}
pageResetKey={pageResetKey}
@@ -67,7 +57,7 @@ const BreakdownTabs: React.FC<{
children: (
<ApiKeysTable
key="api_keys"
apiKeys={apiKeys}
filters={filters}
dateRange={dateRange}
scope={scope}
pageResetKey={pageResetKey}
@@ -81,7 +71,7 @@ const BreakdownTabs: React.FC<{
}
return true;
});
}, [apiKeys, dateRange, routes, pageResetKey, refreshKey, scope, users]);
}, [filters, dateRange, pageResetKey, refreshKey, scope]);
return (
<div style={{ marginTop: 16 }}>
@@ -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,
@@ -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,
@@ -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 (