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[];
|
instance_ids?: number[];
|
||||||
gpu_types?: string[];
|
gpu_types?: string[];
|
||||||
volume_ids?: number[];
|
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 {
|
export interface ResourceBreakdownRequest {
|
||||||
@@ -65,6 +69,10 @@ export interface ResourceBreakdownItem extends ResourceBreakdownSummary {
|
|||||||
volume_name?: string;
|
volume_name?: string;
|
||||||
user_id?: number;
|
user_id?: number;
|
||||||
user_name?: string;
|
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
|
// The grouped entity (instance / volume / user) no longer exists. The name
|
||||||
// fields keep the clean (stale) name; the tables show a DeletedTag off this
|
// fields keep the clean (stale) name; the tables show a DeletedTag off this
|
||||||
// flag plus the id, matching the Tokens tab.
|
// flag plus the id, matching the Tokens tab.
|
||||||
@@ -261,6 +269,7 @@ const GROUP_BY_MAP: Record<string, string> = {
|
|||||||
instance: 'instance',
|
instance: 'instance',
|
||||||
volume: 'volume',
|
volume: 'volume',
|
||||||
user: 'user',
|
user: 'user',
|
||||||
|
organization: 'organization',
|
||||||
date: 'date'
|
date: 'date'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -334,6 +343,10 @@ function flattenItem(
|
|||||||
flat.user_name = rawKey;
|
flat.user_name = rawKey;
|
||||||
flat.user_id = id;
|
flat.user_id = id;
|
||||||
break;
|
break;
|
||||||
|
case 'organization':
|
||||||
|
flat.organization_name = rawKey;
|
||||||
|
flat.organization_id = id;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -393,7 +406,13 @@ function flattenResponse(
|
|||||||
|
|
||||||
function toServerRequest(data: ResourceBreakdownRequest) {
|
function toServerRequest(data: ResourceBreakdownRequest) {
|
||||||
const groupByList = data.group_by?.length ? data.group_by : ['resource_type'];
|
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.
|
// The non-date dimension drives response flattening into the right field.
|
||||||
const dim = groupByList.find((g) => g !== 'date');
|
const dim = groupByList.find((g) => g !== 'date');
|
||||||
return {
|
return {
|
||||||
@@ -408,6 +427,8 @@ function toServerRequest(data: ResourceBreakdownRequest) {
|
|||||||
...(creator_ids?.length ? { creator_ids } : {}),
|
...(creator_ids?.length ? { creator_ids } : {}),
|
||||||
...(instance_ids?.length ? { instance_ids } : {}),
|
...(instance_ids?.length ? { instance_ids } : {}),
|
||||||
...(volume_ids?.length ? { volume_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.order_by ? { order_by: data.order_by } : {}),
|
||||||
...(data.descending !== undefined ? { descending: data.descending } : {}),
|
...(data.descending !== undefined ? { descending: data.descending } : {}),
|
||||||
page: data.page ?? 1,
|
page: data.page ?? 1,
|
||||||
@@ -477,6 +498,8 @@ export async function queryResourceEvents(
|
|||||||
options?: { skipErrorHandler?: boolean; token?: any }
|
options?: { skipErrorHandler?: boolean; token?: any }
|
||||||
): Promise<ResourceEventsResponse> {
|
): Promise<ResourceEventsResponse> {
|
||||||
const creatorIds = data.filters?.creator_ids;
|
const creatorIds = data.filters?.creator_ids;
|
||||||
|
const organizationIds = data.filters?.organization_ids;
|
||||||
|
const userGroupIds = data.filters?.user_group_ids;
|
||||||
return request<ResourceEventsResponse>(URL.EVENTS, {
|
return request<ResourceEventsResponse>(URL.EVENTS, {
|
||||||
params: {
|
params: {
|
||||||
start_date: data.start_date,
|
start_date: data.start_date,
|
||||||
@@ -486,6 +509,12 @@ export async function queryResourceEvents(
|
|||||||
// GET endpoints take list params as CSV strings (avoids axios array
|
// GET endpoints take list params as CSV strings (avoids axios array
|
||||||
// serialization quirks); the server splits them back into lists.
|
// serialization quirks); the server splits them back into lists.
|
||||||
...(creatorIds?.length ? { creator_ids: creatorIds.join(',') } : {}),
|
...(creatorIds?.length ? { creator_ids: creatorIds.join(',') } : {}),
|
||||||
|
...(organizationIds?.length
|
||||||
|
? { organization_ids: organizationIds.join(',') }
|
||||||
|
: {}),
|
||||||
|
...(userGroupIds?.length
|
||||||
|
? { user_group_ids: userGroupIds.join(',') }
|
||||||
|
: {}),
|
||||||
...(data.event_types?.length
|
...(data.event_types?.length
|
||||||
? { event_types: data.event_types.join(',') }
|
? { event_types: data.event_types.join(',') }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -503,12 +532,18 @@ export interface ResourceFilterOption {
|
|||||||
id: number;
|
id: number;
|
||||||
label: string;
|
label: string;
|
||||||
deleted?: boolean;
|
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 {
|
export interface ResourceFilterMeta {
|
||||||
creators: ResourceFilterOption[];
|
creators: ResourceFilterOption[];
|
||||||
instances: ResourceFilterOption[];
|
instances: ResourceFilterOption[];
|
||||||
volumes: ResourceFilterOption[];
|
volumes: ResourceFilterOption[];
|
||||||
|
// Platform-wide "All" view only (backend returns them empty otherwise).
|
||||||
|
organizations: ResourceFilterOption[];
|
||||||
|
user_groups: ResourceFilterOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryResourceFilterMeta(
|
export async function queryResourceFilterMeta(
|
||||||
@@ -521,7 +556,9 @@ export async function queryResourceFilterMeta(
|
|||||||
return {
|
return {
|
||||||
creators: res.creators || [],
|
creators: res.creators || [],
|
||||||
instances: res.instances || [],
|
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;
|
end_date: string;
|
||||||
scope?: 'self' | 'all';
|
scope?: 'self' | 'all';
|
||||||
creator_ids?: number[];
|
creator_ids?: number[];
|
||||||
|
organization_ids?: number[];
|
||||||
|
user_group_ids?: number[];
|
||||||
},
|
},
|
||||||
options?: { token?: any }
|
options?: { token?: any }
|
||||||
): Promise<UsageSummaryResponse> {
|
): Promise<UsageSummaryResponse> {
|
||||||
const { creator_ids, ...rest } = params;
|
const { creator_ids, organization_ids, user_group_ids, ...rest } = params;
|
||||||
const res = await request<{
|
const res = await request<{
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
@@ -548,7 +587,13 @@ export async function queryUsageSummary(
|
|||||||
params: {
|
params: {
|
||||||
...rest,
|
...rest,
|
||||||
scope: params.scope ?? 'all',
|
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',
|
method: 'GET',
|
||||||
cancelToken: options?.token
|
cancelToken: options?.token
|
||||||
@@ -565,7 +610,17 @@ export async function queryUsageSummary(
|
|||||||
end_date: params.end_date,
|
end_date: params.end_date,
|
||||||
scope: params.scope ?? 'all',
|
scope: params.scope ?? 'all',
|
||||||
group_by: ['gpu_type'],
|
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,
|
page: 1,
|
||||||
perPage: 100
|
perPage: 100
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||||
import useRangePickerPreset from '@/pages/dashboard/hooks/use-rangepicker-preset';
|
import useRangePickerPreset from '@/pages/dashboard/hooks/use-rangepicker-preset';
|
||||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +38,14 @@ interface FilterBarProps {
|
|||||||
routeOptions: OptionType[];
|
routeOptions: OptionType[];
|
||||||
userOptions: OptionType[];
|
userOptions: OptionType[];
|
||||||
apiKeyOptions: GroupOption<UsageFilterItem>[];
|
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[][];
|
activeApiKeys: valueType[][];
|
||||||
handlePickerChange: (picker: DateType) => void;
|
handlePickerChange: (picker: DateType) => void;
|
||||||
onScopeChange: (value: string) => void;
|
onScopeChange: (value: string) => void;
|
||||||
@@ -77,6 +86,12 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
|
|||||||
onRoutesChange,
|
onRoutesChange,
|
||||||
onUsersChange,
|
onUsersChange,
|
||||||
onApiKeysChange,
|
onApiKeysChange,
|
||||||
|
organizationOptions,
|
||||||
|
userGroupOptions,
|
||||||
|
selectedOrganizations,
|
||||||
|
selectedUserGroups,
|
||||||
|
onOrganizationsChange,
|
||||||
|
onUserGroupsChange,
|
||||||
onExportChart,
|
onExportChart,
|
||||||
onExportTable,
|
onExportTable,
|
||||||
handleSearch
|
handleSearch
|
||||||
@@ -345,6 +360,21 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
|
|||||||
onChange={onApiKeysChange}
|
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
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
style={{ color: 'var(--ant-color-text-tertiary)' }}
|
style={{ color: 'var(--ant-color-text-tertiary)' }}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ type Scope = 'self' | 'all';
|
|||||||
interface SelectOption {
|
interface SelectOption {
|
||||||
value: number;
|
value: number;
|
||||||
label: string;
|
label: string;
|
||||||
|
deleted?: boolean;
|
||||||
|
// ``org`` / ``user`` / ``group`` — set on organization options for the tag.
|
||||||
|
kind?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResourceExportDataProps {
|
interface ResourceExportDataProps {
|
||||||
@@ -62,6 +65,12 @@ interface ResourceExportDataProps {
|
|||||||
initialDateRange: [dayjs.Dayjs, dayjs.Dayjs];
|
initialDateRange: [dayjs.Dayjs, dayjs.Dayjs];
|
||||||
initialSelectedUsers: number[];
|
initialSelectedUsers: number[];
|
||||||
initialSelectedResources: 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.
|
// 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
|
// 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
|
// (date + instance/volume) row can mark the instance/volume and its owner
|
||||||
@@ -92,6 +101,10 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
|||||||
initialDateRange,
|
initialDateRange,
|
||||||
initialSelectedUsers,
|
initialSelectedUsers,
|
||||||
initialSelectedResources,
|
initialSelectedResources,
|
||||||
|
organizationOptions = [],
|
||||||
|
userGroupOptions = [],
|
||||||
|
initialSelectedOrganizations = [],
|
||||||
|
initialSelectedUserGroups = [],
|
||||||
deletedNameFields
|
deletedNameFields
|
||||||
} = props;
|
} = props;
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -103,6 +116,12 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
|||||||
const [selectedResources, setSelectedResources] = useState<number[]>(
|
const [selectedResources, setSelectedResources] = useState<number[]>(
|
||||||
initialSelectedResources
|
initialSelectedResources
|
||||||
);
|
);
|
||||||
|
const [selectedOrganizations, setSelectedOrganizations] = useState<number[]>(
|
||||||
|
initialSelectedOrganizations
|
||||||
|
);
|
||||||
|
const [selectedUserGroups, setSelectedUserGroups] = useState<number[]>(
|
||||||
|
initialSelectedUserGroups
|
||||||
|
);
|
||||||
const [pageParams, setPageParams] = useState(INITIAL_PAGE);
|
const [pageParams, setPageParams] = useState(INITIAL_PAGE);
|
||||||
const [data, setData] = useState<ResourceBreakdownResponse | null>(null);
|
const [data, setData] = useState<ResourceBreakdownResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -118,11 +137,20 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
|||||||
group_by: groupBy,
|
group_by: groupBy,
|
||||||
granularity: 'day',
|
granularity: 'day',
|
||||||
filters:
|
filters:
|
||||||
selectedUsers.length || selectedResources.length
|
selectedUsers.length ||
|
||||||
|
selectedResources.length ||
|
||||||
|
selectedOrganizations.length ||
|
||||||
|
selectedUserGroups.length
|
||||||
? {
|
? {
|
||||||
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
|
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
|
||||||
...(selectedResources.length
|
...(selectedResources.length
|
||||||
? { [resourceFilter.key]: selectedResources }
|
? { [resourceFilter.key]: selectedResources }
|
||||||
|
: {}),
|
||||||
|
...(selectedOrganizations.length
|
||||||
|
? { organization_ids: selectedOrganizations }
|
||||||
|
: {}),
|
||||||
|
...(selectedUserGroups.length
|
||||||
|
? { user_group_ids: selectedUserGroups }
|
||||||
: {})
|
: {})
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -146,6 +174,8 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
|||||||
setDateRange(initialDateRange);
|
setDateRange(initialDateRange);
|
||||||
setSelectedUsers(initialSelectedUsers);
|
setSelectedUsers(initialSelectedUsers);
|
||||||
setSelectedResources(initialSelectedResources);
|
setSelectedResources(initialSelectedResources);
|
||||||
|
setSelectedOrganizations(initialSelectedOrganizations);
|
||||||
|
setSelectedUserGroups(initialSelectedUserGroups);
|
||||||
setPageParams(INITIAL_PAGE);
|
setPageParams(INITIAL_PAGE);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
@@ -153,7 +183,15 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
fetchPreview(pageParams.page, pageParams.perPage);
|
fetchPreview(pageParams.page, pageParams.perPage);
|
||||||
}, [open, dateRange, selectedUsers, selectedResources, pageParams]);
|
}, [
|
||||||
|
open,
|
||||||
|
dateRange,
|
||||||
|
selectedUsers,
|
||||||
|
selectedResources,
|
||||||
|
selectedOrganizations,
|
||||||
|
selectedUserGroups,
|
||||||
|
pageParams
|
||||||
|
]);
|
||||||
|
|
||||||
const previewColumns = useMemo(
|
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) => {
|
const handlePageChange = (page: number, perPage: number) => {
|
||||||
setPageParams({ page, perPage });
|
setPageParams({ page, perPage });
|
||||||
@@ -210,7 +260,7 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
|||||||
try {
|
try {
|
||||||
const res = await queryFn(buildRequest(-1, INITIAL_PAGE.perPage));
|
const res = await queryFn(buildRequest(-1, INITIAL_PAGE.perPage));
|
||||||
exportBreakdownRows(
|
exportBreakdownRows(
|
||||||
markRows(res.items ?? []),
|
formatRowDates(markRows(res.items ?? [])),
|
||||||
toExportColumns(columns),
|
toExportColumns(columns),
|
||||||
fileName,
|
fileName,
|
||||||
sheetName
|
sheetName
|
||||||
@@ -272,6 +322,18 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
|||||||
},
|
},
|
||||||
placeholder: resourceFilter.placeholder
|
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>
|
</div>
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* ``extra`` lets a tab append its own filters (e.g. Resource Events' resource
|
* ``extra`` lets a tab append its own filters (e.g. Resource Events' resource
|
||||||
* type / event type) inline, keeping one consistent bar.
|
* 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 useRangePickerPreset from '@/pages/dashboard/hooks/use-rangepicker-preset';
|
||||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||||
import { AutoTooltip, IconFont, SimpleSelect } from '@gpustack/core-ui';
|
import { AutoTooltip, IconFont, SimpleSelect } from '@gpustack/core-ui';
|
||||||
@@ -54,6 +55,14 @@ interface ResourceFilterBarProps {
|
|||||||
onExportChart?: () => void;
|
onExportChart?: () => void;
|
||||||
onExportTable?: () => void;
|
onExportTable?: () => void;
|
||||||
extra?: React.ReactNode;
|
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) => {
|
const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
|
||||||
@@ -68,7 +77,13 @@ const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
onExportChart,
|
onExportChart,
|
||||||
onExportTable,
|
onExportTable,
|
||||||
extra
|
extra,
|
||||||
|
organizationOptions,
|
||||||
|
userGroupOptions,
|
||||||
|
selectedOrganizations,
|
||||||
|
selectedUserGroups,
|
||||||
|
onOrganizationsChange,
|
||||||
|
onUserGroupsChange
|
||||||
} = props;
|
} = props;
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
@@ -216,6 +231,21 @@ const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{extra}
|
{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 && (
|
{onRefresh && (
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -9,11 +9,16 @@ export interface UsageFilterItem {
|
|||||||
provider_type: string | null;
|
provider_type: string | null;
|
||||||
provider_name: string | null;
|
provider_name: string | null;
|
||||||
route_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: {
|
current: {
|
||||||
user_id: number | null;
|
user_id: number | null;
|
||||||
api_key_id: string | null;
|
api_key_id: string | null;
|
||||||
route_id: number | null;
|
route_id: number | null;
|
||||||
|
organization_id?: number | null;
|
||||||
|
group_id?: number | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
label: string;
|
label: string;
|
||||||
@@ -64,6 +69,7 @@ export type BreakdownItem = {
|
|||||||
model: UsageFilterItem;
|
model: UsageFilterItem;
|
||||||
route: UsageFilterItem;
|
route: UsageFilterItem;
|
||||||
api_key: UsageFilterItem;
|
api_key: UsageFilterItem;
|
||||||
|
organization: UsageFilterItem;
|
||||||
date: {
|
date: {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -88,16 +94,23 @@ export interface UsageMeta {
|
|||||||
users: UsageFilterItem[];
|
users: UsageFilterItem[];
|
||||||
api_keys: UsageFilterItem[];
|
api_keys: UsageFilterItem[];
|
||||||
routes: UsageFilterItem[];
|
routes: UsageFilterItem[];
|
||||||
|
// Platform-wide "All" view only; empty otherwise (backend-gated).
|
||||||
|
organizations?: UsageFilterItem[];
|
||||||
|
user_groups?: UsageFilterItem[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FilterOptionType = Omit<UsageFilterItem, 'label' | 'deleted'>;
|
export type FilterOptionType = Omit<UsageFilterItem, 'label' | 'deleted'>;
|
||||||
|
|
||||||
// The full breakdown filter set (route / user / api_key). Every breakdown
|
// The full breakdown filter set (route / user / api_key + org / user_group).
|
||||||
// table sends all active dimensions — matching the trend chart — so e.g. a
|
// Every breakdown table sends all active dimensions — matching the trend
|
||||||
// user filter narrows the Models table too, not only the Users table.
|
// 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 = {
|
export type BreakdownFilters = {
|
||||||
routes?: FilterOptionType[];
|
routes?: FilterOptionType[];
|
||||||
users?: FilterOptionType[];
|
users?: FilterOptionType[];
|
||||||
api_keys?: 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]"
|
// The signed-in user's own entry, sorted first and tagged "[Current Account]"
|
||||||
// in the filter dropdown (matches the Tokens tab).
|
// in the filter dropdown (matches the Tokens tab).
|
||||||
isCurrent?: boolean;
|
isCurrent?: boolean;
|
||||||
|
// ``org`` / ``user`` / ``group`` — set on organization options so the filter
|
||||||
|
// can tag a personal (USER) consumer.
|
||||||
|
kind?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceMetaOptions {
|
export interface ResourceMetaOptions {
|
||||||
creators: SelectOption[];
|
creators: SelectOption[];
|
||||||
instances: SelectOption[];
|
instances: SelectOption[];
|
||||||
volumes: SelectOption[];
|
volumes: SelectOption[];
|
||||||
|
// Platform-wide "All" view only (empty otherwise, backend-gated).
|
||||||
|
organizations: SelectOption[];
|
||||||
|
user_groups: SelectOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: ResourceMetaOptions = {
|
const EMPTY: ResourceMetaOptions = {
|
||||||
creators: [],
|
creators: [],
|
||||||
instances: [],
|
instances: [],
|
||||||
volumes: []
|
volumes: [],
|
||||||
|
organizations: [],
|
||||||
|
user_groups: []
|
||||||
};
|
};
|
||||||
|
|
||||||
// Deleted entries sink to the bottom of the dropdown; live ones keep their
|
// Deleted entries sink to the bottom of the dropdown; live ones keep their
|
||||||
@@ -73,7 +81,22 @@ export default function useResourceMeta(
|
|||||||
setMeta({
|
setMeta({
|
||||||
creators: toUserOptions(res.creators, currentUserId),
|
creators: toUserOptions(res.creators, currentUserId),
|
||||||
instances: toOptions(res.instances),
|
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(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ import { withDeletedMark } from '../utils/deleted-label';
|
|||||||
|
|
||||||
// group dimension → the id field inside ``identity.current`` (null for deleted
|
// group dimension → the id field inside ``identity.current`` (null for deleted
|
||||||
// entities on the Tokens tab, so the marker degrades to just "[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',
|
route: 'route_id',
|
||||||
user: 'user_id',
|
user: 'user_id',
|
||||||
api_key: 'api_key_id'
|
api_key: 'api_key_id',
|
||||||
|
organization: 'organization_id'
|
||||||
};
|
};
|
||||||
|
|
||||||
const DefaultDateConfig = {
|
const DefaultDateConfig = {
|
||||||
@@ -38,6 +42,8 @@ interface UseUsageFiltersParams {
|
|||||||
users?: UserOptionType[];
|
users?: UserOptionType[];
|
||||||
api_keys?: GroupOptionType[];
|
api_keys?: GroupOptionType[];
|
||||||
routes?: RouteOptionType[];
|
routes?: RouteOptionType[];
|
||||||
|
organizations?: RouteOptionType[];
|
||||||
|
user_groups?: RouteOptionType[];
|
||||||
};
|
};
|
||||||
chartFilters: {
|
chartFilters: {
|
||||||
metric: string;
|
metric: string;
|
||||||
@@ -63,12 +69,16 @@ interface UseUsageFiltersParams {
|
|||||||
routes?: FilterOptionType[];
|
routes?: FilterOptionType[];
|
||||||
users?: FilterOptionType[];
|
users?: FilterOptionType[];
|
||||||
api_keys?: FilterOptionType[];
|
api_keys?: FilterOptionType[];
|
||||||
|
organizations?: FilterOptionType[];
|
||||||
|
user_groups?: FilterOptionType[];
|
||||||
};
|
};
|
||||||
commonFilters: {
|
commonFilters: {
|
||||||
scope: string;
|
scope: string;
|
||||||
routes: string[];
|
routes: string[];
|
||||||
users: string[];
|
users: string[];
|
||||||
api_keys: string[];
|
api_keys: string[];
|
||||||
|
organizations: string[];
|
||||||
|
user_groups: string[];
|
||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
};
|
};
|
||||||
@@ -112,6 +122,8 @@ export const useUsageFilters = ({
|
|||||||
routes: initialActiveRoutes,
|
routes: initialActiveRoutes,
|
||||||
users: initialUsers || [],
|
users: initialUsers || [],
|
||||||
api_keys: extractSelectedValues(initialActiveApiKeys),
|
api_keys: extractSelectedValues(initialActiveApiKeys),
|
||||||
|
organizations: [] as string[],
|
||||||
|
user_groups: [] as string[],
|
||||||
start_date:
|
start_date:
|
||||||
start_date ||
|
start_date ||
|
||||||
dayjs()
|
dayjs()
|
||||||
@@ -123,6 +135,8 @@ export const useUsageFilters = ({
|
|||||||
const routeOptions = metaData?.routes || [];
|
const routeOptions = metaData?.routes || [];
|
||||||
const userOptions = metaData?.users || [];
|
const userOptions = metaData?.users || [];
|
||||||
const apiKeyOptions = metaData?.api_keys || [];
|
const apiKeyOptions = metaData?.api_keys || [];
|
||||||
|
const organizationOptions = metaData?.organizations || [];
|
||||||
|
const userGroupOptions = metaData?.user_groups || [];
|
||||||
const [activeApiKeys, setActiveApiKeys] =
|
const [activeApiKeys, setActiveApiKeys] =
|
||||||
useState<ValueType[][]>(initialActiveApiKeys);
|
useState<ValueType[][]>(initialActiveApiKeys);
|
||||||
|
|
||||||
@@ -160,6 +174,8 @@ export const useUsageFilters = ({
|
|||||||
routes?: FilterOptionType[];
|
routes?: FilterOptionType[];
|
||||||
users?: FilterOptionType[];
|
users?: FilterOptionType[];
|
||||||
api_keys?: FilterOptionType[];
|
api_keys?: FilterOptionType[];
|
||||||
|
organizations?: FilterOptionType[];
|
||||||
|
user_groups?: FilterOptionType[];
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (selected.routes.length > 0) {
|
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;
|
return filters;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -267,7 +301,7 @@ export const useUsageFilters = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
type: 'routes' | 'users' | 'api_keys',
|
type: 'routes' | 'users' | 'api_keys' | 'organizations' | 'user_groups',
|
||||||
value: string[]
|
value: string[]
|
||||||
) => {
|
) => {
|
||||||
const selectedValues: string[] = value.map((item) => {
|
const selectedValues: string[] = value.map((item) => {
|
||||||
@@ -364,9 +398,13 @@ export const useUsageFilters = ({
|
|||||||
selectedRoutes: commonFilters.routes,
|
selectedRoutes: commonFilters.routes,
|
||||||
selectedUsers: commonFilters.users,
|
selectedUsers: commonFilters.users,
|
||||||
selectedApiKeys: commonFilters.api_keys,
|
selectedApiKeys: commonFilters.api_keys,
|
||||||
|
selectedOrganizations: commonFilters.organizations,
|
||||||
|
selectedUserGroups: commonFilters.user_groups,
|
||||||
routeOptions,
|
routeOptions,
|
||||||
userOptions,
|
userOptions,
|
||||||
apiKeyOptions,
|
apiKeyOptions,
|
||||||
|
organizationOptions,
|
||||||
|
userGroupOptions,
|
||||||
activeApiKeys,
|
activeApiKeys,
|
||||||
handleSearch,
|
handleSearch,
|
||||||
handleActiveApiKeysChange,
|
handleActiveApiKeysChange,
|
||||||
@@ -375,6 +413,10 @@ export const useUsageFilters = ({
|
|||||||
onRoutesChange: (value: string[]) => handleFilterChange('routes', value),
|
onRoutesChange: (value: string[]) => handleFilterChange('routes', value),
|
||||||
onUsersChange: (value: string[]) => handleFilterChange('users', value),
|
onUsersChange: (value: string[]) => handleFilterChange('users', value),
|
||||||
onApiKeysChange: (value: string[]) => handleFilterChange('api_keys', value),
|
onApiKeysChange: (value: string[]) => handleFilterChange('api_keys', value),
|
||||||
|
onOrganizationsChange: (value: string[]) =>
|
||||||
|
handleFilterChange('organizations', value),
|
||||||
|
onUserGroupsChange: (value: string[]) =>
|
||||||
|
handleFilterChange('user_groups', value),
|
||||||
onExportChart: handleOnExportChart
|
onExportChart: handleOnExportChart
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* Talks to the new ``/usage/gpu-instances/{meta,breakdown}`` endpoints.
|
* Talks to the new ``/usage/gpu-instances/{meta,breakdown}`` endpoints.
|
||||||
*/
|
*/
|
||||||
import useCoolColors from '@/hooks/use-cool-colors';
|
import useCoolColors from '@/hooks/use-cool-colors';
|
||||||
|
import { getGPUStackPlugin } from '@/plugins';
|
||||||
import { formatLargeNumber } from '@/utils';
|
import { formatLargeNumber } from '@/utils';
|
||||||
import { SimpleCard } from '@gpustack/core-ui';
|
import { SimpleCard } from '@gpustack/core-ui';
|
||||||
import { useAccess, useIntl } from '@umijs/max';
|
import { useAccess, useIntl } from '@umijs/max';
|
||||||
@@ -44,6 +45,29 @@ type Scope = 'self' | 'all';
|
|||||||
type Metric = 'gpu_hours' | 'instance_hours';
|
type Metric = 'gpu_hours' | 'instance_hours';
|
||||||
type GroupKey = 'gpu_type' | 'instance' | 'user';
|
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 GpuInstancesTab: React.FC = () => {
|
||||||
const access = useAccess();
|
const access = useAccess();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -99,6 +123,11 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
]);
|
]);
|
||||||
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
|
||||||
const [selectedInstances, setSelectedInstances] = 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 [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [metric, setMetric] = useState<Metric>('gpu_hours');
|
const [metric, setMetric] = useState<Metric>('gpu_hours');
|
||||||
const [granularity, setGranularity] = useState<Granularity>('day');
|
const [granularity, setGranularity] = useState<Granularity>('day');
|
||||||
@@ -106,10 +135,25 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
const [chartGroupBy, setChartGroupBy] = useState<GroupKey | null>(null);
|
const [chartGroupBy, setChartGroupBy] = useState<GroupKey | null>(null);
|
||||||
// ``null`` group_by = no row grouping, just the summary KPIs.
|
// ``null`` group_by = no row grouping, just the summary KPIs.
|
||||||
// The chart needs the ``date`` group; tables use the active table tab.
|
// 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 } =
|
const {
|
||||||
useResourceMeta(scope);
|
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
|
// The daily chart fetches group_by=date here; each bottom table owns its own
|
||||||
// fetch (group_by=tab key) inside InstancesBreakdownTable. Bumped on any
|
// fetch (group_by=tab key) inside InstancesBreakdownTable. Bumped on any
|
||||||
@@ -128,11 +172,20 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
scope,
|
scope,
|
||||||
granularity,
|
granularity,
|
||||||
filters:
|
filters:
|
||||||
selectedUsers.length || selectedInstances.length
|
selectedUsers.length ||
|
||||||
|
selectedInstances.length ||
|
||||||
|
selectedOrganizations.length ||
|
||||||
|
selectedUserGroups.length
|
||||||
? {
|
? {
|
||||||
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
|
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
|
||||||
...(selectedInstances.length
|
...(selectedInstances.length
|
||||||
? { instance_ids: selectedInstances }
|
? { instance_ids: selectedInstances }
|
||||||
|
: {}),
|
||||||
|
...(selectedOrganizations.length
|
||||||
|
? { organization_ids: selectedOrganizations }
|
||||||
|
: {}),
|
||||||
|
...(selectedUserGroups.length
|
||||||
|
? { user_group_ids: selectedUserGroups }
|
||||||
: {})
|
: {})
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -159,6 +212,8 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
dateRange,
|
dateRange,
|
||||||
selectedUsers,
|
selectedUsers,
|
||||||
selectedInstances,
|
selectedInstances,
|
||||||
|
selectedOrganizations,
|
||||||
|
selectedUserGroups,
|
||||||
granularity,
|
granularity,
|
||||||
chartGroupBy,
|
chartGroupBy,
|
||||||
refreshKey
|
refreshKey
|
||||||
@@ -395,6 +450,18 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
},
|
},
|
||||||
placeholder: intl.formatMessage({ id: 'usage.filter.instance' })
|
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)}
|
onRefresh={() => setRefreshKey((k) => k + 1)}
|
||||||
onExportChart={() => setExportMode('chart')}
|
onExportChart={() => setExportMode('chart')}
|
||||||
onExportTable={handleExportTable}
|
onExportTable={handleExportTable}
|
||||||
@@ -435,26 +502,62 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
{/* Bottom tabs + table */}
|
{/* Bottom tabs + table */}
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={activeTableTab}
|
activeKey={activeTableTab}
|
||||||
onChange={(k) => setActiveTableTab(k as GroupKey)}
|
onChange={(k) => setActiveTableTab(k)}
|
||||||
items={TABLE_TABS.map((t) => ({
|
items={[
|
||||||
key: t.key,
|
...TABLE_TABS.map((t) => ({
|
||||||
label: t.label,
|
key: t.key,
|
||||||
// Keep every pane mounted so each table holds its own page/sort and
|
label: t.label,
|
||||||
// switching tabs neither refetches nor resets the others.
|
// Keep every pane mounted so each table holds its own page/sort and
|
||||||
forceRender: true,
|
// switching tabs neither refetches nor resets the others.
|
||||||
children: (
|
forceRender: true,
|
||||||
<InstancesBreakdownTable
|
children: (
|
||||||
key={t.key}
|
<InstancesBreakdownTable
|
||||||
groupKey={t.key}
|
key={t.key}
|
||||||
dateRange={dateRange}
|
groupKey={t.key}
|
||||||
scope={scope}
|
dateRange={dateRange}
|
||||||
selectedUsers={selectedUsers}
|
scope={scope}
|
||||||
selectedInstances={selectedInstances}
|
selectedUsers={selectedUsers}
|
||||||
pageResetKey={pageResetKey}
|
selectedInstances={selectedInstances}
|
||||||
refreshKey={refreshKey}
|
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
|
<ResourceExportData
|
||||||
@@ -477,6 +580,10 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
initialDateRange={dateRange}
|
initialDateRange={dateRange}
|
||||||
initialSelectedUsers={selectedUsers}
|
initialSelectedUsers={selectedUsers}
|
||||||
initialSelectedResources={selectedInstances}
|
initialSelectedResources={selectedInstances}
|
||||||
|
organizationOptions={organizations}
|
||||||
|
userGroupOptions={userGroups}
|
||||||
|
initialSelectedOrganizations={selectedOrganizations}
|
||||||
|
initialSelectedUserGroups={selectedUserGroups}
|
||||||
deletedNameFields={[
|
deletedNameFields={[
|
||||||
// The row's ``deleted`` is the grouped instance; the owner user
|
// The row's ``deleted`` is the grouped instance; the owner user
|
||||||
// carries its own ``user_deleted``.
|
// carries its own ``user_deleted``.
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ interface Props {
|
|||||||
scope: Scope;
|
scope: Scope;
|
||||||
selectedUsers: number[];
|
selectedUsers: number[];
|
||||||
selectedInstances: 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
|
// Bumped by the parent when a filter changes, so each mounted table snaps
|
||||||
// back to page 1 independently.
|
// back to page 1 independently.
|
||||||
pageResetKey?: number;
|
pageResetKey?: number;
|
||||||
@@ -42,6 +45,8 @@ const InstancesBreakdownTable: React.FC<Props> = ({
|
|||||||
scope,
|
scope,
|
||||||
selectedUsers,
|
selectedUsers,
|
||||||
selectedInstances,
|
selectedInstances,
|
||||||
|
selectedOrganizations = [],
|
||||||
|
selectedUserGroups = [],
|
||||||
pageResetKey = 0,
|
pageResetKey = 0,
|
||||||
refreshKey = 0
|
refreshKey = 0
|
||||||
}) => {
|
}) => {
|
||||||
@@ -101,11 +106,20 @@ const InstancesBreakdownTable: React.FC<Props> = ({
|
|||||||
end_date: dateRange[1].format('YYYY-MM-DD'),
|
end_date: dateRange[1].format('YYYY-MM-DD'),
|
||||||
scope,
|
scope,
|
||||||
filters:
|
filters:
|
||||||
selectedUsers.length || selectedInstances.length
|
selectedUsers.length ||
|
||||||
|
selectedInstances.length ||
|
||||||
|
selectedOrganizations.length ||
|
||||||
|
selectedUserGroups.length
|
||||||
? {
|
? {
|
||||||
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
|
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
|
||||||
...(selectedInstances.length
|
...(selectedInstances.length
|
||||||
? { instance_ids: selectedInstances }
|
? { instance_ids: selectedInstances }
|
||||||
|
: {}),
|
||||||
|
...(selectedOrganizations.length
|
||||||
|
? { organization_ids: selectedOrganizations }
|
||||||
|
: {}),
|
||||||
|
...(selectedUserGroups.length
|
||||||
|
? { user_group_ids: selectedUserGroups }
|
||||||
: {})
|
: {})
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -120,6 +134,8 @@ const InstancesBreakdownTable: React.FC<Props> = ({
|
|||||||
scope,
|
scope,
|
||||||
selectedUsers,
|
selectedUsers,
|
||||||
selectedInstances,
|
selectedInstances,
|
||||||
|
selectedOrganizations,
|
||||||
|
selectedUserGroups,
|
||||||
queryParams.page,
|
queryParams.page,
|
||||||
queryParams.perPage,
|
queryParams.perPage,
|
||||||
queryParams.sort_by,
|
queryParams.sort_by,
|
||||||
|
|||||||
@@ -34,10 +34,14 @@ export default function useQueryUsageMetaData() {
|
|||||||
users: UserOptionType[];
|
users: UserOptionType[];
|
||||||
api_keys: GroupOption<UsageFilterItem>[];
|
api_keys: GroupOption<UsageFilterItem>[];
|
||||||
routes: RouteOptionType[];
|
routes: RouteOptionType[];
|
||||||
|
organizations: RouteOptionType[];
|
||||||
|
user_groups: RouteOptionType[];
|
||||||
}>({
|
}>({
|
||||||
users: [],
|
users: [],
|
||||||
api_keys: [],
|
api_keys: [],
|
||||||
routes: []
|
routes: [],
|
||||||
|
organizations: [],
|
||||||
|
user_groups: []
|
||||||
});
|
});
|
||||||
|
|
||||||
// Current account first, deleted entries last, everything else keeps its
|
// Current account first, deleted entries last, everything else keeps its
|
||||||
@@ -87,7 +91,21 @@ export default function useQueryUsageMetaData() {
|
|||||||
...item,
|
...item,
|
||||||
value: optionValue(item.identity.current?.route_id, index)
|
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);
|
setResult(data);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
* so there's no phase filter — just date / scope / volume / user.
|
* so there's no phase filter — just date / scope / volume / user.
|
||||||
*/
|
*/
|
||||||
import useCoolColors from '@/hooks/use-cool-colors';
|
import useCoolColors from '@/hooks/use-cool-colors';
|
||||||
|
import { getGPUStackPlugin } from '@/plugins';
|
||||||
import { formatLargeNumber } from '@/utils';
|
import { formatLargeNumber } from '@/utils';
|
||||||
import { SimpleCard } from '@gpustack/core-ui';
|
import { SimpleCard } from '@gpustack/core-ui';
|
||||||
import { useAccess, useIntl } from '@umijs/max';
|
import { useAccess, useIntl } from '@umijs/max';
|
||||||
@@ -47,6 +48,29 @@ type Scope = 'self' | 'all';
|
|||||||
type Metric = 'storage_gb_days' | 'storage_gb_hours';
|
type Metric = 'storage_gb_days' | 'storage_gb_hours';
|
||||||
type GroupKey = 'volume' | 'user';
|
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 StorageTab: React.FC = () => {
|
||||||
const access = useAccess();
|
const access = useAccess();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -96,15 +120,35 @@ const StorageTab: React.FC = () => {
|
|||||||
]);
|
]);
|
||||||
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
|
||||||
const [selectedVolumes, setSelectedVolumes] = 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 [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [metric, setMetric] = useState<Metric>('storage_gb_days');
|
const [metric, setMetric] = useState<Metric>('storage_gb_days');
|
||||||
const [granularity, setGranularity] = useState<Granularity>('day');
|
const [granularity, setGranularity] = useState<Granularity>('day');
|
||||||
// Optional trend group-by (split the chart into one series per group).
|
// Optional trend group-by (split the chart into one series per group).
|
||||||
const [chartGroupBy, setChartGroupBy] = useState<GroupKey | null>(null);
|
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 } =
|
const {
|
||||||
useResourceMeta(scope);
|
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;
|
// Bumped on any filter change to snap every mounted table back to page 1;
|
||||||
// each table owns its own page/sort state otherwise.
|
// each table owns its own page/sort state otherwise.
|
||||||
@@ -122,10 +166,19 @@ const StorageTab: React.FC = () => {
|
|||||||
scope,
|
scope,
|
||||||
granularity,
|
granularity,
|
||||||
filters:
|
filters:
|
||||||
selectedUsers.length || selectedVolumes.length
|
selectedUsers.length ||
|
||||||
|
selectedVolumes.length ||
|
||||||
|
selectedOrganizations.length ||
|
||||||
|
selectedUserGroups.length
|
||||||
? {
|
? {
|
||||||
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
|
...(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,
|
: undefined,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -152,6 +205,8 @@ const StorageTab: React.FC = () => {
|
|||||||
dateRange,
|
dateRange,
|
||||||
selectedUsers,
|
selectedUsers,
|
||||||
selectedVolumes,
|
selectedVolumes,
|
||||||
|
selectedOrganizations,
|
||||||
|
selectedUserGroups,
|
||||||
granularity,
|
granularity,
|
||||||
chartGroupBy,
|
chartGroupBy,
|
||||||
refreshKey
|
refreshKey
|
||||||
@@ -378,6 +433,18 @@ const StorageTab: React.FC = () => {
|
|||||||
},
|
},
|
||||||
placeholder: intl.formatMessage({ id: 'usage.filter.storage' })
|
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)}
|
onRefresh={() => setRefreshKey((k) => k + 1)}
|
||||||
onExportChart={() => setExportMode('chart')}
|
onExportChart={() => setExportMode('chart')}
|
||||||
onExportTable={handleExportTable}
|
onExportTable={handleExportTable}
|
||||||
@@ -415,26 +482,62 @@ const StorageTab: React.FC = () => {
|
|||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={activeTableTab}
|
activeKey={activeTableTab}
|
||||||
onChange={(k) => setActiveTableTab(k as GroupKey)}
|
onChange={(k) => setActiveTableTab(k)}
|
||||||
items={TABLE_TABS.map((t) => ({
|
items={[
|
||||||
key: t.key,
|
...TABLE_TABS.map((t) => ({
|
||||||
label: t.label,
|
key: t.key,
|
||||||
// Keep every pane mounted so each table holds its own page/sort and
|
label: t.label,
|
||||||
// switching tabs neither refetches nor resets the other.
|
// Keep every pane mounted so each table holds its own page/sort and
|
||||||
forceRender: true,
|
// switching tabs neither refetches nor resets the other.
|
||||||
children: (
|
forceRender: true,
|
||||||
<StorageBreakdownTable
|
children: (
|
||||||
key={t.key}
|
<StorageBreakdownTable
|
||||||
groupKey={t.key}
|
key={t.key}
|
||||||
dateRange={dateRange}
|
groupKey={t.key}
|
||||||
scope={scope}
|
dateRange={dateRange}
|
||||||
selectedUsers={selectedUsers}
|
scope={scope}
|
||||||
selectedVolumes={selectedVolumes}
|
selectedUsers={selectedUsers}
|
||||||
pageResetKey={pageResetKey}
|
selectedVolumes={selectedVolumes}
|
||||||
refreshKey={refreshKey}
|
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
|
<ResourceExportData
|
||||||
@@ -457,6 +560,10 @@ const StorageTab: React.FC = () => {
|
|||||||
initialDateRange={dateRange}
|
initialDateRange={dateRange}
|
||||||
initialSelectedUsers={selectedUsers}
|
initialSelectedUsers={selectedUsers}
|
||||||
initialSelectedResources={selectedVolumes}
|
initialSelectedResources={selectedVolumes}
|
||||||
|
organizationOptions={organizations}
|
||||||
|
userGroupOptions={userGroups}
|
||||||
|
initialSelectedOrganizations={selectedOrganizations}
|
||||||
|
initialSelectedUserGroups={selectedUserGroups}
|
||||||
deletedNameFields={[
|
deletedNameFields={[
|
||||||
// The row's ``deleted`` is the grouped volume; the owner user
|
// The row's ``deleted`` is the grouped volume; the owner user
|
||||||
// carries its own ``user_deleted``.
|
// carries its own ``user_deleted``.
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ interface Props {
|
|||||||
scope: Scope;
|
scope: Scope;
|
||||||
selectedUsers: number[];
|
selectedUsers: number[];
|
||||||
selectedVolumes: 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
|
// Bumped by the parent when a filter changes, so each mounted table snaps
|
||||||
// back to page 1 independently.
|
// back to page 1 independently.
|
||||||
pageResetKey?: number;
|
pageResetKey?: number;
|
||||||
@@ -46,6 +49,8 @@ const StorageBreakdownTable: React.FC<Props> = ({
|
|||||||
scope,
|
scope,
|
||||||
selectedUsers,
|
selectedUsers,
|
||||||
selectedVolumes,
|
selectedVolumes,
|
||||||
|
selectedOrganizations = [],
|
||||||
|
selectedUserGroups = [],
|
||||||
pageResetKey = 0,
|
pageResetKey = 0,
|
||||||
refreshKey = 0
|
refreshKey = 0
|
||||||
}) => {
|
}) => {
|
||||||
@@ -105,10 +110,21 @@ const StorageBreakdownTable: React.FC<Props> = ({
|
|||||||
end_date: dateRange[1].format('YYYY-MM-DD'),
|
end_date: dateRange[1].format('YYYY-MM-DD'),
|
||||||
scope,
|
scope,
|
||||||
filters:
|
filters:
|
||||||
selectedUsers.length || selectedVolumes.length
|
selectedUsers.length ||
|
||||||
|
selectedVolumes.length ||
|
||||||
|
selectedOrganizations.length ||
|
||||||
|
selectedUserGroups.length
|
||||||
? {
|
? {
|
||||||
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
|
...(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,
|
: undefined,
|
||||||
group_by: [groupKey],
|
group_by: [groupKey],
|
||||||
@@ -122,6 +138,8 @@ const StorageBreakdownTable: React.FC<Props> = ({
|
|||||||
scope,
|
scope,
|
||||||
selectedUsers,
|
selectedUsers,
|
||||||
selectedVolumes,
|
selectedVolumes,
|
||||||
|
selectedOrganizations,
|
||||||
|
selectedUserGroups,
|
||||||
queryParams.page,
|
queryParams.page,
|
||||||
queryParams.perPage,
|
queryParams.perPage,
|
||||||
queryParams.sort_by,
|
queryParams.sort_by,
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ type QueryParams = {
|
|||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
selectedUsers: number[];
|
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).
|
// 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
|
// 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.
|
// a single object keeps them in sync and trims the dependency arrays.
|
||||||
const [queryParams, setQueryParams] = useState<{
|
const [queryParams, setQueryParams] = useState<QueryParams>({
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
selectedUsers: number[];
|
|
||||||
}>({
|
|
||||||
start: dayjs().subtract(29, 'day').format('YYYY-MM-DD'),
|
start: dayjs().subtract(29, 'day').format('YYYY-MM-DD'),
|
||||||
end: dayjs().format('YYYY-MM-DD'),
|
end: dayjs().format('YYYY-MM-DD'),
|
||||||
selectedUsers: []
|
selectedUsers: [],
|
||||||
|
selectedOrganizations: [],
|
||||||
|
selectedUserGroups: []
|
||||||
});
|
});
|
||||||
const { start, end, selectedUsers } = queryParams;
|
const {
|
||||||
const { creators: resourceUsers } = useResourceMeta(scope);
|
start,
|
||||||
|
end,
|
||||||
|
selectedUsers,
|
||||||
|
selectedOrganizations,
|
||||||
|
selectedUserGroups
|
||||||
|
} = queryParams;
|
||||||
|
const {
|
||||||
|
creators: resourceUsers,
|
||||||
|
organizations,
|
||||||
|
user_groups: userGroups
|
||||||
|
} = useResourceMeta(scope);
|
||||||
const { detailData: tokenMeta, fetchData: fetchTokenMeta } =
|
const { detailData: tokenMeta, fetchData: fetchTokenMeta } =
|
||||||
useQueryUsageMetaData();
|
useQueryUsageMetaData();
|
||||||
|
|
||||||
@@ -336,10 +347,24 @@ const SummaryTab: React.FC = () => {
|
|||||||
setQueryParams(currentParams);
|
setQueryParams(currentParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
// "filter by user" — restricts every resource fetch to these creator ids.
|
// "filter by user" (+ enterprise org / user-group) — restricts every
|
||||||
const creatorFilter = currentParams.selectedUsers.length
|
// resource fetch to these creator / org / group ids.
|
||||||
? { creator_ids: currentParams.selectedUsers }
|
const creatorFilter =
|
||||||
: undefined;
|
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
|
// The token series hits /usage/breakdown, which filters users by identity
|
||||||
// rather than the creator_ids the resource endpoints take — so the token
|
// rather than the creator_ids the resource endpoints take — so the token
|
||||||
@@ -364,6 +389,12 @@ const SummaryTab: React.FC = () => {
|
|||||||
...commonParams,
|
...commonParams,
|
||||||
creator_ids: currentParams.selectedUsers.length
|
creator_ids: currentParams.selectedUsers.length
|
||||||
? currentParams.selectedUsers
|
? currentParams.selectedUsers
|
||||||
|
: undefined,
|
||||||
|
organization_ids: currentParams.selectedOrganizations.length
|
||||||
|
? currentParams.selectedOrganizations
|
||||||
|
: undefined,
|
||||||
|
user_group_ids: currentParams.selectedUserGroups.length
|
||||||
|
? currentParams.selectedUserGroups
|
||||||
: undefined
|
: undefined
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -487,6 +518,14 @@ const SummaryTab: React.FC = () => {
|
|||||||
fetchAll({ selectedUsers: users });
|
fetchAll({ selectedUsers: users });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOrganizationsChange = (ids: number[]) => {
|
||||||
|
fetchAll({ selectedOrganizations: ids });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserGroupsChange = (ids: number[]) => {
|
||||||
|
fetchAll({ selectedUserGroups: ids });
|
||||||
|
};
|
||||||
|
|
||||||
const onRefresh = () => {
|
const onRefresh = () => {
|
||||||
fetchAll();
|
fetchAll();
|
||||||
};
|
};
|
||||||
@@ -505,6 +544,12 @@ const SummaryTab: React.FC = () => {
|
|||||||
userOptions={userOptions}
|
userOptions={userOptions}
|
||||||
selectedUsers={selectedUsers}
|
selectedUsers={selectedUsers}
|
||||||
onUsersChange={handleUserFilterChange}
|
onUsersChange={handleUserFilterChange}
|
||||||
|
organizationOptions={organizations}
|
||||||
|
userGroupOptions={userGroups}
|
||||||
|
selectedOrganizations={selectedOrganizations}
|
||||||
|
selectedUserGroups={selectedUserGroups}
|
||||||
|
onOrganizationsChange={handleOrganizationsChange}
|
||||||
|
onUserGroupsChange={handleUserGroupsChange}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
/>
|
/>
|
||||||
<div style={{ height: 24 }} />
|
<div style={{ height: 24 }} />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getGPUStackPlugin } from '@/plugins';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { Tabs } from 'antd';
|
import { Tabs } from 'antd';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
@@ -6,6 +7,24 @@ import ApiKeysTable from '../tables/apikeys-table';
|
|||||||
import ModelsTable from '../tables/models-table';
|
import ModelsTable from '../tables/models-table';
|
||||||
import UsersTable from '../tables/users-table';
|
import UsersTable from '../tables/users-table';
|
||||||
|
|
||||||
|
// 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<{
|
const BreakdownTabs: React.FC<{
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start_date: string;
|
start_date: string;
|
||||||
@@ -18,7 +37,37 @@ const BreakdownTabs: React.FC<{
|
|||||||
}> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => {
|
}> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => {
|
||||||
const intl = useIntl();
|
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 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 [
|
return [
|
||||||
{
|
{
|
||||||
key: 'models',
|
key: 'models',
|
||||||
@@ -65,13 +114,24 @@ const BreakdownTabs: React.FC<{
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
].filter((item) => {
|
]
|
||||||
if (item.key === 'users') {
|
.filter((item) => {
|
||||||
return scope === 'all';
|
if (item.key === 'users') {
|
||||||
}
|
return scope === 'all';
|
||||||
return true;
|
}
|
||||||
});
|
return true;
|
||||||
}, [filters, dateRange, pageResetKey, refreshKey, scope]);
|
})
|
||||||
|
.concat(extraItems);
|
||||||
|
}, [
|
||||||
|
filters,
|
||||||
|
dateRange,
|
||||||
|
pageResetKey,
|
||||||
|
refreshKey,
|
||||||
|
scope,
|
||||||
|
extraTabs,
|
||||||
|
extraVisible,
|
||||||
|
intl
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
|
|||||||
@@ -178,6 +178,14 @@ const TokenTab: React.FC = () => {
|
|||||||
filterBar.onApiKeysChange(value);
|
filterBar.onApiKeysChange(value);
|
||||||
handleBreakdownPageReset();
|
handleBreakdownPageReset();
|
||||||
}}
|
}}
|
||||||
|
onOrganizationsChange={(value) => {
|
||||||
|
filterBar.onOrganizationsChange(value);
|
||||||
|
handleBreakdownPageReset();
|
||||||
|
}}
|
||||||
|
onUserGroupsChange={(value) => {
|
||||||
|
filterBar.onUserGroupsChange(value);
|
||||||
|
handleBreakdownPageReset();
|
||||||
|
}}
|
||||||
handleSearch={handleSearch}
|
handleSearch={handleSearch}
|
||||||
handlePickerChange={handlePickerChange}
|
handlePickerChange={handlePickerChange}
|
||||||
onExportTable={exportTable}
|
onExportTable={exportTable}
|
||||||
|
|||||||
Reference in New Issue
Block a user