diff --git a/src/locales/en-US/gpuservice.ts b/src/locales/en-US/gpuservice.ts index f152b1f1..03f44159 100644 --- a/src/locales/en-US/gpuservice.ts +++ b/src/locales/en-US/gpuservice.ts @@ -2,6 +2,7 @@ export default { 'gpuservice.template': 'GPU Instance Template', 'gpuservice.template.add': 'Add Instance Template', 'gpuservice.template.edit': 'Edit Instance Template', + 'gpuservice.template.clone': 'Clone Instance Template', 'gpuservice.template.filter.name': 'Filter by name', 'gpuservice.template.filter.vendor': 'Filter by vendor', 'gpuservice.template.image': 'Image', diff --git a/src/locales/ja-JP/gpuservice.ts b/src/locales/ja-JP/gpuservice.ts index 2adf9f8a..3b644512 100644 --- a/src/locales/ja-JP/gpuservice.ts +++ b/src/locales/ja-JP/gpuservice.ts @@ -2,6 +2,7 @@ export default { 'gpuservice.template': 'GPU インスタンステンプレート', 'gpuservice.template.add': 'インスタンステンプレートを追加', 'gpuservice.template.edit': 'インスタンステンプレートを編集', + 'gpuservice.template.clone': 'インスタンステンプレートを複製', 'gpuservice.template.filter.name': '名前でフィルター', 'gpuservice.template.filter.vendor': 'ベンダーでフィルター', 'gpuservice.template.image': 'コンテナイメージ', diff --git a/src/locales/ru-RU/gpuservice.ts b/src/locales/ru-RU/gpuservice.ts index f283e6f7..cba61779 100644 --- a/src/locales/ru-RU/gpuservice.ts +++ b/src/locales/ru-RU/gpuservice.ts @@ -2,6 +2,7 @@ export default { 'gpuservice.template': 'Шаблон экземпляра GPU', 'gpuservice.template.add': 'Добавить шаблон экземпляра', 'gpuservice.template.edit': 'Редактировать шаблон экземпляра', + 'gpuservice.template.clone': 'Клонировать шаблон экземпляра', 'gpuservice.template.filter.name': 'Фильтр по имени', 'gpuservice.template.filter.vendor': 'Фильтр по производителю', 'gpuservice.template.image': 'Образ', diff --git a/src/locales/tr-TR/gpuservice.ts b/src/locales/tr-TR/gpuservice.ts index fe549323..baaf1edb 100644 --- a/src/locales/tr-TR/gpuservice.ts +++ b/src/locales/tr-TR/gpuservice.ts @@ -2,6 +2,7 @@ export default { 'gpuservice.template': 'GPU Örnek Şablonu', 'gpuservice.template.add': 'Örnek Şablonu Ekle', 'gpuservice.template.edit': 'Örnek Şablonunu Düzenle', + 'gpuservice.template.clone': 'Örnek Şablonunu Klonla', 'gpuservice.template.filter.name': 'Ada göre filtrele', 'gpuservice.template.filter.vendor': 'Tedarikçiye göre filtrele', 'gpuservice.template.image': 'İmaj', diff --git a/src/locales/zh-CN/gpuservice.ts b/src/locales/zh-CN/gpuservice.ts index a3b9dee9..902e5d22 100644 --- a/src/locales/zh-CN/gpuservice.ts +++ b/src/locales/zh-CN/gpuservice.ts @@ -2,6 +2,7 @@ export default { 'gpuservice.template': 'GPU 实例模板', 'gpuservice.template.add': '添加实例模板', 'gpuservice.template.edit': '编辑实例模板', + 'gpuservice.template.clone': '克隆实例模板', 'gpuservice.template.filter.name': '按名称过滤', 'gpuservice.template.filter.vendor': '按厂商过滤', 'gpuservice.template.image': '镜像', diff --git a/src/pages/gpu-service/templates/components/template-card.tsx b/src/pages/gpu-service/templates/components/template-card.tsx index 5a24cca4..d0203c4b 100644 --- a/src/pages/gpu-service/templates/components/template-card.tsx +++ b/src/pages/gpu-service/templates/components/template-card.tsx @@ -147,11 +147,22 @@ const TemplateCardItem: React.FC = ({ data, onSelect }) => { const access = useAccess(); const { isDarkTheme } = useUserSettings(); - // Global templates (owner_principal_id NULL) are admin-curated and - // only editable by an admin. Principal-owned rows only reach a - // non-admin's list when they own them, so those are always - // manageable by the caller. - const canManage = !!access.canSeeAdmin || data.owner_principal_id != null; + // Only an explicit NULL owner (Global, admin-curated) is admin-only. + // A principal-owned row reaches a non-admin's list only when they own + // it, and an absent id (single-owner builds omit it on the wire) has + // no tenancy to restrict — both are manageable, so the check is strict + // ``!== null``. Clone stays available regardless — it reads the source + // and creates a fresh copy in the caller's own scope — so a non-admin + // can fork a Global preset into their org. + const canManage = !!access.canSeeAdmin || data.owner_principal_id !== null; + + const actions = useMemo( + () => + canManage + ? templateActions + : templateActions.filter((action) => action.key === 'clone'), + [canManage] + ); const manufacturerLabelMap: Record = useMemo(() => { return Object.values(GPUsConfigs).reduce( @@ -241,14 +252,11 @@ const TemplateCardItem: React.FC = ({ data, onSelect }) => { }; const renderActions = () => { - if (!canManage) { - return null; - } return ( diff --git a/src/pages/gpu-service/templates/config/index.tsx b/src/pages/gpu-service/templates/config/index.tsx index 9e67fa79..c670906f 100644 --- a/src/pages/gpu-service/templates/config/index.tsx +++ b/src/pages/gpu-service/templates/config/index.tsx @@ -114,6 +114,12 @@ export const templateActions: Array<{ locale: true, icon: icons.EditOutlined }, + { + label: 'common.button.clone', + key: 'clone', + locale: true, + icon: icons.CopyOutlined + }, { label: 'common.button.delete', key: 'delete', diff --git a/src/pages/gpu-service/templates/forms/index.tsx b/src/pages/gpu-service/templates/forms/index.tsx index ee0e9c5f..c1db3382 100644 --- a/src/pages/gpu-service/templates/forms/index.tsx +++ b/src/pages/gpu-service/templates/forms/index.tsx @@ -1,4 +1,3 @@ -import { PageAction } from '@/config'; import { PageActionType } from '@/config/types'; import { Form } from 'antd'; import { forwardRef, useEffect, useImperativeHandle } from 'react'; @@ -26,11 +25,13 @@ const GPUServiceTemplateForm: React.FC = forwardRef( return; } - if (action === PageAction.EDIT && currentData) { + // Prefill on Edit and on Clone (Create carrying a source row). + // A plain Create opens with no ``currentData`` and keeps the + // blank ``initialValues``. + if (currentData) { form.setFieldsValue({ ...currentData }); - return; } }, [action, currentData, form, open]); diff --git a/src/pages/gpu-service/templates/index.tsx b/src/pages/gpu-service/templates/index.tsx index a3472fdf..df7d7875 100644 --- a/src/pages/gpu-service/templates/index.tsx +++ b/src/pages/gpu-service/templates/index.tsx @@ -90,6 +90,42 @@ const GPUServiceTemplates: React.FC = () => { ); }; + const handleCloneTemplate = (row: ListItem) => { + // Clone reuses the create flow: drop the source's identity and + // ownership so the backend assigns a fresh id and scopes the copy + // to the caller's own principal. The prefilled name is editable — + // the create endpoint enforces per-owner name uniqueness. + const source = _.omit(row, [ + 'id', + 'owner_principal_id', + 'creator_id', + 'created_at', + 'updated_at', + 'status' + ]) as ListItem; + // Default to a ``-clone`` suffix so cloning within the same scope + // (e.g. a Global preset kept Global) doesn't immediately collide on + // the unique name. Keep within the 63-char name limit by trimming + // the base first. + const suffix = '-clone'; + const base = (row.name ?? '').slice(0, 63 - suffix.length); + source.name = `${base}${suffix}`; + // The card renders ``displayName || name``, so a copied displayName + // would make the clone indistinguishable from its source. Append a + // localized clone label, trimmed to the same 63-char field limit. + if (row.displayName) { + const cloneLabel = intl.formatMessage({ id: 'common.button.clone' }); + const displaySuffix = ` (${cloneLabel})`; + const displayBase = row.displayName.slice(0, 63 - displaySuffix.length); + source.displayName = `${displayBase}${displaySuffix}`; + } + openTemplateModal( + PageAction.CREATE, + intl.formatMessage({ id: 'gpuservice.template.clone' }), + source + ); + }; + const handleModalOk = async (data: FormData) => { try { if (openTemplateModalStatus.action === PageAction.EDIT) { @@ -112,6 +148,10 @@ const GPUServiceTemplates: React.FC = () => { handleEditTemplate(item.data); return; } + if (item.action === 'clone') { + handleCloneTemplate(item.data); + return; + } if (item.action === 'delete') { handleDelete({ ...item.data, name: item.data.name }); }