feat(usage): add plugin injection points for org / user-group filters and org breakdown tab

- token & resource filter bars render a plugin slot (UsageFilterBar /
  ResourceUsageFilterBar) for the organization / user-group filters
- breakdown tabs read plugin-provided extra sub-tabs (breakdownExtraTabs /
  resourceBreakdownExtraTabs) so the enterprise plugin can inject the
  Organization sub-tab
- thread the org / user-group filter state through the token filter hook and
  the resource tabs / breakdown tables / summary / export dialog
- standardize the organization label on the principal name; resource export
  date formatted to date-only
This commit is contained in:
michelia
2026-07-14 14:35:23 +08:00
committed by jialin
parent d0b6498bda
commit efb63335e7
15 changed files with 725 additions and 91 deletions
+60 -5
View File
@@ -20,6 +20,10 @@ export interface ResourceUsageFilters {
instance_ids?: number[];
gpu_types?: string[];
volume_ids?: number[];
// Platform-wide "All" view only (backend-gated): consumer-Org ids and
// user-group ids (expanded server-side to the groups' direct members).
organization_ids?: number[];
user_group_ids?: number[];
}
export interface ResourceBreakdownRequest {
@@ -65,6 +69,10 @@ export interface ResourceBreakdownItem extends ResourceBreakdownSummary {
volume_name?: string;
user_id?: number;
user_name?: string;
// Organization grouping (platform-wide "All" view). ``organization_name``
// is resolved live server-side; a gone Org sets ``deleted``.
organization_id?: number;
organization_name?: string;
// The grouped entity (instance / volume / user) no longer exists. The name
// fields keep the clean (stale) name; the tables show a DeletedTag off this
// flag plus the id, matching the Tokens tab.
@@ -261,6 +269,7 @@ const GROUP_BY_MAP: Record<string, string> = {
instance: 'instance',
volume: 'volume',
user: 'user',
organization: 'organization',
date: 'date'
};
@@ -334,6 +343,10 @@ function flattenItem(
flat.user_name = rawKey;
flat.user_id = id;
break;
case 'organization':
flat.organization_name = rawKey;
flat.organization_id = id;
break;
default:
break;
}
@@ -393,7 +406,13 @@ function flattenResponse(
function toServerRequest(data: ResourceBreakdownRequest) {
const groupByList = data.group_by?.length ? data.group_by : ['resource_type'];
const { creator_ids, instance_ids, volume_ids } = data.filters ?? {};
const {
creator_ids,
instance_ids,
volume_ids,
organization_ids,
user_group_ids
} = data.filters ?? {};
// The non-date dimension drives response flattening into the right field.
const dim = groupByList.find((g) => g !== 'date');
return {
@@ -408,6 +427,8 @@ function toServerRequest(data: ResourceBreakdownRequest) {
...(creator_ids?.length ? { creator_ids } : {}),
...(instance_ids?.length ? { instance_ids } : {}),
...(volume_ids?.length ? { volume_ids } : {}),
...(organization_ids?.length ? { organization_ids } : {}),
...(user_group_ids?.length ? { user_group_ids } : {}),
...(data.order_by ? { order_by: data.order_by } : {}),
...(data.descending !== undefined ? { descending: data.descending } : {}),
page: data.page ?? 1,
@@ -477,6 +498,8 @@ export async function queryResourceEvents(
options?: { skipErrorHandler?: boolean; token?: any }
): Promise<ResourceEventsResponse> {
const creatorIds = data.filters?.creator_ids;
const organizationIds = data.filters?.organization_ids;
const userGroupIds = data.filters?.user_group_ids;
return request<ResourceEventsResponse>(URL.EVENTS, {
params: {
start_date: data.start_date,
@@ -486,6 +509,12 @@ export async function queryResourceEvents(
// GET endpoints take list params as CSV strings (avoids axios array
// serialization quirks); the server splits them back into lists.
...(creatorIds?.length ? { creator_ids: creatorIds.join(',') } : {}),
...(organizationIds?.length
? { organization_ids: organizationIds.join(',') }
: {}),
...(userGroupIds?.length
? { user_group_ids: userGroupIds.join(',') }
: {}),
...(data.event_types?.length
? { event_types: data.event_types.join(',') }
: {}),
@@ -503,12 +532,18 @@ export interface ResourceFilterOption {
id: number;
label: string;
deleted?: boolean;
// ``org`` / ``user`` / ``group`` — only set on organization options so the
// filter dropdown can tag a personal (USER) consumer.
kind?: string;
}
export interface ResourceFilterMeta {
creators: ResourceFilterOption[];
instances: ResourceFilterOption[];
volumes: ResourceFilterOption[];
// Platform-wide "All" view only (backend returns them empty otherwise).
organizations: ResourceFilterOption[];
user_groups: ResourceFilterOption[];
}
export async function queryResourceFilterMeta(
@@ -521,7 +556,9 @@ export async function queryResourceFilterMeta(
return {
creators: res.creators || [],
instances: res.instances || [],
volumes: res.volumes || []
volumes: res.volumes || [],
organizations: res.organizations || [],
user_groups: res.user_groups || []
};
}
@@ -531,10 +568,12 @@ export async function queryUsageSummary(
end_date: string;
scope?: 'self' | 'all';
creator_ids?: number[];
organization_ids?: number[];
user_group_ids?: number[];
},
options?: { token?: any }
): Promise<UsageSummaryResponse> {
const { creator_ids, ...rest } = params;
const { creator_ids, organization_ids, user_group_ids, ...rest } = params;
const res = await request<{
total_tokens: number;
input_tokens: number;
@@ -548,7 +587,13 @@ export async function queryUsageSummary(
params: {
...rest,
scope: params.scope ?? 'all',
...(creator_ids?.length ? { creator_ids: creator_ids.join(',') } : {})
...(creator_ids?.length ? { creator_ids: creator_ids.join(',') } : {}),
...(organization_ids?.length
? { organization_ids: organization_ids.join(',') }
: {}),
...(user_group_ids?.length
? { user_group_ids: user_group_ids.join(',') }
: {})
},
method: 'GET',
cancelToken: options?.token
@@ -565,7 +610,17 @@ export async function queryUsageSummary(
end_date: params.end_date,
scope: params.scope ?? 'all',
group_by: ['gpu_type'],
...(creator_ids?.length ? { filters: { creator_ids } } : {}),
...(creator_ids?.length ||
organization_ids?.length ||
user_group_ids?.length
? {
filters: {
...(creator_ids?.length ? { creator_ids } : {}),
...(organization_ids?.length ? { organization_ids } : {}),
...(user_group_ids?.length ? { user_group_ids } : {})
}
}
: {}),
page: 1,
perPage: 100
},
+30
View File
@@ -1,3 +1,4 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import useRangePickerPreset from '@/pages/dashboard/hooks/use-rangepicker-preset';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import {
@@ -37,6 +38,14 @@ interface FilterBarProps {
routeOptions: OptionType[];
userOptions: OptionType[];
apiKeyOptions: GroupOption<UsageFilterItem>[];
// Platform-wide "All" view only; empty otherwise (backend-gated). Rendered
// by the enterprise ``UsageFilterBar`` slot.
organizationOptions?: OptionType[];
userGroupOptions?: OptionType[];
selectedOrganizations?: string[];
selectedUserGroups?: string[];
onOrganizationsChange?: (value: string[]) => void;
onUserGroupsChange?: (value: string[]) => void;
activeApiKeys: valueType[][];
handlePickerChange: (picker: DateType) => void;
onScopeChange: (value: string) => void;
@@ -77,6 +86,12 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
onRoutesChange,
onUsersChange,
onApiKeysChange,
organizationOptions,
userGroupOptions,
selectedOrganizations,
selectedUserGroups,
onOrganizationsChange,
onUserGroupsChange,
onExportChart,
onExportTable,
handleSearch
@@ -345,6 +360,21 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
onChange={onApiKeysChange}
/>
)}
{/* Enterprise-only Organization / User Group filters (platform-wide
"All" view). Renders nothing when no plugin is registered or the
backend returned no options. */}
<PluginExtraFields
name="UsageFilterBar"
context={{
organizationOptions: organizationOptions || [],
userGroupOptions: userGroupOptions || [],
selectedOrganizations: selectedOrganizations || [],
selectedUserGroups: selectedUserGroups || [],
onOrganizationsChange,
onUserGroupsChange,
optionLabelRender: singleOptionRender
}}
/>
<Button
type="text"
style={{ color: 'var(--ant-color-text-tertiary)' }}
@@ -33,6 +33,9 @@ type Scope = 'self' | 'all';
interface SelectOption {
value: number;
label: string;
deleted?: boolean;
// ``org`` / ``user`` / ``group`` — set on organization options for the tag.
kind?: string;
}
interface ResourceExportDataProps {
@@ -62,6 +65,12 @@ interface ResourceExportDataProps {
initialDateRange: [dayjs.Dayjs, dayjs.Dayjs];
initialSelectedUsers: number[];
initialSelectedResources: number[];
// Platform-wide "All" view only; empty otherwise. Mirrors the tab's
// Organization / User Group filters so the export can re-narrow by them.
organizationOptions?: SelectOption[];
userGroupOptions?: SelectOption[];
initialSelectedOrganizations?: number[];
initialSelectedUserGroups?: number[];
// Name columns that carry a "[Deleted.#id]" marker when their entity is gone.
// Each maps a clean-name field to its id + own deleted flag, so a compound
// (date + instance/volume) row can mark the instance/volume and its owner
@@ -92,6 +101,10 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
initialDateRange,
initialSelectedUsers,
initialSelectedResources,
organizationOptions = [],
userGroupOptions = [],
initialSelectedOrganizations = [],
initialSelectedUserGroups = [],
deletedNameFields
} = props;
const intl = useIntl();
@@ -103,6 +116,12 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
const [selectedResources, setSelectedResources] = useState<number[]>(
initialSelectedResources
);
const [selectedOrganizations, setSelectedOrganizations] = useState<number[]>(
initialSelectedOrganizations
);
const [selectedUserGroups, setSelectedUserGroups] = useState<number[]>(
initialSelectedUserGroups
);
const [pageParams, setPageParams] = useState(INITIAL_PAGE);
const [data, setData] = useState<ResourceBreakdownResponse | null>(null);
const [loading, setLoading] = useState(false);
@@ -118,11 +137,20 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
group_by: groupBy,
granularity: 'day',
filters:
selectedUsers.length || selectedResources.length
selectedUsers.length ||
selectedResources.length ||
selectedOrganizations.length ||
selectedUserGroups.length
? {
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
...(selectedResources.length
? { [resourceFilter.key]: selectedResources }
: {}),
...(selectedOrganizations.length
? { organization_ids: selectedOrganizations }
: {}),
...(selectedUserGroups.length
? { user_group_ids: selectedUserGroups }
: {})
}
: undefined,
@@ -146,6 +174,8 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
setDateRange(initialDateRange);
setSelectedUsers(initialSelectedUsers);
setSelectedResources(initialSelectedResources);
setSelectedOrganizations(initialSelectedOrganizations);
setSelectedUserGroups(initialSelectedUserGroups);
setPageParams(INITIAL_PAGE);
}, [open]);
@@ -153,7 +183,15 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
useEffect(() => {
if (!open) return;
fetchPreview(pageParams.page, pageParams.perPage);
}, [open, dateRange, selectedUsers, selectedResources, pageParams]);
}, [
open,
dateRange,
selectedUsers,
selectedResources,
selectedOrganizations,
selectedUserGroups,
pageParams
]);
const previewColumns = useMemo(
() => [
@@ -197,7 +235,19 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
});
};
const rows: ResourceBreakdownItem[] = markRows(data?.items ?? []);
// Normalize the date bucket to a plain calendar day (drop the ``T00:00:00``
// the hourly ``metered_usage`` carries) so the export matches the Tokens
// tab's date-only format. The export always requests day granularity.
const formatRowDates = (
items: ResourceBreakdownItem[]
): ResourceBreakdownItem[] =>
items.map((i) =>
i.date ? { ...i, date: dayjs(i.date).format('YYYY-MM-DD') } : i
);
const rows: ResourceBreakdownItem[] = formatRowDates(
markRows(data?.items ?? [])
);
const handlePageChange = (page: number, perPage: number) => {
setPageParams({ page, perPage });
@@ -210,7 +260,7 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
try {
const res = await queryFn(buildRequest(-1, INITIAL_PAGE.perPage));
exportBreakdownRows(
markRows(res.items ?? []),
formatRowDates(markRows(res.items ?? [])),
toExportColumns(columns),
fileName,
sheetName
@@ -272,6 +322,18 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
},
placeholder: resourceFilter.placeholder
}}
organizationOptions={organizationOptions}
userGroupOptions={userGroupOptions}
selectedOrganizations={selectedOrganizations}
selectedUserGroups={selectedUserGroups}
onOrganizationsChange={(ids) => {
setSelectedOrganizations(ids);
setPageParams(INITIAL_PAGE);
}}
onUserGroupsChange={(ids) => {
setSelectedUserGroups(ids);
setPageParams(INITIAL_PAGE);
}}
/>
</div>
<Table
@@ -9,6 +9,7 @@
* ``extra`` lets a tab append its own filters (e.g. Resource Events' resource
* type / event type) inline, keeping one consistent bar.
*/
import PluginExtraFields from '@/components/plugin-extra-fields';
import useRangePickerPreset from '@/pages/dashboard/hooks/use-rangepicker-preset';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { AutoTooltip, IconFont, SimpleSelect } from '@gpustack/core-ui';
@@ -54,6 +55,14 @@ interface ResourceFilterBarProps {
onExportChart?: () => void;
onExportTable?: () => void;
extra?: React.ReactNode;
// Platform-wide "All" view only; empty otherwise (backend-gated). Rendered
// by the enterprise ``ResourceUsageFilterBar`` slot.
organizationOptions?: SelectOption[];
userGroupOptions?: SelectOption[];
selectedOrganizations?: number[];
selectedUserGroups?: number[];
onOrganizationsChange?: (ids: number[]) => void;
onUserGroupsChange?: (ids: number[]) => void;
}
const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
@@ -68,7 +77,13 @@ const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
onRefresh,
onExportChart,
onExportTable,
extra
extra,
organizationOptions,
userGroupOptions,
selectedOrganizations,
selectedUserGroups,
onOrganizationsChange,
onUserGroupsChange
} = props;
const intl = useIntl();
@@ -216,6 +231,21 @@ const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
/>
)}
{extra}
{/* Enterprise-only Organization / User Group filters (platform-wide
"All" view). Renders nothing when no plugin is registered or the
backend returned no options. */}
<PluginExtraFields
name="ResourceUsageFilterBar"
context={{
organizationOptions: organizationOptions || [],
userGroupOptions: userGroupOptions || [],
selectedOrganizations: selectedOrganizations || [],
selectedUserGroups: selectedUserGroups || [],
onOrganizationsChange,
onUserGroupsChange,
optionLabelRender: userOptionRender
}}
/>
{onRefresh && (
<Button
type="text"
+16 -3
View File
@@ -9,11 +9,16 @@ export interface UsageFilterItem {
provider_type: string | null;
provider_name: string | null;
route_name: string | null;
// Resolved live from principals (platform-wide "All" view only).
organization_name?: string | null;
group_name?: string | null;
};
current: {
user_id: number | null;
api_key_id: string | null;
route_id: number | null;
organization_id?: number | null;
group_id?: number | null;
};
};
label: string;
@@ -64,6 +69,7 @@ export type BreakdownItem = {
model: UsageFilterItem;
route: UsageFilterItem;
api_key: UsageFilterItem;
organization: UsageFilterItem;
date: {
value: string;
label: string;
@@ -88,16 +94,23 @@ export interface UsageMeta {
users: UsageFilterItem[];
api_keys: UsageFilterItem[];
routes: UsageFilterItem[];
// Platform-wide "All" view only; empty otherwise (backend-gated).
organizations?: UsageFilterItem[];
user_groups?: UsageFilterItem[];
};
}
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.
// The full breakdown filter set (route / user / api_key + org / user_group).
// 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. ``organizations`` / ``user_groups`` are only ever populated in
// the platform-wide "All" view (their filter options are backend-gated).
export type BreakdownFilters = {
routes?: FilterOptionType[];
users?: FilterOptionType[];
api_keys?: FilterOptionType[];
organizations?: FilterOptionType[];
user_groups?: FilterOptionType[];
};
+25 -2
View File
@@ -12,18 +12,26 @@ export interface SelectOption {
// The signed-in user's own entry, sorted first and tagged "[Current Account]"
// in the filter dropdown (matches the Tokens tab).
isCurrent?: boolean;
// ``org`` / ``user`` / ``group`` — set on organization options so the filter
// can tag a personal (USER) consumer.
kind?: string;
}
export interface ResourceMetaOptions {
creators: SelectOption[];
instances: SelectOption[];
volumes: SelectOption[];
// Platform-wide "All" view only (empty otherwise, backend-gated).
organizations: SelectOption[];
user_groups: SelectOption[];
}
const EMPTY: ResourceMetaOptions = {
creators: [],
instances: [],
volumes: []
volumes: [],
organizations: [],
user_groups: []
};
// Deleted entries sink to the bottom of the dropdown; live ones keep their
@@ -73,7 +81,22 @@ export default function useResourceMeta(
setMeta({
creators: toUserOptions(res.creators, currentUserId),
instances: toOptions(res.instances),
volumes: toOptions(res.volumes)
volumes: toOptions(res.volumes),
// Keep ``kind`` on org options so the filter can tag personal (USER)
// consumers; deleted ones still sink to the bottom.
organizations: res.organizations
.map((i) => ({
value: i.id,
label: i.label,
deleted: i.deleted,
kind: i.kind
}))
.sort((a, b) => Number(!!a.deleted) - Number(!!b.deleted)),
// Groups are never flagged deleted; keep incoming order.
user_groups: res.user_groups.map((i) => ({
value: i.id,
label: i.label
}))
})
)
.catch(() => {
+45 -3
View File
@@ -10,10 +10,14 @@ import { withDeletedMark } from '../utils/deleted-label';
// group dimension → the id field inside ``identity.current`` (null for deleted
// entities on the Tokens tab, so the marker degrades to just "[Deleted]").
const GROUP_ID_KEY: Record<string, 'route_id' | 'user_id' | 'api_key_id'> = {
const GROUP_ID_KEY: Record<
string,
'route_id' | 'user_id' | 'api_key_id' | 'organization_id'
> = {
route: 'route_id',
user: 'user_id',
api_key: 'api_key_id'
api_key: 'api_key_id',
organization: 'organization_id'
};
const DefaultDateConfig = {
@@ -38,6 +42,8 @@ interface UseUsageFiltersParams {
users?: UserOptionType[];
api_keys?: GroupOptionType[];
routes?: RouteOptionType[];
organizations?: RouteOptionType[];
user_groups?: RouteOptionType[];
};
chartFilters: {
metric: string;
@@ -63,12 +69,16 @@ interface UseUsageFiltersParams {
routes?: FilterOptionType[];
users?: FilterOptionType[];
api_keys?: FilterOptionType[];
organizations?: FilterOptionType[];
user_groups?: FilterOptionType[];
};
commonFilters: {
scope: string;
routes: string[];
users: string[];
api_keys: string[];
organizations: string[];
user_groups: string[];
start_date: string;
end_date: string;
};
@@ -112,6 +122,8 @@ export const useUsageFilters = ({
routes: initialActiveRoutes,
users: initialUsers || [],
api_keys: extractSelectedValues(initialActiveApiKeys),
organizations: [] as string[],
user_groups: [] as string[],
start_date:
start_date ||
dayjs()
@@ -123,6 +135,8 @@ export const useUsageFilters = ({
const routeOptions = metaData?.routes || [];
const userOptions = metaData?.users || [];
const apiKeyOptions = metaData?.api_keys || [];
const organizationOptions = metaData?.organizations || [];
const userGroupOptions = metaData?.user_groups || [];
const [activeApiKeys, setActiveApiKeys] =
useState<ValueType[][]>(initialActiveApiKeys);
@@ -160,6 +174,8 @@ export const useUsageFilters = ({
routes?: FilterOptionType[];
users?: FilterOptionType[];
api_keys?: FilterOptionType[];
organizations?: FilterOptionType[];
user_groups?: FilterOptionType[];
} = {};
if (selected.routes.length > 0) {
@@ -190,6 +206,24 @@ export const useUsageFilters = ({
}));
}
if (selected.organizations.length > 0) {
const orgSet = new Set(selected.organizations);
filters.organizations = organizationOptions
.filter((item) => orgSet.has(item.value))
.map((item) => ({
identity: item.identity
}));
}
if (selected.user_groups.length > 0) {
const groupSet = new Set(selected.user_groups);
filters.user_groups = userGroupOptions
.filter((item) => groupSet.has(item.value))
.map((item) => ({
identity: item.identity
}));
}
return filters;
};
@@ -267,7 +301,7 @@ export const useUsageFilters = ({
};
const handleFilterChange = (
type: 'routes' | 'users' | 'api_keys',
type: 'routes' | 'users' | 'api_keys' | 'organizations' | 'user_groups',
value: string[]
) => {
const selectedValues: string[] = value.map((item) => {
@@ -364,9 +398,13 @@ export const useUsageFilters = ({
selectedRoutes: commonFilters.routes,
selectedUsers: commonFilters.users,
selectedApiKeys: commonFilters.api_keys,
selectedOrganizations: commonFilters.organizations,
selectedUserGroups: commonFilters.user_groups,
routeOptions,
userOptions,
apiKeyOptions,
organizationOptions,
userGroupOptions,
activeApiKeys,
handleSearch,
handleActiveApiKeysChange,
@@ -375,6 +413,10 @@ export const useUsageFilters = ({
onRoutesChange: (value: string[]) => handleFilterChange('routes', value),
onUsersChange: (value: string[]) => handleFilterChange('users', value),
onApiKeysChange: (value: string[]) => handleFilterChange('api_keys', value),
onOrganizationsChange: (value: string[]) =>
handleFilterChange('organizations', value),
onUserGroupsChange: (value: string[]) =>
handleFilterChange('user_groups', value),
onExportChart: handleOnExportChart
};
+131 -24
View File
@@ -10,6 +10,7 @@
* Talks to the new ``/usage/gpu-instances/{meta,breakdown}`` endpoints.
*/
import useCoolColors from '@/hooks/use-cool-colors';
import { getGPUStackPlugin } from '@/plugins';
import { formatLargeNumber } from '@/utils';
import { SimpleCard } from '@gpustack/core-ui';
import { useAccess, useIntl } from '@umijs/max';
@@ -44,6 +45,29 @@ type Scope = 'self' | 'all';
type Metric = 'gpu_hours' | 'instance_hours';
type GroupKey = 'gpu_type' | 'instance' | 'user';
// Enterprise-provided extra bottom sub-tab (e.g. the Organization breakdown).
// Registered on the enterprise plugin under ``usage.resourceBreakdownExtraTabs``;
// empty when no plugin is loaded, so the OSS build renders nothing extra.
interface ResourceBreakdownExtraTab {
key: string;
labelId: string;
useVisible?: () => boolean;
Component: React.ComponentType<{
tab: 'gpu-instances' | 'storage';
dateRange: [dayjs.Dayjs, dayjs.Dayjs];
scope: Scope;
filters: {
creator_ids?: number[];
instance_ids?: number[];
volume_ids?: number[];
organization_ids?: number[];
user_group_ids?: number[];
};
pageResetKey?: number;
refreshKey?: number;
}>;
}
const GpuInstancesTab: React.FC = () => {
const access = useAccess();
const intl = useIntl();
@@ -99,6 +123,11 @@ const GpuInstancesTab: React.FC = () => {
]);
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
const [selectedInstances, setSelectedInstances] = useState<number[]>([]);
// Platform-wide "All" view only (enterprise-gated); empty otherwise.
const [selectedOrganizations, setSelectedOrganizations] = useState<number[]>(
[]
);
const [selectedUserGroups, setSelectedUserGroups] = useState<number[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
const [metric, setMetric] = useState<Metric>('gpu_hours');
const [granularity, setGranularity] = useState<Granularity>('day');
@@ -106,10 +135,25 @@ const GpuInstancesTab: React.FC = () => {
const [chartGroupBy, setChartGroupBy] = useState<GroupKey | null>(null);
// ``null`` group_by = no row grouping, just the summary KPIs.
// The chart needs the ``date`` group; tables use the active table tab.
const [activeTableTab, setActiveTableTab] = useState<GroupKey>('instance');
// Widened to ``string``: enterprise extra tabs use arbitrary keys.
const [activeTableTab, setActiveTableTab] = useState<string>('instance');
const { creators: userOptions, instances: instanceOptions } =
useResourceMeta(scope);
const {
creators: userOptions,
instances: instanceOptions,
organizations,
user_groups: userGroups
} = useResourceMeta(scope);
// Enterprise-provided extra bottom sub-tabs (empty in the OSS build). Call
// each descriptor's ``useVisible`` in a stable order — the descriptor list is
// registered once at plugin init, so its length never changes (rules of
// hooks).
const extraBreakdownTabs: ResourceBreakdownExtraTab[] =
getGPUStackPlugin()?.usage?.resourceBreakdownExtraTabs ?? [];
const extraTabVisible = extraBreakdownTabs.map(
(t) => t.useVisible?.() ?? true
);
// The daily chart fetches group_by=date here; each bottom table owns its own
// fetch (group_by=tab key) inside InstancesBreakdownTable. Bumped on any
@@ -128,11 +172,20 @@ const GpuInstancesTab: React.FC = () => {
scope,
granularity,
filters:
selectedUsers.length || selectedInstances.length
selectedUsers.length ||
selectedInstances.length ||
selectedOrganizations.length ||
selectedUserGroups.length
? {
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
...(selectedInstances.length
? { instance_ids: selectedInstances }
: {}),
...(selectedOrganizations.length
? { organization_ids: selectedOrganizations }
: {}),
...(selectedUserGroups.length
? { user_group_ids: selectedUserGroups }
: {})
}
: undefined,
@@ -159,6 +212,8 @@ const GpuInstancesTab: React.FC = () => {
dateRange,
selectedUsers,
selectedInstances,
selectedOrganizations,
selectedUserGroups,
granularity,
chartGroupBy,
refreshKey
@@ -395,6 +450,18 @@ const GpuInstancesTab: React.FC = () => {
},
placeholder: intl.formatMessage({ id: 'usage.filter.instance' })
}}
organizationOptions={organizations}
userGroupOptions={userGroups}
selectedOrganizations={selectedOrganizations}
selectedUserGroups={selectedUserGroups}
onOrganizationsChange={(ids) => {
setSelectedOrganizations(ids);
setPageResetKey((k) => k + 1);
}}
onUserGroupsChange={(ids) => {
setSelectedUserGroups(ids);
setPageResetKey((k) => k + 1);
}}
onRefresh={() => setRefreshKey((k) => k + 1)}
onExportChart={() => setExportMode('chart')}
onExportTable={handleExportTable}
@@ -435,26 +502,62 @@ const GpuInstancesTab: React.FC = () => {
{/* Bottom tabs + table */}
<Tabs
activeKey={activeTableTab}
onChange={(k) => setActiveTableTab(k as GroupKey)}
items={TABLE_TABS.map((t) => ({
key: t.key,
label: t.label,
// Keep every pane mounted so each table holds its own page/sort and
// switching tabs neither refetches nor resets the others.
forceRender: true,
children: (
<InstancesBreakdownTable
key={t.key}
groupKey={t.key}
dateRange={dateRange}
scope={scope}
selectedUsers={selectedUsers}
selectedInstances={selectedInstances}
pageResetKey={pageResetKey}
refreshKey={refreshKey}
/>
)
}))}
onChange={(k) => setActiveTableTab(k)}
items={[
...TABLE_TABS.map((t) => ({
key: t.key,
label: t.label,
// Keep every pane mounted so each table holds its own page/sort and
// switching tabs neither refetches nor resets the others.
forceRender: true,
children: (
<InstancesBreakdownTable
key={t.key}
groupKey={t.key}
dateRange={dateRange}
scope={scope}
selectedUsers={selectedUsers}
selectedInstances={selectedInstances}
selectedOrganizations={selectedOrganizations}
selectedUserGroups={selectedUserGroups}
pageResetKey={pageResetKey}
refreshKey={refreshKey}
/>
)
})),
// Enterprise Organization breakdown sub-tab(s) — appended after the
// built-in tabs; nothing here in the OSS build.
...extraBreakdownTabs
.filter((_, i) => extraTabVisible[i])
.map((t) => ({
key: t.key,
label: intl.formatMessage({ id: t.labelId }),
forceRender: true,
children: (
<t.Component
tab="gpu-instances"
dateRange={dateRange}
scope={scope}
filters={{
...(selectedUsers.length
? { creator_ids: selectedUsers }
: {}),
...(selectedInstances.length
? { instance_ids: selectedInstances }
: {}),
...(selectedOrganizations.length
? { organization_ids: selectedOrganizations }
: {}),
...(selectedUserGroups.length
? { user_group_ids: selectedUserGroups }
: {})
}}
pageResetKey={pageResetKey}
refreshKey={refreshKey}
/>
)
}))
]}
/>
<ResourceExportData
@@ -477,6 +580,10 @@ const GpuInstancesTab: React.FC = () => {
initialDateRange={dateRange}
initialSelectedUsers={selectedUsers}
initialSelectedResources={selectedInstances}
organizationOptions={organizations}
userGroupOptions={userGroups}
initialSelectedOrganizations={selectedOrganizations}
initialSelectedUserGroups={selectedUserGroups}
deletedNameFields={[
// The row's ``deleted`` is the grouped instance; the owner user
// carries its own ``user_deleted``.
@@ -24,6 +24,9 @@ interface Props {
scope: Scope;
selectedUsers: number[];
selectedInstances: number[];
// Platform-wide "All" view only (enterprise-gated); empty otherwise.
selectedOrganizations?: number[];
selectedUserGroups?: number[];
// Bumped by the parent when a filter changes, so each mounted table snaps
// back to page 1 independently.
pageResetKey?: number;
@@ -42,6 +45,8 @@ const InstancesBreakdownTable: React.FC<Props> = ({
scope,
selectedUsers,
selectedInstances,
selectedOrganizations = [],
selectedUserGroups = [],
pageResetKey = 0,
refreshKey = 0
}) => {
@@ -101,11 +106,20 @@ const InstancesBreakdownTable: React.FC<Props> = ({
end_date: dateRange[1].format('YYYY-MM-DD'),
scope,
filters:
selectedUsers.length || selectedInstances.length
selectedUsers.length ||
selectedInstances.length ||
selectedOrganizations.length ||
selectedUserGroups.length
? {
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
...(selectedInstances.length
? { instance_ids: selectedInstances }
: {}),
...(selectedOrganizations.length
? { organization_ids: selectedOrganizations }
: {}),
...(selectedUserGroups.length
? { user_group_ids: selectedUserGroups }
: {})
}
: undefined,
@@ -120,6 +134,8 @@ const InstancesBreakdownTable: React.FC<Props> = ({
scope,
selectedUsers,
selectedInstances,
selectedOrganizations,
selectedUserGroups,
queryParams.page,
queryParams.perPage,
queryParams.sort_by,
@@ -34,10 +34,14 @@ export default function useQueryUsageMetaData() {
users: UserOptionType[];
api_keys: GroupOption<UsageFilterItem>[];
routes: RouteOptionType[];
organizations: RouteOptionType[];
user_groups: RouteOptionType[];
}>({
users: [],
api_keys: [],
routes: []
routes: [],
organizations: [],
user_groups: []
});
// Current account first, deleted entries last, everything else keeps its
@@ -87,7 +91,21 @@ export default function useQueryUsageMetaData() {
...item,
value: optionValue(item.identity.current?.route_id, index)
}))
.sort((a, b) => Number(!!a.deleted) - Number(!!b.deleted)) || []
.sort((a, b) => Number(!!a.deleted) - Number(!!b.deleted)) || [],
// Platform-wide "All" view only (backend returns these empty otherwise).
// Deleted orgs sink to the bottom; groups are never flagged deleted.
organizations:
(res?.filters?.organizations || [])
.map((item, index) => ({
...item,
value: optionValue(item.identity.current?.organization_id, index)
}))
.sort((a, b) => Number(!!a.deleted) - Number(!!b.deleted)) || [],
user_groups:
(res?.filters?.user_groups || []).map((item, index) => ({
...item,
value: optionValue(item.identity.current?.group_id, index)
})) || []
};
setResult(data);
};
+132 -25
View File
@@ -12,6 +12,7 @@
* so there's no phase filter — just date / scope / volume / user.
*/
import useCoolColors from '@/hooks/use-cool-colors';
import { getGPUStackPlugin } from '@/plugins';
import { formatLargeNumber } from '@/utils';
import { SimpleCard } from '@gpustack/core-ui';
import { useAccess, useIntl } from '@umijs/max';
@@ -47,6 +48,29 @@ type Scope = 'self' | 'all';
type Metric = 'storage_gb_days' | 'storage_gb_hours';
type GroupKey = 'volume' | 'user';
// Enterprise-provided extra bottom sub-tab (e.g. the Organization breakdown).
// Registered on the enterprise plugin under ``usage.resourceBreakdownExtraTabs``;
// empty when no plugin is loaded, so the OSS build renders nothing extra.
interface ResourceBreakdownExtraTab {
key: string;
labelId: string;
useVisible?: () => boolean;
Component: React.ComponentType<{
tab: 'gpu-instances' | 'storage';
dateRange: [dayjs.Dayjs, dayjs.Dayjs];
scope: Scope;
filters: {
creator_ids?: number[];
instance_ids?: number[];
volume_ids?: number[];
organization_ids?: number[];
user_group_ids?: number[];
};
pageResetKey?: number;
refreshKey?: number;
}>;
}
const StorageTab: React.FC = () => {
const access = useAccess();
const intl = useIntl();
@@ -96,15 +120,35 @@ const StorageTab: React.FC = () => {
]);
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
const [selectedVolumes, setSelectedVolumes] = useState<number[]>([]);
// Platform-wide "All" view only (enterprise-gated); empty otherwise.
const [selectedOrganizations, setSelectedOrganizations] = useState<number[]>(
[]
);
const [selectedUserGroups, setSelectedUserGroups] = useState<number[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
const [metric, setMetric] = useState<Metric>('storage_gb_days');
const [granularity, setGranularity] = useState<Granularity>('day');
// Optional trend group-by (split the chart into one series per group).
const [chartGroupBy, setChartGroupBy] = useState<GroupKey | null>(null);
const [activeTableTab, setActiveTableTab] = useState<GroupKey>('volume');
// Widened to ``string``: enterprise extra tabs use arbitrary keys.
const [activeTableTab, setActiveTableTab] = useState<string>('volume');
const { creators: userOptions, volumes: volumeOptions } =
useResourceMeta(scope);
const {
creators: userOptions,
volumes: volumeOptions,
organizations,
user_groups: userGroups
} = useResourceMeta(scope);
// Enterprise-provided extra bottom sub-tabs (empty in the OSS build). Call
// each descriptor's ``useVisible`` in a stable order — the descriptor list is
// registered once at plugin init, so its length never changes (rules of
// hooks).
const extraBreakdownTabs: ResourceBreakdownExtraTab[] =
getGPUStackPlugin()?.usage?.resourceBreakdownExtraTabs ?? [];
const extraTabVisible = extraBreakdownTabs.map(
(t) => t.useVisible?.() ?? true
);
// Bumped on any filter change to snap every mounted table back to page 1;
// each table owns its own page/sort state otherwise.
@@ -122,10 +166,19 @@ const StorageTab: React.FC = () => {
scope,
granularity,
filters:
selectedUsers.length || selectedVolumes.length
selectedUsers.length ||
selectedVolumes.length ||
selectedOrganizations.length ||
selectedUserGroups.length
? {
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
...(selectedVolumes.length ? { volume_ids: selectedVolumes } : {})
...(selectedVolumes.length ? { volume_ids: selectedVolumes } : {}),
...(selectedOrganizations.length
? { organization_ids: selectedOrganizations }
: {}),
...(selectedUserGroups.length
? { user_group_ids: selectedUserGroups }
: {})
}
: undefined,
page: 1,
@@ -152,6 +205,8 @@ const StorageTab: React.FC = () => {
dateRange,
selectedUsers,
selectedVolumes,
selectedOrganizations,
selectedUserGroups,
granularity,
chartGroupBy,
refreshKey
@@ -378,6 +433,18 @@ const StorageTab: React.FC = () => {
},
placeholder: intl.formatMessage({ id: 'usage.filter.storage' })
}}
organizationOptions={organizations}
userGroupOptions={userGroups}
selectedOrganizations={selectedOrganizations}
selectedUserGroups={selectedUserGroups}
onOrganizationsChange={(ids) => {
setSelectedOrganizations(ids);
setPageResetKey((k) => k + 1);
}}
onUserGroupsChange={(ids) => {
setSelectedUserGroups(ids);
setPageResetKey((k) => k + 1);
}}
onRefresh={() => setRefreshKey((k) => k + 1)}
onExportChart={() => setExportMode('chart')}
onExportTable={handleExportTable}
@@ -415,26 +482,62 @@ const StorageTab: React.FC = () => {
<Tabs
activeKey={activeTableTab}
onChange={(k) => setActiveTableTab(k as GroupKey)}
items={TABLE_TABS.map((t) => ({
key: t.key,
label: t.label,
// Keep every pane mounted so each table holds its own page/sort and
// switching tabs neither refetches nor resets the other.
forceRender: true,
children: (
<StorageBreakdownTable
key={t.key}
groupKey={t.key}
dateRange={dateRange}
scope={scope}
selectedUsers={selectedUsers}
selectedVolumes={selectedVolumes}
pageResetKey={pageResetKey}
refreshKey={refreshKey}
/>
)
}))}
onChange={(k) => setActiveTableTab(k)}
items={[
...TABLE_TABS.map((t) => ({
key: t.key,
label: t.label,
// Keep every pane mounted so each table holds its own page/sort and
// switching tabs neither refetches nor resets the other.
forceRender: true,
children: (
<StorageBreakdownTable
key={t.key}
groupKey={t.key}
dateRange={dateRange}
scope={scope}
selectedUsers={selectedUsers}
selectedVolumes={selectedVolumes}
selectedOrganizations={selectedOrganizations}
selectedUserGroups={selectedUserGroups}
pageResetKey={pageResetKey}
refreshKey={refreshKey}
/>
)
})),
// Enterprise Organization breakdown sub-tab(s) — appended after the
// built-in tabs; nothing here in the OSS build.
...extraBreakdownTabs
.filter((_, i) => extraTabVisible[i])
.map((t) => ({
key: t.key,
label: intl.formatMessage({ id: t.labelId }),
forceRender: true,
children: (
<t.Component
tab="storage"
dateRange={dateRange}
scope={scope}
filters={{
...(selectedUsers.length
? { creator_ids: selectedUsers }
: {}),
...(selectedVolumes.length
? { volume_ids: selectedVolumes }
: {}),
...(selectedOrganizations.length
? { organization_ids: selectedOrganizations }
: {}),
...(selectedUserGroups.length
? { user_group_ids: selectedUserGroups }
: {})
}}
pageResetKey={pageResetKey}
refreshKey={refreshKey}
/>
)
}))
]}
/>
<ResourceExportData
@@ -457,6 +560,10 @@ const StorageTab: React.FC = () => {
initialDateRange={dateRange}
initialSelectedUsers={selectedUsers}
initialSelectedResources={selectedVolumes}
organizationOptions={organizations}
userGroupOptions={userGroups}
initialSelectedOrganizations={selectedOrganizations}
initialSelectedUserGroups={selectedUserGroups}
deletedNameFields={[
// The row's ``deleted`` is the grouped volume; the owner user
// carries its own ``user_deleted``.
@@ -28,6 +28,9 @@ interface Props {
scope: Scope;
selectedUsers: number[];
selectedVolumes: number[];
// Platform-wide "All" view only (enterprise-gated); empty otherwise.
selectedOrganizations?: number[];
selectedUserGroups?: number[];
// Bumped by the parent when a filter changes, so each mounted table snaps
// back to page 1 independently.
pageResetKey?: number;
@@ -46,6 +49,8 @@ const StorageBreakdownTable: React.FC<Props> = ({
scope,
selectedUsers,
selectedVolumes,
selectedOrganizations = [],
selectedUserGroups = [],
pageResetKey = 0,
refreshKey = 0
}) => {
@@ -105,10 +110,21 @@ const StorageBreakdownTable: React.FC<Props> = ({
end_date: dateRange[1].format('YYYY-MM-DD'),
scope,
filters:
selectedUsers.length || selectedVolumes.length
selectedUsers.length ||
selectedVolumes.length ||
selectedOrganizations.length ||
selectedUserGroups.length
? {
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
...(selectedVolumes.length ? { volume_ids: selectedVolumes } : {})
...(selectedVolumes.length
? { volume_ids: selectedVolumes }
: {}),
...(selectedOrganizations.length
? { organization_ids: selectedOrganizations }
: {}),
...(selectedUserGroups.length
? { user_group_ids: selectedUserGroups }
: {})
}
: undefined,
group_by: [groupKey],
@@ -122,6 +138,8 @@ const StorageBreakdownTable: React.FC<Props> = ({
scope,
selectedUsers,
selectedVolumes,
selectedOrganizations,
selectedUserGroups,
queryParams.page,
queryParams.perPage,
queryParams.sort_by,
+57 -12
View File
@@ -51,6 +51,9 @@ type QueryParams = {
start: string;
end: string;
selectedUsers: number[];
// Platform-wide "All" view only (enterprise-gated); empty otherwise.
selectedOrganizations: number[];
selectedUserGroups: number[];
};
// Round to at most 2 decimals everywhere (avoid 1.60999999… in the donut center).
@@ -226,17 +229,25 @@ const SummaryTab: React.FC = () => {
// Date range + user filter live together: every fetch keys off all three, so
// a single object keeps them in sync and trims the dependency arrays.
const [queryParams, setQueryParams] = useState<{
start: string;
end: string;
selectedUsers: number[];
}>({
const [queryParams, setQueryParams] = useState<QueryParams>({
start: dayjs().subtract(29, 'day').format('YYYY-MM-DD'),
end: dayjs().format('YYYY-MM-DD'),
selectedUsers: []
selectedUsers: [],
selectedOrganizations: [],
selectedUserGroups: []
});
const { start, end, selectedUsers } = queryParams;
const { creators: resourceUsers } = useResourceMeta(scope);
const {
start,
end,
selectedUsers,
selectedOrganizations,
selectedUserGroups
} = queryParams;
const {
creators: resourceUsers,
organizations,
user_groups: userGroups
} = useResourceMeta(scope);
const { detailData: tokenMeta, fetchData: fetchTokenMeta } =
useQueryUsageMetaData();
@@ -336,10 +347,24 @@ const SummaryTab: React.FC = () => {
setQueryParams(currentParams);
}
// "filter by user" — restricts every resource fetch to these creator ids.
const creatorFilter = currentParams.selectedUsers.length
? { creator_ids: currentParams.selectedUsers }
: undefined;
// "filter by user" (+ enterprise org / user-group) — restricts every
// resource fetch to these creator / org / group ids.
const creatorFilter =
currentParams.selectedUsers.length ||
currentParams.selectedOrganizations.length ||
currentParams.selectedUserGroups.length
? {
...(currentParams.selectedUsers.length
? { creator_ids: currentParams.selectedUsers }
: {}),
...(currentParams.selectedOrganizations.length
? { organization_ids: currentParams.selectedOrganizations }
: {}),
...(currentParams.selectedUserGroups.length
? { user_group_ids: currentParams.selectedUserGroups }
: {})
}
: undefined;
// The token series hits /usage/breakdown, which filters users by identity
// rather than the creator_ids the resource endpoints take — so the token
@@ -364,6 +389,12 @@ const SummaryTab: React.FC = () => {
...commonParams,
creator_ids: currentParams.selectedUsers.length
? currentParams.selectedUsers
: undefined,
organization_ids: currentParams.selectedOrganizations.length
? currentParams.selectedOrganizations
: undefined,
user_group_ids: currentParams.selectedUserGroups.length
? currentParams.selectedUserGroups
: undefined
}),
@@ -487,6 +518,14 @@ const SummaryTab: React.FC = () => {
fetchAll({ selectedUsers: users });
};
const handleOrganizationsChange = (ids: number[]) => {
fetchAll({ selectedOrganizations: ids });
};
const handleUserGroupsChange = (ids: number[]) => {
fetchAll({ selectedUserGroups: ids });
};
const onRefresh = () => {
fetchAll();
};
@@ -505,6 +544,12 @@ const SummaryTab: React.FC = () => {
userOptions={userOptions}
selectedUsers={selectedUsers}
onUsersChange={handleUserFilterChange}
organizationOptions={organizations}
userGroupOptions={userGroups}
selectedOrganizations={selectedOrganizations}
selectedUserGroups={selectedUserGroups}
onOrganizationsChange={handleOrganizationsChange}
onUserGroupsChange={handleUserGroupsChange}
onRefresh={onRefresh}
/>
<div style={{ height: 24 }} />
@@ -1,3 +1,4 @@
import { getGPUStackPlugin } from '@/plugins';
import { useIntl } from '@umijs/max';
import { Tabs } from 'antd';
import React, { useMemo } from 'react';
@@ -6,6 +7,24 @@ import ApiKeysTable from '../tables/apikeys-table';
import ModelsTable from '../tables/models-table';
import UsersTable from '../tables/users-table';
// A breakdown sub-tab contributed by a plugin (e.g. the enterprise
// Organization tab). ``useVisible`` is a React hook the host calls
// unconditionally per descriptor (stable array length → hook-safe) so the
// plugin can gate visibility on its own runtime state (e.g. platform-wide
// "All" context).
export interface BreakdownExtraTab {
key: string;
labelId: string;
useVisible?: () => boolean;
Component: React.ComponentType<{
filters: BreakdownFilters;
dateRange: { start_date: string; end_date: string };
scope: string;
pageResetKey?: number;
refreshKey?: number;
}>;
}
const BreakdownTabs: React.FC<{
dateRange: {
start_date: string;
@@ -18,7 +37,37 @@ const BreakdownTabs: React.FC<{
}> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => {
const intl = useIntl();
const extraTabs: BreakdownExtraTab[] =
getGPUStackPlugin()?.usage?.breakdownExtraTabs ?? [];
// Call each descriptor's visibility hook here (outside useMemo) so React's
// rules of hooks hold; the plugin's array is stable, so call order is too.
const extraVisible = extraTabs.map((tab) =>
tab.useVisible ? tab.useVisible() : true
);
const items = useMemo(() => {
const extraItems = extraTabs
.map((tab, index) => ({ tab, visible: extraVisible[index] }))
.filter(({ visible }) => visible)
.map(({ tab }) => {
const Component = tab.Component;
return {
key: tab.key,
label: intl.formatMessage({ id: tab.labelId }),
forceRender: true,
children: (
<Component
key={tab.key}
filters={filters}
dateRange={dateRange}
scope={scope}
pageResetKey={pageResetKey}
refreshKey={refreshKey}
/>
)
};
});
return [
{
key: 'models',
@@ -65,13 +114,24 @@ const BreakdownTabs: React.FC<{
/>
)
}
].filter((item) => {
if (item.key === 'users') {
return scope === 'all';
}
return true;
});
}, [filters, dateRange, pageResetKey, refreshKey, scope]);
]
.filter((item) => {
if (item.key === 'users') {
return scope === 'all';
}
return true;
})
.concat(extraItems);
}, [
filters,
dateRange,
pageResetKey,
refreshKey,
scope,
extraTabs,
extraVisible,
intl
]);
return (
<div style={{ marginTop: 16 }}>
+8
View File
@@ -178,6 +178,14 @@ const TokenTab: React.FC = () => {
filterBar.onApiKeysChange(value);
handleBreakdownPageReset();
}}
onOrganizationsChange={(value) => {
filterBar.onOrganizationsChange(value);
handleBreakdownPageReset();
}}
onUserGroupsChange={(value) => {
filterBar.onUserGroupsChange(value);
handleBreakdownPageReset();
}}
handleSearch={handleSearch}
handlePickerChange={handlePickerChange}
onExportTable={exportTable}