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:
@@ -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
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user