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:
gitlawr
2026-06-12 20:22:56 +08:00
committed by Lawrence Li
parent 778ea36a9d
commit 7a76bda484
20 changed files with 395 additions and 54 deletions
@@ -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
]);
};