From 96d194a4f4dd52c6d45b191445335c420c060660 Mon Sep 17 00:00:00 2001 From: jialin Date: Mon, 13 Jul 2026 17:18:31 +0800 Subject: [PATCH] feat(gpu-service): allow re-typing stopped instances on edit - Add instance-type column to edit drawer for stopped instances - Extract saveInstanceDataInDescription into shared util - Floor sliced unit resources; keep CPU >= 1 core - Expose applyInstanceType for both create and edit flows --- .../instances/components/add-modal.tsx | 217 ++++++++++-------- .../components/instance-type-item.tsx | 7 +- .../gpu-service/instances/forms/index.tsx | 77 ++++--- .../instances/forms/instance-type.tsx | 40 ++-- .../instances/hooks/use-create-instance.ts | 6 +- .../instances/utils/instance-description.ts | 18 ++ 6 files changed, 215 insertions(+), 150 deletions(-) create mode 100644 src/pages/gpu-service/instances/utils/instance-description.ts diff --git a/src/pages/gpu-service/instances/components/add-modal.tsx b/src/pages/gpu-service/instances/components/add-modal.tsx index 76a07c16..86942e95 100644 --- a/src/pages/gpu-service/instances/components/add-modal.tsx +++ b/src/pages/gpu-service/instances/components/add-modal.tsx @@ -13,15 +13,16 @@ import { } from '@gpustack/core-ui'; import { useIntl, useModel } from '@umijs/max'; import { Input, Typography } from 'antd'; -import _ from 'lodash'; import { useEffect, useMemo, useRef, useState } from 'react'; import { ListItem as TemplateItem } from '../../templates/config/types'; import useQueryTemplates from '../../templates/services/use-query-templates'; +import { InstanceStatusValueMap } from '../config'; import { FormData, InstanceTypeItem, ListItem } from '../config/types'; import GPUServiceInstanceForm from '../forms'; import TemplateSelector, { TemplateGroup } from '../forms/template-selector'; import useQueryInstanceTypes from '../services/use-query-instance-types'; import styles from '../styles/instances.module.less'; +import { saveInstanceDataInDescription } from '../utils/instance-description'; import InstanceTypeList from './instance-type-list'; type AddModalProps = { @@ -103,6 +104,12 @@ const AddModal: React.FC = ({ manufacturer: undefined }); const [templateId, setTemplateId] = useState(); + // Re-selected instance type on a stopped-instance edit. Kept separate from + // `instanceTypeSelection` (the create card selection) so the two flows don't + // couple; starts empty each open (no default highlight). + const [editSelectedType, setEditSelectedType] = useState( + undefined + ); const [instanceKeyword, setInstanceKeyword] = useState(''); const [templateKeyword, setTemplateKeyword] = useState(''); const { loading, guard, run, release } = useSubmitLock(); @@ -169,6 +176,13 @@ const AddModal: React.FC = ({ const isRecreate = realAction === PageAction.CREATE; const showResourceSelectors = action === PageAction.CREATE || isRecreate; const shouldAutoSelectResource = action === PageAction.CREATE && !isRecreate; + // 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. + const isStoppedEdit = + action === PageAction.EDIT && + data?.status?.phase === InstanceStatusValueMap.Stopped; + const showInstanceTypeColumn = showResourceSelectors || isStoppedEdit; const findTemplateByManufacturer = ( manufacturer: string | undefined, @@ -179,20 +193,6 @@ const AddModal: React.FC = ({ : undefined; }; - const saveInstanceDataInDescription = (instanceType: InstanceTypeItem) => { - return JSON.stringify({ - name: instanceType.name, - spec: { - ..._.omit(instanceType.spec, ['cache', 'cpu']), - cpu: _.pick(instanceType.spec?.cpu, [ - 'manufacturer', - 'product', - 'family' - ]) - } - }); - }; - // GPU types carry their accelerator vendor; non-acceleratable (CPU) types // all map to the single 'cpu' bucket used to match templates. const manufacturerOf = (instanceType: InstanceTypeItem) => @@ -359,6 +359,7 @@ const AddModal: React.FC = ({ manufacturer: undefined }); setTemplateId(undefined); + setEditSelectedType(undefined); setInstanceKeyword(''); setTemplateKeyword(''); setScopeOrgId(undefined); @@ -367,6 +368,10 @@ const AddModal: React.FC = ({ if (action === PageAction.CREATE) { loadCreateResources(); + } else if (action === PageAction.EDIT) { + // Edit has no card columns, but the change-type overlay still needs the + // full instance-type list to re-type a stopped instance. + fetchData({ page: -1 }); } }, [open, shouldAutoSelectResource, action]); @@ -506,6 +511,17 @@ const AddModal: React.FC = ({ applySelection(item, template); }; + // Stopped-edit re-type. Decoupled from applySelection (the create flow): it + // only snapshots the type into `description` and applies it to the form — no + // template selection or filtering. + const handleEditInstanceTypeChange = (item: InstanceTypeItem) => { + setEditSelectedType(item.name); + form.current?.setFieldsValue({ + description: saveInstanceDataInDescription(item) + }); + form.current?.applyInstanceType?.(item); + }; + const handleTemplateChange = (id: number, item: TemplateItem) => { setTemplateId(id); const formValues = form.current?.getFieldsValue(); @@ -543,87 +559,102 @@ const AddModal: React.FC = ({ footer={false} >
+ {showInstanceTypeColumn && ( +
+ +
+
+ + {intl.formatMessage({ + id: 'gpuservice.instance.types' + })} + + } + placeholder={intl.formatMessage({ + id: 'gpuservice.instance.search.type.placeholder' + })} + value={instanceKeyword} + onChange={(e) => setInstanceKeyword(e.target.value)} + /> +
+ +
+
+ +
+ )} {showResourceSelectors && ( - <> -
- -
-
- - {intl.formatMessage({ - id: 'gpuservice.instance.types' - })} - - } - placeholder={intl.formatMessage({ - id: 'gpuservice.instance.search.type.placeholder' - })} - value={instanceKeyword} - onChange={(e) => setInstanceKeyword(e.target.value)} - /> -
- + +
+
+ + {intl.formatMessage({ + id: 'gpuservice.instance.templates' + })} + + } + placeholder={intl.formatMessage({ + id: 'gpuservice.instance.search.template.placeholder' + })} + value={templateKeyword} + onChange={(e) => setTemplateKeyword(e.target.value)} />
- - -
-
- -
-
- - {intl.formatMessage({ - id: 'gpuservice.instance.templates' - })} - - } - placeholder={intl.formatMessage({ - id: 'gpuservice.instance.search.template.placeholder' - })} - value={templateKeyword} - onChange={(e) => setTemplateKeyword(e.target.value)} - /> -
- -
-
- -
- + +
+
+ +
)}
` interface InstanceTypeItemProps { item: InstanceTypeItemModel; + action?: React.ReactNode; } interface MetadataSectionProps { @@ -212,7 +213,10 @@ export const InstanceMetadataSection: React.FC = ({ ); }; -const InstanceTypeItem: React.FC = ({ item }) => { +const InstanceTypeItem: React.FC = ({ + item, + action +}) => { const specData = item.spec || {}; const { acceleratable, manufacturer, displayName, cpuManufacturer } = @@ -264,6 +268,7 @@ const InstanceTypeItem: React.FC = ({ item }) => { name="InstanceTypeBillingBadge" context={{ instanceType: item }} /> + {action &&
{action}
} = forwardRef( form.setFieldsValue({ spec: { resources: { - // Display the precise (rounded) fractional values — the inputs are - // disabled, so decimals are fine and match the submitted - // millicore / MiB allocation better than a floored integer. + // Floor the scaled unit resources to whole units; CPU never drops + // below 1 core so a small slice still gets a usable vCPU. cpu: cpuCores != null && percentage > 0 - ? _.round((cpuCores * percentage) / 100, 2) + ? Math.max(1, _.floor((cpuCores * percentage) / 100)) : null, ram: ramValue != null && percentage > 0 - ? _.round((ramValue * percentage) / 100, 2) + ? _.floor((ramValue * percentage) / 100) : null } } @@ -468,6 +467,38 @@ const GPUServiceInstanceForm: React.FC = forwardRef( } }; + // Apply a chosen instance type to the form: default to sliced mode for a + // sliceable type with no whole-card capacity, otherwise whole-card with a + // count of 1. Shared by the create card selection (imperative handle) and + // the edit change-type overlay. + const applyInstanceType = (instanceType?: InstanceTypeItem) => { + if (!instanceType) { + setSliceMode('whole'); + resolveAndApply(undefined, 0); + return; + } + + // A sliceable type with no whole-card capacity (Max < 1) defaults to + // sliced mode — whole mode would have nothing selectable. + const wholeMax = instanceType.spec?.maxComputeUnitCount ?? 0; + const slicedMax = + _.toNumber(instanceType.status?.onceMaxRequest?.acceleratorSliced) || 0; + const defaultSliced = + !!instanceType.spec?.sliceable && wholeMax < 1 && slicedMax > 0; + + if (defaultSliced) { + setSliceMode('sliced'); + resolveAndApply(instanceType, 1, true); + applySlicedDefaults(instanceType); + return; + } + + // Otherwise default to whole-card mode (a new type may not be + // sliceable); set count to 1 for all instance types: GPU or non-GPU. + setSliceMode('whole'); + resolveAndApply(instanceType, 1); + }; + const onTargetChange = (key: string) => { scrollTabsRef.current?.handleTargetChange(key); }; @@ -614,34 +645,7 @@ const GPUServiceInstanceForm: React.FC = forwardRef( form.setFieldsValue(values as any); }, getFieldsValue: () => form.getFieldsValue(), - applyInstanceType: (instanceType?: InstanceTypeItem) => { - if (!instanceType) { - setSliceMode('whole'); - resolveAndApply(undefined, 0); - return; - } - - // A sliceable type with no whole-card capacity (Max < 1) defaults to - // sliced mode — whole mode would have nothing selectable. - const wholeMax = instanceType.spec?.maxComputeUnitCount ?? 0; - const slicedMax = - _.toNumber(instanceType.status?.onceMaxRequest?.acceleratorSliced) || - 0; - const defaultSliced = - !!instanceType.spec?.sliceable && wholeMax < 1 && slicedMax > 0; - - if (defaultSliced) { - setSliceMode('sliced'); - resolveAndApply(instanceType, 1, true); - applySlicedDefaults(instanceType); - return; - } - - // Otherwise default to whole-card mode (a new type may not be - // sliceable); set count to 1 for all instance types: GPU or non-GPU. - setSliceMode('whole'); - resolveAndApply(instanceType, 1); - } + applyInstanceType })); const handleAddSSHKey = () => { @@ -766,7 +770,7 @@ const GPUServiceInstanceForm: React.FC = forwardRef( children: ( ) @@ -778,10 +782,7 @@ const GPUServiceInstanceForm: React.FC = forwardRef( }), forceRender: true, children: ( - + ) } ]} diff --git a/src/pages/gpu-service/instances/forms/instance-type.tsx b/src/pages/gpu-service/instances/forms/instance-type.tsx index 159097ea..f715dc00 100644 --- a/src/pages/gpu-service/instances/forms/instance-type.tsx +++ b/src/pages/gpu-service/instances/forms/instance-type.tsx @@ -95,8 +95,14 @@ const InstanceTypeFormItem: React.FC = ({ const form = Form.useFormInstance(); const { isGPUType } = useContext(FormContext); + // In edit mode the type card is read-only until a type is re-picked from the + // instance-type column (stopped instances only); once selected the section + // behaves like create (editable count / slice controls, live capacity + // labels). + const readonlyType = action === PageAction.EDIT && !selectedInstanceType; + const maxComputeUnitCount = useMemo(() => { - if (action === PageAction.EDIT) { + if (readonlyType) { const description = parseJsonSafe( currentData?.description || '{}', {} as any @@ -104,25 +110,24 @@ const InstanceTypeFormItem: React.FC = ({ return description.spec?.maxComputeUnitCount || 0; } return selectedInstanceType?.spec?.maxComputeUnitCount || 0; - }, [action, currentData, selectedInstanceType]); + }, [readonlyType, currentData, selectedInstanceType]); const isGPU = useMemo(() => { - if (action === PageAction.EDIT) { + if (readonlyType) { return _.toNumber(currentData?.spec?.resources?.accelerator) > 0; } return selectedInstanceType?.spec?.acceleratable; - }, [selectedInstanceType, action]); + }, [selectedInstanceType, readonlyType, currentData]); const handleOnGPUCountChange = (value: number) => { onGPUCountChange?.(value); }; // Sliced mode is only offered for sliceable accelerator types, and only when - // the section is editable (create / recreate; edit renders a readonly card). + // the section is editable (create / recreate, or edit after re-picking a + // type; a not-yet-re-typed edit renders a readonly card). const showModeSwitch = - action !== PageAction.EDIT && - isGPUType && - !!selectedInstanceType?.spec?.sliceable; + !readonlyType && isGPUType && !!selectedInstanceType?.spec?.sliceable; const handleModeChange = (value: string) => { onSliceModeChange?.(value as 'whole' | 'sliced'); @@ -191,7 +196,7 @@ const InstanceTypeFormItem: React.FC = ({ }; const renderMemoryLabel = (): React.ReactNode => { - if (isGPUType || action === PageAction.EDIT || !onceMaxRequest?.memory) { + if (isGPUType || readonlyType || !onceMaxRequest?.memory) { return intl.formatMessage({ id: 'gpuservice.template.memory' }); } @@ -257,13 +262,14 @@ const InstanceTypeFormItem: React.FC = ({ } ]} > - {action === PageAction.CREATE && ( + {readonlyType ? ( + renderInstanceType() + ) : ( )} - {action === PageAction.EDIT && renderInstanceType()} {!noAvailableTypes && ( @@ -275,7 +281,7 @@ const InstanceTypeFormItem: React.FC = ({ : ['spec', 'resources', 'cpu'] } preserve - hidden={action === PageAction.EDIT || isSliced} + hidden={readonlyType || isSliced} normalize={(value) => (value != null ? _.toString(value) : undefined)} getValueProps={(value) => ({ value: value != null ? _.toNumber(value) : undefined @@ -317,7 +323,7 @@ const InstanceTypeFormItem: React.FC = ({ step={1} required labelExtra={sliceMode === 'whole' ? modeSegmented : undefined} - disabled={disabled || action === PageAction.EDIT} + disabled={disabled || readonlyType} label={`${intl.formatMessage({ id: 'common.max.count' }, { label: numberSelectionLabel.label })} (${intl.formatMessage( { id: 'common.max' @@ -391,10 +397,10 @@ const InstanceTypeFormItem: React.FC = ({ )} - {/* Edit renders a readonly card (no sliced UI), so register the slice - percentages as hidden fields — otherwise their persisted values are - dropped from the submit payload. */} - {action === PageAction.EDIT && ( + {/* A not-yet-re-typed edit renders a readonly card (no sliced UI), so + register the slice percentages as hidden fields — otherwise their + persisted values are dropped from the submit payload. */} + {readonlyType && ( <> name={['spec', 'resources', 'acceleratorSlicedMemoryPercentage']} diff --git a/src/pages/gpu-service/instances/hooks/use-create-instance.ts b/src/pages/gpu-service/instances/hooks/use-create-instance.ts index 525f07cb..dcac55fe 100644 --- a/src/pages/gpu-service/instances/hooks/use-create-instance.ts +++ b/src/pages/gpu-service/instances/hooks/use-create-instance.ts @@ -3,6 +3,7 @@ import type { PageActionType } from '@/config/types'; import useBodyScroll from '@/hooks/use-body-scroll'; import { useIntl } from '@umijs/max'; import { useState } from 'react'; +import { InstanceStatusValueMap } from '../config'; import type { ListItem } from '../config/types'; const useCreateInstance = () => { @@ -52,11 +53,14 @@ const useCreateInstance = () => { }; const openEditInstanceModal = (row: ListItem) => { + // A stopped instance can be re-typed, so it needs the two-column layout + // (instance-type list + form); other statuses edit in a single column. + const isStopped = row.status?.phase === InstanceStatusValueMap.Stopped; openModal( PageAction.EDIT, intl.formatMessage({ id: 'gpuservice.instance.edit' }), row, - 600 + isStopped ? 'min(1040px, calc(100vw - 220px))' : 600 ); }; diff --git a/src/pages/gpu-service/instances/utils/instance-description.ts b/src/pages/gpu-service/instances/utils/instance-description.ts new file mode 100644 index 00000000..a66f4421 --- /dev/null +++ b/src/pages/gpu-service/instances/utils/instance-description.ts @@ -0,0 +1,18 @@ +import _ from 'lodash'; +import { InstanceTypeItem } from '../config/types'; + +// 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). +export const saveInstanceDataInDescription = ( + instanceType: InstanceTypeItem +): string => { + return JSON.stringify({ + name: instanceType.name, + spec: { + ..._.omit(instanceType.spec, ['cache', 'cpu']), + cpu: _.pick(instanceType.spec?.cpu, ['manufacturer', 'product', 'family']) + } + }); +};