feat(gpu-service): clone an instance template into the caller's scope
Add a Clone action to instance template cards. It reuses the create flow prefilled from the source, dropping the source id and ownership so the backend assigns a fresh id and scopes the copy to the caller's own principal. Clone is offered on every visible template — including admin-curated Global rows a non-admin can otherwise only view — so users can fork a Global preset into their own organization without admin involvement. The prefilled name defaults to a `-clone` suffix (trimmed to the 63-char limit) so cloning within the same scope doesn't collide on the unique name. Edit/Delete remain gated on ownership; Clone is always available.
This commit is contained in:
@@ -2,6 +2,7 @@ export default {
|
|||||||
'gpuservice.template': 'GPU Instance Template',
|
'gpuservice.template': 'GPU Instance Template',
|
||||||
'gpuservice.template.add': 'Add Instance Template',
|
'gpuservice.template.add': 'Add Instance Template',
|
||||||
'gpuservice.template.edit': 'Edit Instance Template',
|
'gpuservice.template.edit': 'Edit Instance Template',
|
||||||
|
'gpuservice.template.clone': 'Clone Instance Template',
|
||||||
'gpuservice.template.filter.name': 'Filter by name',
|
'gpuservice.template.filter.name': 'Filter by name',
|
||||||
'gpuservice.template.filter.vendor': 'Filter by vendor',
|
'gpuservice.template.filter.vendor': 'Filter by vendor',
|
||||||
'gpuservice.template.image': 'Image',
|
'gpuservice.template.image': 'Image',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export default {
|
|||||||
'gpuservice.template': 'GPU インスタンステンプレート',
|
'gpuservice.template': 'GPU インスタンステンプレート',
|
||||||
'gpuservice.template.add': 'インスタンステンプレートを追加',
|
'gpuservice.template.add': 'インスタンステンプレートを追加',
|
||||||
'gpuservice.template.edit': 'インスタンステンプレートを編集',
|
'gpuservice.template.edit': 'インスタンステンプレートを編集',
|
||||||
|
'gpuservice.template.clone': 'インスタンステンプレートを複製',
|
||||||
'gpuservice.template.filter.name': '名前でフィルター',
|
'gpuservice.template.filter.name': '名前でフィルター',
|
||||||
'gpuservice.template.filter.vendor': 'ベンダーでフィルター',
|
'gpuservice.template.filter.vendor': 'ベンダーでフィルター',
|
||||||
'gpuservice.template.image': 'コンテナイメージ',
|
'gpuservice.template.image': 'コンテナイメージ',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export default {
|
|||||||
'gpuservice.template': 'Шаблон экземпляра GPU',
|
'gpuservice.template': 'Шаблон экземпляра GPU',
|
||||||
'gpuservice.template.add': 'Добавить шаблон экземпляра',
|
'gpuservice.template.add': 'Добавить шаблон экземпляра',
|
||||||
'gpuservice.template.edit': 'Редактировать шаблон экземпляра',
|
'gpuservice.template.edit': 'Редактировать шаблон экземпляра',
|
||||||
|
'gpuservice.template.clone': 'Клонировать шаблон экземпляра',
|
||||||
'gpuservice.template.filter.name': 'Фильтр по имени',
|
'gpuservice.template.filter.name': 'Фильтр по имени',
|
||||||
'gpuservice.template.filter.vendor': 'Фильтр по производителю',
|
'gpuservice.template.filter.vendor': 'Фильтр по производителю',
|
||||||
'gpuservice.template.image': 'Образ',
|
'gpuservice.template.image': 'Образ',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export default {
|
|||||||
'gpuservice.template': 'GPU Örnek Şablonu',
|
'gpuservice.template': 'GPU Örnek Şablonu',
|
||||||
'gpuservice.template.add': 'Örnek Şablonu Ekle',
|
'gpuservice.template.add': 'Örnek Şablonu Ekle',
|
||||||
'gpuservice.template.edit': 'Örnek Şablonunu Düzenle',
|
'gpuservice.template.edit': 'Örnek Şablonunu Düzenle',
|
||||||
|
'gpuservice.template.clone': 'Örnek Şablonunu Klonla',
|
||||||
'gpuservice.template.filter.name': 'Ada göre filtrele',
|
'gpuservice.template.filter.name': 'Ada göre filtrele',
|
||||||
'gpuservice.template.filter.vendor': 'Tedarikçiye göre filtrele',
|
'gpuservice.template.filter.vendor': 'Tedarikçiye göre filtrele',
|
||||||
'gpuservice.template.image': 'İmaj',
|
'gpuservice.template.image': 'İmaj',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export default {
|
|||||||
'gpuservice.template': 'GPU 实例模板',
|
'gpuservice.template': 'GPU 实例模板',
|
||||||
'gpuservice.template.add': '添加实例模板',
|
'gpuservice.template.add': '添加实例模板',
|
||||||
'gpuservice.template.edit': '编辑实例模板',
|
'gpuservice.template.edit': '编辑实例模板',
|
||||||
|
'gpuservice.template.clone': '克隆实例模板',
|
||||||
'gpuservice.template.filter.name': '按名称过滤',
|
'gpuservice.template.filter.name': '按名称过滤',
|
||||||
'gpuservice.template.filter.vendor': '按厂商过滤',
|
'gpuservice.template.filter.vendor': '按厂商过滤',
|
||||||
'gpuservice.template.image': '镜像',
|
'gpuservice.template.image': '镜像',
|
||||||
|
|||||||
@@ -147,11 +147,22 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
|
|||||||
const access = useAccess();
|
const access = useAccess();
|
||||||
const { isDarkTheme } = useUserSettings();
|
const { isDarkTheme } = useUserSettings();
|
||||||
|
|
||||||
// Global templates (owner_principal_id NULL) are admin-curated and
|
// Only an explicit NULL owner (Global, admin-curated) is admin-only.
|
||||||
// only editable by an admin. Principal-owned rows only reach a
|
// A principal-owned row reaches a non-admin's list only when they own
|
||||||
// non-admin's list when they own them, so those are always
|
// it, and an absent id (single-owner builds omit it on the wire) has
|
||||||
// manageable by the caller.
|
// no tenancy to restrict — both are manageable, so the check is strict
|
||||||
const canManage = !!access.canSeeAdmin || data.owner_principal_id != null;
|
// ``!== 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<string, string> = useMemo(() => {
|
const manufacturerLabelMap: Record<string, string> = useMemo(() => {
|
||||||
return Object.values(GPUsConfigs).reduce(
|
return Object.values(GPUsConfigs).reduce(
|
||||||
@@ -241,14 +252,11 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderActions = () => {
|
const renderActions = () => {
|
||||||
if (!canManage) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<span onClick={handleonClickAction} className="operations">
|
<span onClick={handleonClickAction} className="operations">
|
||||||
<DropdownActions
|
<DropdownActions
|
||||||
menu={{
|
menu={{
|
||||||
items: templateActions,
|
items: actions,
|
||||||
onClick: handleOnSelect
|
onClick: handleOnSelect
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ export const templateActions: Array<{
|
|||||||
locale: true,
|
locale: true,
|
||||||
icon: icons.EditOutlined
|
icon: icons.EditOutlined
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'common.button.clone',
|
||||||
|
key: 'clone',
|
||||||
|
locale: true,
|
||||||
|
icon: icons.CopyOutlined
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'common.button.delete',
|
label: 'common.button.delete',
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { PageAction } from '@/config';
|
|
||||||
import { PageActionType } from '@/config/types';
|
import { PageActionType } from '@/config/types';
|
||||||
import { Form } from 'antd';
|
import { Form } from 'antd';
|
||||||
import { forwardRef, useEffect, useImperativeHandle } from 'react';
|
import { forwardRef, useEffect, useImperativeHandle } from 'react';
|
||||||
@@ -26,11 +25,13 @@ const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
|
|||||||
return;
|
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({
|
form.setFieldsValue({
|
||||||
...currentData
|
...currentData
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}, [action, currentData, form, open]);
|
}, [action, currentData, form, open]);
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
const handleModalOk = async (data: FormData) => {
|
||||||
try {
|
try {
|
||||||
if (openTemplateModalStatus.action === PageAction.EDIT) {
|
if (openTemplateModalStatus.action === PageAction.EDIT) {
|
||||||
@@ -112,6 +148,10 @@ const GPUServiceTemplates: React.FC = () => {
|
|||||||
handleEditTemplate(item.data);
|
handleEditTemplate(item.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (item.action === 'clone') {
|
||||||
|
handleCloneTemplate(item.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (item.action === 'delete') {
|
if (item.action === 'delete') {
|
||||||
handleDelete({ ...item.data, name: item.data.name });
|
handleDelete({ ...item.data, name: item.data.name });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user