feat: distinguish same-name GPU service resources across users
Resource names are unique per owning principal, so different users can legally create same-name GPU instances, templates, SSH public keys, storage volumes and storage types — but the admin's cross-tenant lists and the instance-create template picker rendered them indistinguishably. - Group the template picker by owning scope: Your Templates, Global Templates, then — admin cross-tenant view only — one group per owner. A single group renders flat without headers. A plugin can take over grouping via hooks.useTemplateOwnerGroups when its principal model scopes templates beyond USER owners; a plugin without the hook keeps the flat list. - Add an admin-only Creator column to the Instances, SSH Public Keys, Storage and Storage Types tables, sitting right before Created (mirroring the API Keys page convention). Resolves creator_id to a username via the user directory, falling back to owner_principal_id for legacy rows — a personal-scope row's creator IS its owner. - Tag template cards with their owner scope (Global / username) for admins. The Creator column and the card tag defer to a plugin that registers the page's list columns / the OwnerScopeTag slot. Requires the gpustack server change that records creator_id on templates, SSH public keys and storage types.
This commit is contained in:
@@ -2,7 +2,9 @@ import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import useSubmitLock from '@/hooks/use-submit-lock';
|
||||
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
|
||||
import useUserDirectory from '@/pages/gpu-service/hooks/use-user-directory';
|
||||
import Separator from '@/pages/llmodels/components/separator';
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
AlertBlockInfo,
|
||||
@@ -10,7 +12,7 @@ import {
|
||||
GSDrawer,
|
||||
ModalFooter
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { useIntl, useModel } from '@umijs/max';
|
||||
import { Empty, Input, Typography } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -18,7 +20,7 @@ import { ListItem as TemplateItem } from '../../templates/config/types';
|
||||
import useQueryTemplates from '../../templates/services/use-query-templates';
|
||||
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
|
||||
import GPUServiceInstanceForm from '../forms';
|
||||
import TemplateSelector from '../forms/template-selector';
|
||||
import TemplateSelector, { TemplateGroup } from '../forms/template-selector';
|
||||
import useQueryInstanceTypes from '../services/use-query-instance-types';
|
||||
import styles from '../styles/instances.module.less';
|
||||
import InstanceTypeList from './instance-type-list';
|
||||
@@ -79,6 +81,12 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
realAction
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { initialState } = useModel('@@initialState') || {};
|
||||
const currentUser = initialState?.currentUser;
|
||||
const pluginActive = !!getGPUStackPlugin();
|
||||
const userDirectory = useUserDirectory(
|
||||
!!currentUser?.is_admin && !pluginActive
|
||||
);
|
||||
const form = useRef<any>(null);
|
||||
const sessionRef = useRef(0);
|
||||
const [instanceTypeSelection, setInstanceTypeSelection] = useState<{
|
||||
@@ -395,6 +403,89 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
);
|
||||
});
|
||||
|
||||
// Group the picker by owning scope so same-name templates stay
|
||||
// distinguishable: the caller's own templates first, then the
|
||||
// admin-curated Global presets, then — platform admin's cross-tenant
|
||||
// view only — other users' templates, one group per owner.
|
||||
//
|
||||
// The default buckets below assume every non-Global owner is a USER
|
||||
// principal. A plugin's principal model may scope templates to other
|
||||
// owner kinds (no user-directory entry, not the caller's user id),
|
||||
// which these buckets would mislabel — so a plugin can take over
|
||||
// grouping via `hooks.useTemplateOwnerGroups`. The registry is wired
|
||||
// at boot, so the conditional hook call is render-stable — same
|
||||
// contract as `usePluginListColumns`' function entries.
|
||||
const usePluginTemplateGroups = getGPUStackPlugin()?.hooks
|
||||
?.useTemplateOwnerGroups as
|
||||
| ((items: TemplateItem[]) => TemplateGroup[])
|
||||
| undefined;
|
||||
const pluginTemplateGroups = usePluginTemplateGroups?.(filteredTemplates);
|
||||
|
||||
const templateGroups: TemplateGroup[] = useMemo(() => {
|
||||
if (pluginTemplateGroups) {
|
||||
return pluginTemplateGroups;
|
||||
}
|
||||
if (pluginActive) {
|
||||
// Plugin present but without the grouping hook (older plugin
|
||||
// build): keep the flat list rather than mislabeling owners
|
||||
// outside the USER-principal model.
|
||||
return filteredTemplates.length
|
||||
? [{ key: 'all', label: null, items: filteredTemplates }]
|
||||
: [];
|
||||
}
|
||||
const yours: TemplateItem[] = [];
|
||||
const globals: TemplateItem[] = [];
|
||||
const byOwner = new Map<number, TemplateItem[]>();
|
||||
|
||||
filteredTemplates.forEach((item) => {
|
||||
if (item.owner_principal_id == null) {
|
||||
globals.push(item);
|
||||
} else if (item.owner_principal_id === currentUser?.id) {
|
||||
yours.push(item);
|
||||
} else {
|
||||
const list = byOwner.get(item.owner_principal_id) || [];
|
||||
list.push(item);
|
||||
byOwner.set(item.owner_principal_id, list);
|
||||
}
|
||||
});
|
||||
|
||||
const groups: TemplateGroup[] = [];
|
||||
if (yours.length) {
|
||||
groups.push({
|
||||
key: 'yours',
|
||||
label: intl.formatMessage({ id: 'gpuservice.template.group.yours' }),
|
||||
items: yours
|
||||
});
|
||||
}
|
||||
if (globals.length) {
|
||||
groups.push({
|
||||
key: 'global',
|
||||
label: intl.formatMessage({ id: 'gpuservice.template.group.global' }),
|
||||
items: globals
|
||||
});
|
||||
}
|
||||
groups.push(
|
||||
...[...byOwner.entries()]
|
||||
.map(([ownerId, items]) => ({
|
||||
key: `owner-${ownerId}`,
|
||||
// `#id` is a placeholder for the moment before the user
|
||||
// directory resolves (the memo recomputes once it lands)
|
||||
// and for the API-only case of a non-USER owner.
|
||||
label: userDirectory.get(ownerId) || `#${ownerId}`,
|
||||
items
|
||||
}))
|
||||
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
|
||||
);
|
||||
return groups;
|
||||
}, [
|
||||
filteredTemplates,
|
||||
currentUser?.id,
|
||||
userDirectory,
|
||||
intl,
|
||||
pluginActive,
|
||||
pluginTemplateGroups
|
||||
]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
guard(() => form.current?.submit());
|
||||
};
|
||||
@@ -531,7 +622,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
{filteredTemplates.length > 0 && initializedRef.current ? (
|
||||
<TemplateSelector
|
||||
value={templateId}
|
||||
dataList={filteredTemplates}
|
||||
groups={templateGroups}
|
||||
onChange={handleTemplateChange}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -110,6 +110,7 @@ export interface ListItem extends FormData {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at?: string | null;
|
||||
creator_id?: number | null;
|
||||
status?: InstanceStatus | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Fragment } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { ListItem as TemplateItem } from '../../templates/config/types';
|
||||
|
||||
@@ -9,6 +10,13 @@ const TemplateGrid = styled.div`
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const GroupTitle = styled.div`
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
margin-bottom: -8px;
|
||||
`;
|
||||
|
||||
const TemplateContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -41,16 +49,22 @@ const TemplateContent = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export interface TemplateGroup {
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
items: TemplateItem[];
|
||||
}
|
||||
|
||||
interface TemplateSelectorProps {
|
||||
value?: number;
|
||||
onChange?: (value: number, item: TemplateItem) => void;
|
||||
dataList?: TemplateItem[];
|
||||
groups?: TemplateGroup[];
|
||||
}
|
||||
|
||||
const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
dataList = []
|
||||
groups = []
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -59,48 +73,59 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
||||
onChange?.(item.id, item);
|
||||
};
|
||||
|
||||
const renderItem = (item: TemplateItem) => (
|
||||
<TemplateCard
|
||||
key={item.id}
|
||||
clickable
|
||||
ghost
|
||||
hoverable
|
||||
height={102}
|
||||
active={value === item.id}
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
<TemplateContent>
|
||||
<div className="name">
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
{item.displayName || item.name || '-'}
|
||||
</AutoTooltip>
|
||||
</div>
|
||||
<div className="info">
|
||||
<span>
|
||||
<IconFont className="icon" type="icon-model" />{' '}
|
||||
{intl.formatMessage({
|
||||
id: 'gpuservice.instance.template.image'
|
||||
})}
|
||||
:
|
||||
</span>
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
<span className="value">{item.spec?.image || '-'}</span>
|
||||
</AutoTooltip>
|
||||
</div>
|
||||
<div className="info">
|
||||
<span>
|
||||
<IconFont className="icon" type="icon-storage-outlined" />{' '}
|
||||
{intl.formatMessage({
|
||||
id: 'gpuservice.instance.template.mount'
|
||||
})}
|
||||
:
|
||||
</span>
|
||||
<span className="value">{item.spec?.volumeMount || '-'}</span>
|
||||
</div>
|
||||
</TemplateContent>
|
||||
</TemplateCard>
|
||||
);
|
||||
|
||||
// A single group renders flat — the header would only restate what
|
||||
// the whole list already is.
|
||||
const showGroupTitles = groups.length > 1;
|
||||
|
||||
return (
|
||||
<TemplateGrid>
|
||||
{dataList.map((item: TemplateItem) => (
|
||||
<TemplateCard
|
||||
key={item.id}
|
||||
clickable
|
||||
ghost
|
||||
hoverable
|
||||
height={102}
|
||||
active={value === item.id}
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
<TemplateContent>
|
||||
<div className="name">
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
{item.displayName || item.name || '-'}
|
||||
</AutoTooltip>
|
||||
</div>
|
||||
<div className="info">
|
||||
<span>
|
||||
<IconFont className="icon" type="icon-model" />{' '}
|
||||
{intl.formatMessage({
|
||||
id: 'gpuservice.instance.template.image'
|
||||
})}
|
||||
:
|
||||
</span>
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
<span className="value">{item.spec?.image || '-'}</span>
|
||||
</AutoTooltip>
|
||||
</div>
|
||||
<div className="info">
|
||||
<span>
|
||||
<IconFont className="icon" type="icon-storage-outlined" />{' '}
|
||||
{intl.formatMessage({
|
||||
id: 'gpuservice.instance.template.mount'
|
||||
})}
|
||||
:
|
||||
</span>
|
||||
<span className="value">{item.spec?.volumeMount || '-'}</span>
|
||||
</div>
|
||||
</TemplateContent>
|
||||
</TemplateCard>
|
||||
{groups.map((group) => (
|
||||
<Fragment key={group.key}>
|
||||
{showGroupTitles && <GroupTitle>{group.label}</GroupTitle>}
|
||||
{group.items.map(renderItem)}
|
||||
</Fragment>
|
||||
))}
|
||||
</TemplateGrid>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
|
||||
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
||||
import { ExportOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
@@ -172,6 +173,7 @@ const useInstancesColumns = ({
|
||||
const intl = useIntl();
|
||||
const access = useAccess();
|
||||
const pluginCols = usePluginListColumns('gpuInstances');
|
||||
const creatorCols = useCreatorColumn<ListItem>('gpuInstances');
|
||||
|
||||
const renderInstanceType = (record: ListItem) => {
|
||||
const description =
|
||||
@@ -403,6 +405,7 @@ const useInstancesColumns = ({
|
||||
</AutoTooltip>
|
||||
)
|
||||
},
|
||||
...creatorCols,
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.createTime' }),
|
||||
dataIndex: 'created_at',
|
||||
@@ -441,7 +444,8 @@ const useInstancesColumns = ({
|
||||
clusterList,
|
||||
intl,
|
||||
pvCapacityByName,
|
||||
pluginCols
|
||||
pluginCols,
|
||||
creatorCols
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user