diff --git a/src/locales/en-US/gpuservice.ts b/src/locales/en-US/gpuservice.ts index 47735599..b567227c 100644 --- a/src/locales/en-US/gpuservice.ts +++ b/src/locales/en-US/gpuservice.ts @@ -174,5 +174,9 @@ export default { 'Please enter the temporary storage capacity', 'gpuservice.form.rule.name': "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' }; diff --git a/src/locales/ja-JP/gpuservice.ts b/src/locales/ja-JP/gpuservice.ts index 50febcde..639e8c2b 100644 --- a/src/locales/ja-JP/gpuservice.ts +++ b/src/locales/ja-JP/gpuservice.ts @@ -174,5 +174,9 @@ export default { 'Data is cleared when the instance stops.', 'gpuservice.storage.persistentVolume.tips': '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': 'グローバルテンプレート' }; diff --git a/src/locales/ru-RU/gpuservice.ts b/src/locales/ru-RU/gpuservice.ts index 3d01ed84..0c4f3665 100644 --- a/src/locales/ru-RU/gpuservice.ts +++ b/src/locales/ru-RU/gpuservice.ts @@ -171,5 +171,9 @@ export default { 'Data is cleared when the instance stops.', 'gpuservice.storage.persistentVolume.tips': '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': 'Глобальные шаблоны' }; diff --git a/src/locales/tr-TR/gpuservice.ts b/src/locales/tr-TR/gpuservice.ts index 9aee124b..1c3a5068 100644 --- a/src/locales/tr-TR/gpuservice.ts +++ b/src/locales/tr-TR/gpuservice.ts @@ -169,5 +169,9 @@ export default { 'Data is cleared when the instance stops.', 'gpuservice.storage.persistentVolume.tips': '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' }; diff --git a/src/locales/zh-CN/gpuservice.ts b/src/locales/zh-CN/gpuservice.ts index d53e14fe..eb595811 100644 --- a/src/locales/zh-CN/gpuservice.ts +++ b/src/locales/zh-CN/gpuservice.ts @@ -158,5 +158,9 @@ export default { 'gpuservice.storage.tempCapacity.required': '请输入临时存储容量', 'gpuservice.form.rule.name': '由小写字母、数字和 "-" 组成,以字母或数字开头和结尾,不能包含连续的 "-",最多 63 个字符。', - 'gpuservice.form.storage.select': '选择存储' + 'gpuservice.form.storage.select': '选择存储', + 'gpuservice.creator': '创建者', + 'gpuservice.owner.global': '全局', + 'gpuservice.template.group.yours': '我的模板', + 'gpuservice.template.group.global': '全局模板' }; diff --git a/src/pages/gpu-service/components/owner-tag.tsx b/src/pages/gpu-service/components/owner-tag.tsx new file mode 100644 index 00000000..0f9383c7 --- /dev/null +++ b/src/pages/gpu-service/components/owner-tag.tsx @@ -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 ( + + {intl.formatMessage({ id: 'gpuservice.owner.global' })} + + ); + } + + 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 ( + + {username} + + ); +}; + +export default OwnerTag; diff --git a/src/pages/gpu-service/hooks/use-creator-column.tsx b/src/pages/gpu-service/hooks/use-creator-column.tsx new file mode 100644 index 00000000..ae5cc58a --- /dev/null +++ b/src/pages/gpu-service/hooks/use-creator-column.tsx @@ -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 = { + title: string; + key: string; + ellipsis: { showTitle: false }; + render: (text: any, record: T) => React.ReactNode; +}; + +export const CreatorLabel: React.FC<{ + record: CreatorAware; + users: Map; +}> = ({ 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 ( + + {username} + + ); + } + // Unresolvable: the directory is still loading, or a legacy row + // whose only attribution is a non-USER principal (platform-owned). + return -; +}; + +/** + * "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 = ( + pageKey: string +): CreatorColumn[] => { + const intl = useIntl(); + const access = useAccess(); + const pluginOwnsSlot = !!( + getGPUStackPlugin()?.listExtraColumns as Record | 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) => ( + + ) + } + ]; + }, [show, users, intl]); +}; + +export default useCreatorColumn; diff --git a/src/pages/gpu-service/hooks/use-user-directory.ts b/src/pages/gpu-service/hooks/use-user-directory.ts new file mode 100644 index 00000000..fe4d334e --- /dev/null +++ b/src/pages/gpu-service/hooks/use-user-directory.ts @@ -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> | null = null; + +const fetchDirectory = (): Promise> => { + 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>(new Map()); + + useEffect(() => { + if (!enabled) return undefined; + let alive = true; + fetchDirectory() + .then((map) => { + if (alive) setUsers(map); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, [enabled]); + + return users; +} diff --git a/src/pages/gpu-service/instances/components/add-modal.tsx b/src/pages/gpu-service/instances/components/add-modal.tsx index cc7979d4..88c5a47a 100644 --- a/src/pages/gpu-service/instances/components/add-modal.tsx +++ b/src/pages/gpu-service/instances/components/add-modal.tsx @@ -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 = ({ 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(null); const sessionRef = useRef(0); const [instanceTypeSelection, setInstanceTypeSelection] = useState<{ @@ -395,6 +403,89 @@ const AddModal: React.FC = ({ ); }); + // 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(); + + 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 = ({ {filteredTemplates.length > 0 && initializedRef.current ? ( ) : ( diff --git a/src/pages/gpu-service/instances/config/types.ts b/src/pages/gpu-service/instances/config/types.ts index 367d3cac..87dc6b66 100644 --- a/src/pages/gpu-service/instances/config/types.ts +++ b/src/pages/gpu-service/instances/config/types.ts @@ -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; } diff --git a/src/pages/gpu-service/instances/forms/template-selector.tsx b/src/pages/gpu-service/instances/forms/template-selector.tsx index 98e0ca12..0216312c 100644 --- a/src/pages/gpu-service/instances/forms/template-selector.tsx +++ b/src/pages/gpu-service/instances/forms/template-selector.tsx @@ -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 = ({ value, onChange, - dataList = [] + groups = [] }) => { const intl = useIntl(); @@ -59,48 +73,59 @@ const TemplateSelector: React.FC = ({ onChange?.(item.id, item); }; + const renderItem = (item: TemplateItem) => ( + handleSelect(item)} + > + +
+ + {item.displayName || item.name || '-'} + +
+
+ + {' '} + {intl.formatMessage({ + id: 'gpuservice.instance.template.image' + })} + : + + + {item.spec?.image || '-'} + +
+
+ + {' '} + {intl.formatMessage({ + id: 'gpuservice.instance.template.mount' + })} + : + + {item.spec?.volumeMount || '-'} +
+
+
+ ); + + // A single group renders flat — the header would only restate what + // the whole list already is. + const showGroupTitles = groups.length > 1; + return ( - {dataList.map((item: TemplateItem) => ( - handleSelect(item)} - > - -
- - {item.displayName || item.name || '-'} - -
-
- - {' '} - {intl.formatMessage({ - id: 'gpuservice.instance.template.image' - })} - : - - - {item.spec?.image || '-'} - -
-
- - {' '} - {intl.formatMessage({ - id: 'gpuservice.instance.template.mount' - })} - : - - {item.spec?.volumeMount || '-'} -
-
-
+ {groups.map((group) => ( + + {showGroupTitles && {group.label}} + {group.items.map(renderItem)} + ))}
); diff --git a/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx b/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx index e88296b7..b60fef53 100644 --- a/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx +++ b/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx @@ -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('gpuInstances'); const renderInstanceType = (record: ListItem) => { const description = @@ -403,6 +405,7 @@ const useInstancesColumns = ({ ) }, + ...creatorCols, { title: intl.formatMessage({ id: 'common.table.createTime' }), dataIndex: 'created_at', @@ -441,7 +444,8 @@ const useInstancesColumns = ({ clusterList, intl, pvCapacityByName, - pluginCols + pluginCols, + creatorCols ]); }; diff --git a/src/pages/gpu-service/public-keys/config/types.ts b/src/pages/gpu-service/public-keys/config/types.ts index 078ca656..ec28ded8 100644 --- a/src/pages/gpu-service/public-keys/config/types.ts +++ b/src/pages/gpu-service/public-keys/config/types.ts @@ -14,6 +14,7 @@ export interface ListItem { updated_at: string; deleted_at?: string | null; owner_principal_id?: number | null; + creator_id?: number | null; name?: string; displayName?: string | null; description?: string | null; diff --git a/src/pages/gpu-service/public-keys/hooks/use-public-key-columns.tsx b/src/pages/gpu-service/public-keys/hooks/use-public-key-columns.tsx index ecfde62f..6d1bf57c 100644 --- a/src/pages/gpu-service/public-keys/hooks/use-public-key-columns.tsx +++ b/src/pages/gpu-service/public-keys/hooks/use-public-key-columns.tsx @@ -1,3 +1,4 @@ +import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column'; import { usePluginListColumns } from '@/plugins/list-extra-columns'; import { AutoTooltip, DropdownButtons, icons } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; @@ -35,6 +36,7 @@ const usePublicKeyColumns = ({ }: ColumnsHookProps): ColumnsType => { const intl = useIntl(); const pluginCols = usePluginListColumns('gpuPublicKeys'); + const creatorCols = useCreatorColumn('gpuPublicKeys'); return useMemo(() => { const pluginRendered = pluginCols.map((c) => ({ title: intl.formatMessage({ id: c.titleId }), @@ -62,6 +64,7 @@ const usePublicKeyColumns = ({ ) }, ...pluginRendered, + ...creatorCols, { title: intl.formatMessage({ id: 'common.table.createTime' }), dataIndex: 'created_at', @@ -89,7 +92,7 @@ const usePublicKeyColumns = ({ ) } ]; - }, [handleSelect, sortOrder, intl, pluginCols]); + }, [handleSelect, sortOrder, intl, pluginCols, creatorCols]); }; export default usePublicKeyColumns; diff --git a/src/pages/gpu-service/storage-types/config/types.ts b/src/pages/gpu-service/storage-types/config/types.ts index 17ab45eb..f65405e8 100644 --- a/src/pages/gpu-service/storage-types/config/types.ts +++ b/src/pages/gpu-service/storage-types/config/types.ts @@ -36,6 +36,7 @@ export interface ListItem { updated_at: string; deleted_at?: string | null; owner_principal_id?: number | null; + creator_id?: number | null; displayName?: string | null; description?: string | null; name: string; diff --git a/src/pages/gpu-service/storage-types/hooks/use-storage-type-columns.tsx b/src/pages/gpu-service/storage-types/hooks/use-storage-type-columns.tsx index 6ff58711..39636881 100644 --- a/src/pages/gpu-service/storage-types/hooks/use-storage-type-columns.tsx +++ b/src/pages/gpu-service/storage-types/hooks/use-storage-type-columns.tsx @@ -1,3 +1,4 @@ +import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column'; import { usePluginListColumns } from '@/plugins/list-extra-columns'; import { FolderOutlined } from '@ant-design/icons'; import { @@ -48,6 +49,7 @@ const useStorageTypeColumns = ({ }: ColumnsHookProps): ColumnsType => { const intl = useIntl(); const pluginCols = usePluginListColumns('gpuStorageTypes'); + const creatorCols = useCreatorColumn('gpuStorageTypes'); return useMemo(() => { const pluginRendered = pluginCols.map((c) => ({ title: intl.formatMessage({ id: c.titleId }), @@ -79,6 +81,7 @@ const useStorageTypeColumns = ({ sorter: false, render: (_text, record) => getKindLabel(record) }, + ...creatorCols, // { // title: intl.formatMessage({ id: 'common.table.description' }), // dataIndex: 'description', @@ -115,7 +118,7 @@ const useStorageTypeColumns = ({ ) } ]; - }, [handleSelect, sortOrder, intl, pluginCols]); + }, [handleSelect, sortOrder, intl, pluginCols, creatorCols]); }; export default useStorageTypeColumns; diff --git a/src/pages/gpu-service/storage/config/types.ts b/src/pages/gpu-service/storage/config/types.ts index aa3666bf..966ffd37 100644 --- a/src/pages/gpu-service/storage/config/types.ts +++ b/src/pages/gpu-service/storage/config/types.ts @@ -23,6 +23,7 @@ export interface ListItem { updated_at: string; deleted_at?: string | null; owner_principal_id?: number | null; + creator_id?: number | null; displayName?: string | null; description?: string | null; name: string; diff --git a/src/pages/gpu-service/storage/hooks/use-storage-columns.tsx b/src/pages/gpu-service/storage/hooks/use-storage-columns.tsx index 65d4cc4d..abac1a5f 100644 --- a/src/pages/gpu-service/storage/hooks/use-storage-columns.tsx +++ b/src/pages/gpu-service/storage/hooks/use-storage-columns.tsx @@ -1,3 +1,4 @@ +import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column'; import { usePluginListColumns } from '@/plugins/list-extra-columns'; import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; @@ -20,6 +21,7 @@ const useStorageColumns = ({ }: ColumnsHookProps): ColumnsType => { const intl = useIntl(); const pluginCols = usePluginListColumns('gpuStorage'); + const creatorCols = useCreatorColumn('gpuStorage'); return useMemo(() => { const pluginRendered = pluginCols.map((c) => ({ title: intl.formatMessage({ id: c.titleId }), @@ -68,6 +70,7 @@ const useStorageColumns = ({ sorter: false, render: (value: string) => (value ? value.replace(/Gi$/, 'GB') : '-') }, + ...creatorCols, // { // title: intl.formatMessage({ id: 'common.table.status' }), // dataIndex: ['status', 'phase'], @@ -111,7 +114,14 @@ const useStorageColumns = ({ ) } ]; - }, [handleSelect, sortOrder, storageClassList, intl, pluginCols]); + }, [ + handleSelect, + sortOrder, + storageClassList, + intl, + pluginCols, + creatorCols + ]); }; export default useStorageColumns; diff --git a/src/pages/gpu-service/templates/components/template-card.tsx b/src/pages/gpu-service/templates/components/template-card.tsx index 15ff7d88..a25609d7 100644 --- a/src/pages/gpu-service/templates/components/template-card.tsx +++ b/src/pages/gpu-service/templates/components/template-card.tsx @@ -14,6 +14,7 @@ import tensorflowkLogo from '@/assets/logo/tensorflow.svg'; import ubuntuLogo from '@/assets/logo/ubuntu_logo.png'; import vllmLogo from '@/assets/logo/vllm.png'; import PluginExtraFields from '@/components/plugin-extra-fields'; +import OwnerTag from '@/pages/gpu-service/components/owner-tag'; import { GPUsConfigs, manfacturerValueMap @@ -266,6 +267,7 @@ const TemplateCardItem: React.FC = ({ data, onSelect }) => { name="OwnerScopeTag" context={{ ownerPrincipalId: data.owner_principal_id }} /> + diff --git a/src/pages/gpu-service/templates/config/types.ts b/src/pages/gpu-service/templates/config/types.ts index 9bf1d7df..a1ccc989 100644 --- a/src/pages/gpu-service/templates/config/types.ts +++ b/src/pages/gpu-service/templates/config/types.ts @@ -40,6 +40,7 @@ export interface ListItem extends FormData { // the `OwnerScopeTag` slot on the card so the owner is visible at a // glance. Optional on the wire — absent in single-owner builds. owner_principal_id?: number | null; + creator_id?: number | null; created_at?: string; updated_at?: string; }