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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user