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
+5 -1
View File
@@ -174,5 +174,9 @@ export default {
'Please enter the temporary storage capacity', 'Please enter the temporary storage capacity',
'gpuservice.form.rule.name': 'gpuservice.form.rule.name':
"Lowercase letters, numbers, and '-'. Start and end with a letter or number, no consecutive '-', max 63 characters.", "Lowercase letters, numbers, and '-'. Start and end with a letter or number, no consecutive '-', max 63 characters.",
'gpuservice.form.storage.select': 'Select Storage' 'gpuservice.form.storage.select': 'Select Storage',
'gpuservice.creator': 'Creator',
'gpuservice.owner.global': 'Global',
'gpuservice.template.group.yours': 'Your Templates',
'gpuservice.template.group.global': 'Global Templates'
}; };
+5 -1
View File
@@ -174,5 +174,9 @@ export default {
'Data is cleared when the instance stops.', 'Data is cleared when the instance stops.',
'gpuservice.storage.persistentVolume.tips': 'gpuservice.storage.persistentVolume.tips':
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.', 'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
'gpuservice.form.storage.select': 'ストレージを選択' 'gpuservice.form.storage.select': 'ストレージを選択',
'gpuservice.creator': '作成者',
'gpuservice.owner.global': 'グローバル',
'gpuservice.template.group.yours': '自分のテンプレート',
'gpuservice.template.group.global': 'グローバルテンプレート'
}; };
+5 -1
View File
@@ -171,5 +171,9 @@ export default {
'Data is cleared when the instance stops.', 'Data is cleared when the instance stops.',
'gpuservice.storage.persistentVolume.tips': 'gpuservice.storage.persistentVolume.tips':
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.', 'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
'gpuservice.form.storage.select': 'Выберите хранилище' 'gpuservice.form.storage.select': 'Выберите хранилище',
'gpuservice.creator': 'Создатель',
'gpuservice.owner.global': 'Глобальный',
'gpuservice.template.group.yours': 'Ваши шаблоны',
'gpuservice.template.group.global': 'Глобальные шаблоны'
}; };
+5 -1
View File
@@ -169,5 +169,9 @@ export default {
'Data is cleared when the instance stops.', 'Data is cleared when the instance stops.',
'gpuservice.storage.persistentVolume.tips': 'gpuservice.storage.persistentVolume.tips':
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.', 'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
'gpuservice.form.storage.select': 'Depolamayı Seç' 'gpuservice.form.storage.select': 'Depolamayı Seç',
'gpuservice.creator': 'Oluşturan',
'gpuservice.owner.global': 'Genel',
'gpuservice.template.group.yours': 'Şablonlarınız',
'gpuservice.template.group.global': 'Genel Şablonlar'
}; };
+5 -1
View File
@@ -158,5 +158,9 @@ export default {
'gpuservice.storage.tempCapacity.required': '请输入临时存储容量', 'gpuservice.storage.tempCapacity.required': '请输入临时存储容量',
'gpuservice.form.rule.name': 'gpuservice.form.rule.name':
'由小写字母、数字和 "-" 组成,以字母或数字开头和结尾,不能包含连续的 "-",最多 63 个字符。', '由小写字母、数字和 "-" 组成,以字母或数字开头和结尾,不能包含连续的 "-",最多 63 个字符。',
'gpuservice.form.storage.select': '选择存储' 'gpuservice.form.storage.select': '选择存储',
'gpuservice.creator': '创建者',
'gpuservice.owner.global': '全局',
'gpuservice.template.group.yours': '我的模板',
'gpuservice.template.group.global': '全局模板'
}; };
@@ -0,0 +1,45 @@
import { getGPUStackPlugin } from '@/plugins';
import { ThemeTag } from '@gpustack/core-ui';
import { useAccess, useIntl } from '@umijs/max';
import useUserDirectory from '../hooks/use-user-directory';
/**
* Owner tag for template cards, disambiguating same-name templates in
* the admin's cross-tenant view. Renders nothing for non-admin callers
* (the management page is `mine`-scoped for them) and when a plugin
* provides its own `OwnerScopeTag` slot.
*/
const OwnerTag: React.FC<{ ownerId?: number | null }> = ({ ownerId }) => {
const intl = useIntl();
const access = useAccess();
const pluginOwnsTag = !!getGPUStackPlugin()?.components?.OwnerScopeTag;
const show = !!access.canSeeAdmin && !pluginOwnsTag;
const users = useUserDirectory(show);
if (!show) {
return null;
}
if (ownerId == null) {
return (
<ThemeTag color="gold" style={{ fontWeight: 400 }}>
{intl.formatMessage({ id: 'gpuservice.owner.global' })}
</ThemeTag>
);
}
const username = users.get(ownerId);
if (!username) {
// Directory still loading, or a non-USER principal (built-in
// platform principal) — nothing meaningful to show on a card.
return null;
}
return (
<ThemeTag color="blue" style={{ fontWeight: 400 }}>
{username}
</ThemeTag>
);
};
export default OwnerTag;
@@ -0,0 +1,81 @@
import { getGPUStackPlugin } from '@/plugins';
import { AutoTooltip } from '@gpustack/core-ui';
import { useAccess, useIntl } from '@umijs/max';
import { useMemo } from 'react';
import useUserDirectory from './use-user-directory';
type CreatorAware = {
creator_id?: number | null;
owner_principal_id?: number | null;
};
// Concrete column shape rather than antd's `ColumnsType` union:
// spreading a union-typed array into a page's column literal breaks
// contextual typing for the sibling inline columns (their render
// params degrade to implicit `any`), while a concrete object type
// composes cleanly — same contract the plugin columns use.
type CreatorColumn<T> = {
title: string;
key: string;
ellipsis: { showTitle: false };
render: (text: any, record: T) => React.ReactNode;
};
export const CreatorLabel: React.FC<{
record: CreatorAware;
users: Map<number, string>;
}> = ({ record, users }) => {
// Legacy rows predate `creator_id`. A personal-scope row's creator
// IS its owner, so the owner id doubles as the fallback — the same
// derivation the backend migration backfills with; keeping it here
// covers servers that haven't run the migration yet.
const id = record.creator_id ?? record.owner_principal_id;
const username = id != null ? users.get(id) : undefined;
if (username) {
return (
<AutoTooltip ghost maxWidth={240}>
{username}
</AutoTooltip>
);
}
// Unresolvable: the directory is still loading, or a legacy row
// whose only attribution is a non-USER principal (platform-owned).
return <span>-</span>;
};
/**
* "Creator" column for the GPU-service list pages, attributing each
* row to the user who created it — disambiguates same-name rows from
* different users in the admin's cross-tenant view, and reads as a
* plain username for admin-created rows too. Returns `[]` for
* non-admin callers (their lists only ever contain their own rows)
* and when a plugin registers list columns for `pageKey` — the
* plugin's own attribution column covers the same ground.
*/
const useCreatorColumn = <T extends CreatorAware>(
pageKey: string
): CreatorColumn<T>[] => {
const intl = useIntl();
const access = useAccess();
const pluginOwnsSlot = !!(
getGPUStackPlugin()?.listExtraColumns as Record<string, unknown> | undefined
)?.[pageKey];
const show = !!access.canSeeAdmin && !pluginOwnsSlot;
const users = useUserDirectory(show);
return useMemo(() => {
if (!show) return [];
return [
{
title: intl.formatMessage({ id: 'gpuservice.creator' }),
key: 'creator',
ellipsis: { showTitle: false as const },
render: (_text: any, record: T) => (
<CreatorLabel record={record} users={users} />
)
}
];
}, [show, users, intl]);
};
export default useCreatorColumn;
@@ -0,0 +1,52 @@
import { queryUserDirectory } from '@/pages/users/apis';
import { useEffect, useState } from 'react';
// Module-level cache: the directory is fetched once per app session and
// shared by every consumer (owner columns on the Instances / Public Keys /
// Storage tables, the template owner tags, the create-instance template
// picker). A failed fetch clears the cache so the next consumer retries.
let directoryPromise: Promise<Map<number, string>> | null = null;
const fetchDirectory = (): Promise<Map<number, string>> => {
directoryPromise ??= queryUserDirectory({ page: -1 })
.then(
(res) =>
new Map(
(res.items || [])
.filter((u) => u.id != null)
.map((u) => [u.id as number, u.username])
)
)
.catch((error) => {
directoryPromise = null;
throw error;
});
return directoryPromise;
};
/**
* id → username map of all users, for resolving `owner_principal_id`
* to a display name (a USER principal's id IS the user's id).
*
* Only fetches when `enabled` — callers gate on platform admin, since
* `/user-directory` 403s for regular users (who only ever see their
* own resources anyway). Returns an empty map until loaded.
*/
export default function useUserDirectory(enabled: boolean) {
const [users, setUsers] = useState<Map<number, string>>(new Map());
useEffect(() => {
if (!enabled) return undefined;
let alive = true;
fetchDirectory()
.then((map) => {
if (alive) setUsers(map);
})
.catch(() => {});
return () => {
alive = false;
};
}, [enabled]);
return users;
}
@@ -2,7 +2,9 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import useSubmitLock from '@/hooks/use-submit-lock'; import useSubmitLock from '@/hooks/use-submit-lock';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list'; 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 Separator from '@/pages/llmodels/components/separator';
import { getGPUStackPlugin } from '@/plugins';
import { SearchOutlined } from '@ant-design/icons'; import { SearchOutlined } from '@ant-design/icons';
import { import {
AlertBlockInfo, AlertBlockInfo,
@@ -10,7 +12,7 @@ import {
GSDrawer, GSDrawer,
ModalFooter ModalFooter
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl, useModel } from '@umijs/max';
import { Empty, Input, Typography } from 'antd'; import { Empty, Input, Typography } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react'; 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 useQueryTemplates from '../../templates/services/use-query-templates';
import { FormData, InstanceTypeItem, ListItem } from '../config/types'; import { FormData, InstanceTypeItem, ListItem } from '../config/types';
import GPUServiceInstanceForm from '../forms'; 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 useQueryInstanceTypes from '../services/use-query-instance-types';
import styles from '../styles/instances.module.less'; import styles from '../styles/instances.module.less';
import InstanceTypeList from './instance-type-list'; import InstanceTypeList from './instance-type-list';
@@ -79,6 +81,12 @@ const AddModal: React.FC<AddModalProps> = ({
realAction realAction
}) => { }) => {
const intl = useIntl(); 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 form = useRef<any>(null);
const sessionRef = useRef(0); const sessionRef = useRef(0);
const [instanceTypeSelection, setInstanceTypeSelection] = useState<{ 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 = () => { const handleSubmit = () => {
guard(() => form.current?.submit()); guard(() => form.current?.submit());
}; };
@@ -531,7 +622,7 @@ const AddModal: React.FC<AddModalProps> = ({
{filteredTemplates.length > 0 && initializedRef.current ? ( {filteredTemplates.length > 0 && initializedRef.current ? (
<TemplateSelector <TemplateSelector
value={templateId} value={templateId}
dataList={filteredTemplates} groups={templateGroups}
onChange={handleTemplateChange} onChange={handleTemplateChange}
/> />
) : ( ) : (
@@ -110,6 +110,7 @@ export interface ListItem extends FormData {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
deleted_at?: string | null; deleted_at?: string | null;
creator_id?: number | null;
status?: InstanceStatus | null; status?: InstanceStatus | null;
} }
@@ -1,5 +1,6 @@
import { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui'; import { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Fragment } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { ListItem as TemplateItem } from '../../templates/config/types'; import { ListItem as TemplateItem } from '../../templates/config/types';
@@ -9,6 +10,13 @@ const TemplateGrid = styled.div`
gap: 16px; 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` const TemplateContent = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -41,16 +49,22 @@ const TemplateContent = styled.div`
} }
`; `;
export interface TemplateGroup {
key: string;
label: React.ReactNode;
items: TemplateItem[];
}
interface TemplateSelectorProps { interface TemplateSelectorProps {
value?: number; value?: number;
onChange?: (value: number, item: TemplateItem) => void; onChange?: (value: number, item: TemplateItem) => void;
dataList?: TemplateItem[]; groups?: TemplateGroup[];
} }
const TemplateSelector: React.FC<TemplateSelectorProps> = ({ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
value, value,
onChange, onChange,
dataList = [] groups = []
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@@ -59,48 +73,59 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
onChange?.(item.id, item); 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 ( return (
<TemplateGrid> <TemplateGrid>
{dataList.map((item: TemplateItem) => ( {groups.map((group) => (
<TemplateCard <Fragment key={group.key}>
key={item.id} {showGroupTitles && <GroupTitle>{group.label}</GroupTitle>}
clickable {group.items.map(renderItem)}
ghost </Fragment>
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>
))} ))}
</TemplateGrid> </TemplateGrid>
); );
@@ -1,3 +1,4 @@
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
import { usePluginListColumns } from '@/plugins/list-extra-columns'; import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { ExportOutlined } from '@ant-design/icons'; import { ExportOutlined } from '@ant-design/icons';
import { import {
@@ -172,6 +173,7 @@ const useInstancesColumns = ({
const intl = useIntl(); const intl = useIntl();
const access = useAccess(); const access = useAccess();
const pluginCols = usePluginListColumns('gpuInstances'); const pluginCols = usePluginListColumns('gpuInstances');
const creatorCols = useCreatorColumn<ListItem>('gpuInstances');
const renderInstanceType = (record: ListItem) => { const renderInstanceType = (record: ListItem) => {
const description = const description =
@@ -403,6 +405,7 @@ const useInstancesColumns = ({
</AutoTooltip> </AutoTooltip>
) )
}, },
...creatorCols,
{ {
title: intl.formatMessage({ id: 'common.table.createTime' }), title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at', dataIndex: 'created_at',
@@ -441,7 +444,8 @@ const useInstancesColumns = ({
clusterList, clusterList,
intl, intl,
pvCapacityByName, pvCapacityByName,
pluginCols pluginCols,
creatorCols
]); ]);
}; };
@@ -14,6 +14,7 @@ export interface ListItem {
updated_at: string; updated_at: string;
deleted_at?: string | null; deleted_at?: string | null;
owner_principal_id?: number | null; owner_principal_id?: number | null;
creator_id?: number | null;
name?: string; name?: string;
displayName?: string | null; displayName?: string | null;
description?: string | null; description?: string | null;
@@ -1,3 +1,4 @@
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
import { usePluginListColumns } from '@/plugins/list-extra-columns'; import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { AutoTooltip, DropdownButtons, icons } from '@gpustack/core-ui'; import { AutoTooltip, DropdownButtons, icons } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
@@ -35,6 +36,7 @@ const usePublicKeyColumns = ({
}: ColumnsHookProps): ColumnsType<ListItem> => { }: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl(); const intl = useIntl();
const pluginCols = usePluginListColumns('gpuPublicKeys'); const pluginCols = usePluginListColumns('gpuPublicKeys');
const creatorCols = useCreatorColumn<ListItem>('gpuPublicKeys');
return useMemo(() => { return useMemo(() => {
const pluginRendered = pluginCols.map((c) => ({ const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }), title: intl.formatMessage({ id: c.titleId }),
@@ -62,6 +64,7 @@ const usePublicKeyColumns = ({
) )
}, },
...pluginRendered, ...pluginRendered,
...creatorCols,
{ {
title: intl.formatMessage({ id: 'common.table.createTime' }), title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at', dataIndex: 'created_at',
@@ -89,7 +92,7 @@ const usePublicKeyColumns = ({
) )
} }
]; ];
}, [handleSelect, sortOrder, intl, pluginCols]); }, [handleSelect, sortOrder, intl, pluginCols, creatorCols]);
}; };
export default usePublicKeyColumns; export default usePublicKeyColumns;
@@ -36,6 +36,7 @@ export interface ListItem {
updated_at: string; updated_at: string;
deleted_at?: string | null; deleted_at?: string | null;
owner_principal_id?: number | null; owner_principal_id?: number | null;
creator_id?: number | null;
displayName?: string | null; displayName?: string | null;
description?: string | null; description?: string | null;
name: string; name: string;
@@ -1,3 +1,4 @@
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
import { usePluginListColumns } from '@/plugins/list-extra-columns'; import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { FolderOutlined } from '@ant-design/icons'; import { FolderOutlined } from '@ant-design/icons';
import { import {
@@ -48,6 +49,7 @@ const useStorageTypeColumns = ({
}: ColumnsHookProps): ColumnsType<ListItem> => { }: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl(); const intl = useIntl();
const pluginCols = usePluginListColumns('gpuStorageTypes'); const pluginCols = usePluginListColumns('gpuStorageTypes');
const creatorCols = useCreatorColumn<ListItem>('gpuStorageTypes');
return useMemo(() => { return useMemo(() => {
const pluginRendered = pluginCols.map((c) => ({ const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }), title: intl.formatMessage({ id: c.titleId }),
@@ -79,6 +81,7 @@ const useStorageTypeColumns = ({
sorter: false, sorter: false,
render: (_text, record) => getKindLabel(record) render: (_text, record) => getKindLabel(record)
}, },
...creatorCols,
// { // {
// title: intl.formatMessage({ id: 'common.table.description' }), // title: intl.formatMessage({ id: 'common.table.description' }),
// dataIndex: 'description', // dataIndex: 'description',
@@ -115,7 +118,7 @@ const useStorageTypeColumns = ({
) )
} }
]; ];
}, [handleSelect, sortOrder, intl, pluginCols]); }, [handleSelect, sortOrder, intl, pluginCols, creatorCols]);
}; };
export default useStorageTypeColumns; export default useStorageTypeColumns;
@@ -23,6 +23,7 @@ export interface ListItem {
updated_at: string; updated_at: string;
deleted_at?: string | null; deleted_at?: string | null;
owner_principal_id?: number | null; owner_principal_id?: number | null;
creator_id?: number | null;
displayName?: string | null; displayName?: string | null;
description?: string | null; description?: string | null;
name: string; name: string;
@@ -1,3 +1,4 @@
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
import { usePluginListColumns } from '@/plugins/list-extra-columns'; import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui'; import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
@@ -20,6 +21,7 @@ const useStorageColumns = ({
}: ColumnsHookProps): ColumnsType<ListItem> => { }: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl(); const intl = useIntl();
const pluginCols = usePluginListColumns('gpuStorage'); const pluginCols = usePluginListColumns('gpuStorage');
const creatorCols = useCreatorColumn<ListItem>('gpuStorage');
return useMemo(() => { return useMemo(() => {
const pluginRendered = pluginCols.map((c) => ({ const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }), title: intl.formatMessage({ id: c.titleId }),
@@ -68,6 +70,7 @@ const useStorageColumns = ({
sorter: false, sorter: false,
render: (value: string) => (value ? value.replace(/Gi$/, 'GB') : '-') render: (value: string) => (value ? value.replace(/Gi$/, 'GB') : '-')
}, },
...creatorCols,
// { // {
// title: intl.formatMessage({ id: 'common.table.status' }), // title: intl.formatMessage({ id: 'common.table.status' }),
// dataIndex: ['status', 'phase'], // dataIndex: ['status', 'phase'],
@@ -111,7 +114,14 @@ const useStorageColumns = ({
) )
} }
]; ];
}, [handleSelect, sortOrder, storageClassList, intl, pluginCols]); }, [
handleSelect,
sortOrder,
storageClassList,
intl,
pluginCols,
creatorCols
]);
}; };
export default useStorageColumns; export default useStorageColumns;
@@ -14,6 +14,7 @@ import tensorflowkLogo from '@/assets/logo/tensorflow.svg';
import ubuntuLogo from '@/assets/logo/ubuntu_logo.png'; import ubuntuLogo from '@/assets/logo/ubuntu_logo.png';
import vllmLogo from '@/assets/logo/vllm.png'; import vllmLogo from '@/assets/logo/vllm.png';
import PluginExtraFields from '@/components/plugin-extra-fields'; import PluginExtraFields from '@/components/plugin-extra-fields';
import OwnerTag from '@/pages/gpu-service/components/owner-tag';
import { import {
GPUsConfigs, GPUsConfigs,
manfacturerValueMap manfacturerValueMap
@@ -266,6 +267,7 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
name="OwnerScopeTag" name="OwnerScopeTag"
context={{ ownerPrincipalId: data.owner_principal_id }} context={{ ownerPrincipalId: data.owner_principal_id }}
/> />
<OwnerTag ownerId={data.owner_principal_id} />
</CardName> </CardName>
<InfoItem> <InfoItem>
<span> <span>
@@ -40,6 +40,7 @@ export interface ListItem extends FormData {
// the `OwnerScopeTag` slot on the card so the owner is visible at a // the `OwnerScopeTag` slot on the card so the owner is visible at a
// glance. Optional on the wire — absent in single-owner builds. // glance. Optional on the wire — absent in single-owner builds.
owner_principal_id?: number | null; owner_principal_id?: number | null;
creator_id?: number | null;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }