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:
@@ -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',
|
||||
|
||||
@@ -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': 'ストレージを編集',
|
||||
|
||||
@@ -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': 'Редактировать хранилище',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user