feat(gpu-service): restrict non-stopped instance edit, drop recreate

- non-stopped edit: only displayName and SSH public keys editable;
  type/template/storage sections disabled (stopped edit keeps all editable)
- remove recreate feature and the realAction prop it required:
  entry, delete-then-create submit path, warning banner, locale keys
This commit is contained in:
jialin
2026-07-23 10:24:54 +08:00
parent 89a74ed26a
commit e919b22d98
11 changed files with 41 additions and 129 deletions
-3
View File
@@ -178,9 +178,6 @@ export default {
'Only events from the last hour are shown',
'gpuservice.instance.event.tab.instance': 'Instance Events',
'gpuservice.instance.event.tab.volume': 'Volume Events',
'gpuservice.instance.recreate.confirm.title': 'Confirm recreation',
'gpuservice.instance.recreate.confirm.content':
'The current instance will be deleted first, then recreated with the current configuration.\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'gpuservice.storage': 'Storage',
'gpuservice.storage.add': 'Add Storage',
'gpuservice.storage.edit': 'Edit Storage',
-3
View File
@@ -177,9 +177,6 @@ export default {
'直近 1 時間のイベントのみ表示されます',
'gpuservice.instance.event.tab.instance': 'インスタンスイベント',
'gpuservice.instance.event.tab.volume': 'ボリュームイベント',
'gpuservice.instance.recreate.confirm.title': '再作成を確認しますか',
'gpuservice.instance.recreate.confirm.content':
'現在のインスタンスを削除した後、現在の構成で再作成します。\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'gpuservice.storage': 'ストレージ',
'gpuservice.storage.add': 'ストレージを追加',
'gpuservice.storage.edit': 'ストレージを編集',
-3
View File
@@ -176,9 +176,6 @@ export default {
'Отображаются только события за последний час',
'gpuservice.instance.event.tab.instance': 'События экземпляра',
'gpuservice.instance.event.tab.volume': 'События тома',
'gpuservice.instance.recreate.confirm.title': 'Подтвердить пересоздание',
'gpuservice.instance.recreate.confirm.content':
'Текущий экземпляр будет сначала удалён, а затем пересоздан с текущей конфигурацией.\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'gpuservice.storage': 'Хранилище',
'gpuservice.storage.add': 'Добавить хранилище',
'gpuservice.storage.edit': 'Редактировать хранилище',
-4
View File
@@ -172,10 +172,6 @@ export default {
'Yalnızca son bir saatteki olaylar gösterilir',
'gpuservice.instance.event.tab.instance': 'Örnek Olayları',
'gpuservice.instance.event.tab.volume': 'Birim Olayları',
'gpuservice.instance.recreate.confirm.title':
'Yeniden oluşturma onaylansın mı',
'gpuservice.instance.recreate.confirm.content':
'Mevcut örnek önce silinecek, ardından mevcut yapılandırmayla yeniden oluşturulacaktır.\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'gpuservice.storage': 'Depolama',
'gpuservice.storage.add': 'Depolama Ekle',
'gpuservice.storage.edit': 'Depolamayı Düzenle',
-3
View File
@@ -162,9 +162,6 @@ export default {
'gpuservice.instance.event.recentHourTip': '仅显示最近一小时的事件。',
'gpuservice.instance.event.tab.instance': '实例事件',
'gpuservice.instance.event.tab.volume': '存储卷事件',
'gpuservice.instance.recreate.confirm.title': '确认重新创建',
'gpuservice.instance.recreate.confirm.content':
'系统将先删除当前实例,然后使用当前配置重新创建。\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'gpuservice.storage': '存储',
'gpuservice.storage.add': '添加存储',
'gpuservice.storage.edit': '编辑存储',
@@ -5,12 +5,7 @@ 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,
ColumnWrapper,
GSDrawer,
ModalFooter
} from '@gpustack/core-ui';
import { ColumnWrapper, GSDrawer, ModalFooter } from '@gpustack/core-ui';
import { useIntl, useModel } from '@umijs/max';
import { Input, Typography } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -30,7 +25,6 @@ type AddModalProps = {
action: PageActionType;
open: boolean;
width?: number | string;
realAction?: string;
clusterList?: Array<{
label: string;
value: number;
@@ -84,8 +78,7 @@ const AddModal: React.FC<AddModalProps> = ({
data,
onCancel,
width,
clusterList = [],
realAction
clusterList = []
}) => {
const intl = useIntl();
const { initialState } = useModel('@@initialState') || {};
@@ -173,16 +166,18 @@ const AddModal: React.FC<AddModalProps> = ({
);
// const readonly = action === PageAction.VIEW;
const readonly = false;
const isRecreate = realAction === PageAction.CREATE;
const showResourceSelectors = action === PageAction.CREATE || isRecreate;
const shouldAutoSelectResource = action === PageAction.CREATE && !isRecreate;
const showResourceSelectors = action === PageAction.CREATE;
// Only a stopped instance can be re-typed on edit. It shows the instance-type
// column (but not the template column) beside the form; the create card
// columns render for CREATE / recreate.
// columns render for CREATE.
const isStoppedEdit =
action === PageAction.EDIT &&
data?.status?.phase === InstanceStatusValueMap.Stopped;
const showInstanceTypeColumn = showResourceSelectors || isStoppedEdit;
// Editing a non-stopped instance is restricted: only displayName and the
// SSH public keys stay editable; the type / template / storage sections
// render disabled. A stopped instance edits everything.
const isRestrictedEdit = action === PageAction.EDIT && !isStoppedEdit;
const findTemplateByManufacturer = (
manufacturer: string | undefined,
@@ -266,43 +261,12 @@ const AddModal: React.FC<AddModalProps> = ({
);
};
const findAggregateOf = (
candidateName: string | undefined,
clusterId: number | null | undefined,
instanceTypes: InstanceTypeItem[]
): InstanceTypeItem | undefined => {
if (!candidateName) return undefined;
return instanceTypes.find((item) =>
(item.status?.tiers ?? []).some((tier) =>
(tier.candidates ?? []).some(
(c) => c.name === candidateName && Number(c.cluster) === clusterId
)
)
);
};
// initial for first
const applyAutoSelection = (
instanceTypes: InstanceTypeItem[],
templates: TemplateItem[],
orgId?: number | null
) => {
// On edit / view, surface the persisted selection in the card list.
if (!shouldAutoSelectResource) {
const aggregate = findAggregateOf(
data?.spec?.type,
data?.clusterId,
instanceTypes
);
if (aggregate) {
setInstanceTypeSelection({
instanceType: aggregate.name,
manufacturer: manufacturerOf(aggregate)
});
}
return;
}
// Scope to clusters the chosen org owns (admin "All" view).
const owned = filterTypesByOwner(instanceTypes, orgId);
@@ -376,7 +340,7 @@ const AddModal: React.FC<AddModalProps> = ({
// full instance-type list to re-type a stopped instance.
fetchData({ page: -1 });
}
}, [open, shouldAutoSelectResource, action]);
}, [open, action]);
// filter instance types (already scoped to the chosen org's clusters)
const filteredInstanceTypes = ownedInstanceTypes.filter((item) =>
@@ -664,17 +628,6 @@ const AddModal: React.FC<AddModalProps> = ({
styles={{ container: { paddingBlock: 0 } }}
footer={
<>
{isRecreate && open && (
<div style={{ marginInline: 24, paddingTop: 8 }}>
<AlertBlockInfo
type="warning"
contentStyle={{ paddingInline: 0 }}
message={intl.formatMessage({
id: 'gpuservice.instance.recreate.confirm.content'
})}
/>
</div>
)}
<ModalFooter
onOk={handleSubmit}
onCancel={handleCancel}
@@ -698,9 +651,9 @@ const AddModal: React.FC<AddModalProps> = ({
<GPUServiceInstanceForm
ref={form}
action={action}
realAction={realAction}
currentData={data}
disabled={readonly}
restrictedEdit={isRestrictedEdit}
onFinish={onFinish}
onFinishFailed={release}
onScopeChange={handleScopeChange}
+22 -21
View File
@@ -59,7 +59,7 @@ interface InstanceFormProps {
ref?: any;
open: boolean;
action: PageActionType;
realAction?: PageActionType | string;
// Present on edit / view.
currentData?: ListItem | null;
namespace?: string;
instanceTypeList?: InstanceTypeItem[];
@@ -67,6 +67,9 @@ interface InstanceFormProps {
// surfaces a "no available instance type" message in the scheduling tab.
noAvailableInstanceTypes?: boolean;
disabled?: boolean;
// Editing a non-stopped instance: only displayName and the SSH public keys
// stay editable; the type / template / storage sections render disabled.
restrictedEdit?: boolean;
// Fired when the create-scope picker retargets the form to another
// org (or Global). Only emitted on genuine changes — never on the
// initial mount, and never in builds where the picker isn't mounted
@@ -111,9 +114,9 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
(props, ref) => {
const {
action,
realAction,
currentData,
disabled,
restrictedEdit,
open,
instanceTypeList = [],
noAvailableInstanceTypes,
@@ -125,8 +128,9 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
const { getRuleMessage } = useAppUtils();
const [form] = Form.useForm<InstanceFormValues>();
const scrollTabsRef = useRef<any>(null);
const formAction =
realAction === PageAction.CREATE ? PageAction.CREATE : action;
// Restricted (non-stopped) edit disables the type / template / storage
// sections; displayName and the SSH public keys keep following `disabled`.
const sectionDisabled = disabled || restrictedEdit;
const sshEnabled = Form.useWatch('enable_ssh', form);
const description = Form.useWatch(['description'], form);
// `organization_id` is owned by the create-scope picker slot; it only
@@ -464,8 +468,8 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
};
// Whole-card (exclusive) vs sliced (percentage) mode. Only meaningful for
// sliceable accelerator types; derived (no persisted field) — on edit/
// recreate it is inferred from acceleratorSlicedMemoryPercentage > 0.
// sliceable accelerator types; derived (no persisted field) — on edit it
// is inferred from acceleratorSlicedMemoryPercentage > 0.
const [sliceMode, setSliceMode] = useState<'whole' | 'sliced'>('whole');
const handleAcceleratorChange = (count: number) => {
@@ -582,11 +586,8 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
return;
}
if (
action === PageAction.EDIT ||
action === PageAction.VIEW ||
realAction === PageAction.CREATE
) {
// Prefill from the source row on edit / view.
if (currentData) {
console.log('currentData', currentData);
const currentSpec = parseJsonSafe(
currentData?.description || '{}',
@@ -597,8 +598,8 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
? _.toNumber(currentData?.spec?.resources?.accelerator)
: _.toNumber(currentData?.spec?.resources?.cpu) || 0;
// Infer the mode from the persisted slice percentage (recreate keeps
// the section editable; edit/view render a readonly card).
// Infer the mode from the persisted slice percentage (edit/view
// render a readonly card).
const persistedSliced =
_.toNumber(
currentData?.spec?.resources?.acceleratorSlicedMemoryPercentage
@@ -624,12 +625,12 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
});
// buildResourcesData above filled CPU / RAM for the whole card; rescale
// them off the persisted percentages when recreating a sliced instance.
// them off the persisted percentages for a sliced instance.
if (persistedSliced) {
applySlicedResourceScaling();
}
}
}, [action, currentData, form, open, realAction, instanceTypeList]);
}, [action, currentData, form, open, instanceTypeList]);
const getUnitResources = () => {
if (selectedInstanceType?.spec?.unitResourcesParsed) {
@@ -736,7 +737,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
>
<FormContext.Provider
value={{
action: formAction,
action: action,
currentData: currentData,
isGPUType: isGPUType
}}
@@ -780,7 +781,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
storageMode: StorageModeValueMap.Temporary
}}
>
<Basic action={formAction} disabled={disabled} />
<Basic action={action} disabled={disabled} />
<Form.Item name="clusterId" hidden>
<CInput.Input />
</Form.Item>
@@ -797,8 +798,8 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
forceRender: true,
children: (
<InstanceTypeFormItem
action={formAction}
disabled={disabled}
action={action}
disabled={sectionDisabled}
selectedInstanceType={selectedInstanceType}
currentData={currentData as any}
onceMaxRequest={onceMaxRequest}
@@ -820,7 +821,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
children: (
<TemplateBasicForm
page="instance"
disabled={disabled}
disabled={sectionDisabled}
onceMaxRequest={onceMaxRequest}
/>
)
@@ -832,7 +833,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}),
forceRender: true,
children: (
<StorageVolume disabled={disabled} action={formAction} />
<StorageVolume disabled={sectionDisabled} action={action} />
)
}
]}
@@ -136,8 +136,8 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
};
// Sliced mode is only offered for sliceable accelerator types, and only when
// the section is editable (create / recreate, or edit after re-picking a
// type; a not-yet-re-typed edit renders a readonly card).
// the section is editable (create, or edit after re-picking a type; a
// not-yet-re-typed edit renders a readonly card).
const showModeSwitch =
!readonlyType &&
isGPUType &&
@@ -15,30 +15,26 @@ const useCreateInstance = () => {
title: string;
currentData?: ListItem | null;
width?: number | string;
realAction?: string;
}>({
action: PageAction.CREATE,
title: '',
open: false,
width: undefined,
currentData: null,
realAction: undefined
currentData: null
});
const openModal = (
action: PageActionType,
title: string,
currentData?: ListItem | null,
width?: number | string,
realAction?: string
width?: number | string
) => {
setOpenModalStatus({
action,
title,
open: true,
currentData,
width,
realAction
width
});
saveScrollHeight();
};
@@ -73,23 +69,12 @@ const useCreateInstance = () => {
);
};
const openRecreateInstanceModal = (row: ListItem) => {
openModal(
PageAction.EDIT,
intl.formatMessage({ id: 'common.button.recreate' }),
row,
'calc(100vw - 220px)',
PageAction.CREATE
);
};
const closeModal = () => {
setOpenModalStatus({
...openModalStatus,
title: '',
open: false,
currentData: null,
realAction: undefined
currentData: null
});
restoreScrollHeight();
};
@@ -101,7 +86,6 @@ const useCreateInstance = () => {
openCreateInstanceModal,
openEditInstanceModal,
openViewInstanceModal,
openRecreateInstanceModal,
closeInstanceModal: closeModal
};
};
+1 -11
View File
@@ -86,7 +86,6 @@ const GPUService: React.FC = () => {
openCreateInstanceModal,
openEditInstanceModal,
openViewInstanceModal,
openRecreateInstanceModal,
closeInstanceModal
} = useCreateInstance();
const { openViewLogsModal, closeViewLogsModal, openViewLogsModalStatus } =
@@ -136,13 +135,7 @@ const GPUService: React.FC = () => {
const handleModalOk = async (data: FormData) => {
try {
if (openInstanceModalStatus.realAction === PageAction.CREATE) {
await deleteGPUServiceInstance(openInstanceModalStatus.currentData!.id);
await new Promise((resolve) => {
setTimeout(resolve, 300);
});
await createInstance({ data });
} else if (openInstanceModalStatus.action === PageAction.EDIT) {
if (openInstanceModalStatus.action === PageAction.EDIT) {
await updateInstance({
id: openInstanceModalStatus.currentData!.id,
data
@@ -243,8 +236,6 @@ const GPUService: React.FC = () => {
openEditInstanceModal(row);
} else if (val === 'delete') {
handleDelete({ ...row });
} else if (val === 'recreate') {
openRecreateInstanceModal(row);
} else if (val === 'viewlog') {
openViewLogsModal(row);
} else if (val === 'viewevent') {
@@ -391,7 +382,6 @@ const GPUService: React.FC = () => {
title={openInstanceModalStatus.title}
data={openInstanceModalStatus.currentData}
width={openInstanceModalStatus.width}
realAction={openInstanceModalStatus.realAction}
clusterList={clusterList}
onCancel={closeInstanceModal}
onOk={handleModalOk}
@@ -23,8 +23,8 @@ export const buildInstanceTypeSnapshotSpec = (
// Serialize the chosen instance type into the instance's `description` field —
// a persisted spec snapshot the form reads back to render the type card and
// derive unit resources. Shared by the create/recreate flow (card selection)
// and the edit flow (change-type overlay).
// derive unit resources. Shared by the create flow (card selection) and the
// edit flow (change-type overlay).
export const saveInstanceDataInDescription = (
instanceType: InstanceTypeItem
): string => {