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
This commit is contained in:
jialin
2026-07-22 15:52:17 +08:00
parent e9d585cc90
commit 96d194a4f4
6 changed files with 215 additions and 150 deletions
@@ -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<AddModalProps> = ({
manufacturer: undefined
});
const [templateId, setTemplateId] = useState<number | undefined>();
// 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<string | undefined>(
undefined
);
const [instanceKeyword, setInstanceKeyword] = useState('');
const [templateKeyword, setTemplateKeyword] = useState('');
const { loading, guard, run, release } = useSubmitLock();
@@ -169,6 +176,13 @@ const AddModal: React.FC<AddModalProps> = ({
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<AddModalProps> = ({
: 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<AddModalProps> = ({
manufacturer: undefined
});
setTemplateId(undefined);
setEditSelectedType(undefined);
setInstanceKeyword('');
setTemplateKeyword('');
setScopeOrgId(undefined);
@@ -367,6 +368,10 @@ const AddModal: React.FC<AddModalProps> = ({
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<AddModalProps> = ({
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<AddModalProps> = ({
footer={false}
>
<div className={styles.container}>
{showInstanceTypeColumn && (
<div
className={styles.colWrapper}
// The 33% cap suits the 3-column create layout; in the 2-column
// stopped-edit layout, split the space evenly with the form column.
style={isStoppedEdit ? { flex: 1, maxWidth: 'none' } : undefined}
>
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
<div className={styles.panelBody}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: 'var(--ant-color-bg-elevated)'
}}
>
<ColTitle style={{ paddingBottom: 0 }}>
{intl.formatMessage({
id: 'gpuservice.instance.types'
})}
</ColTitle>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
placeholder={intl.formatMessage({
id: 'gpuservice.instance.search.type.placeholder'
})}
value={instanceKeyword}
onChange={(e) => setInstanceKeyword(e.target.value)}
/>
</div>
<InstanceTypeList
// Edit (stopped) re-selection is decoupled from create's
// card selection: separate highlight state + apply handler.
value={
isStoppedEdit
? editSelectedType
: instanceTypeSelection.instanceType
}
dataList={filteredInstanceTypes}
loading={instanceTypesLoading}
onChange={
isStoppedEdit
? handleEditInstanceTypeChange
: handleInstanceTypeChange
}
/>
</div>
</ColumnWrapper>
<Separator></Separator>
</div>
)}
{showResourceSelectors && (
<>
<div className={styles.colWrapper}>
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
<div className={styles.panelBody}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: 'var(--ant-color-bg-elevated)'
}}
>
<ColTitle style={{ paddingBottom: 0 }}>
{intl.formatMessage({
id: 'gpuservice.instance.types'
})}
</ColTitle>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
placeholder={intl.formatMessage({
id: 'gpuservice.instance.search.type.placeholder'
})}
value={instanceKeyword}
onChange={(e) => setInstanceKeyword(e.target.value)}
/>
</div>
<InstanceTypeList
value={instanceTypeSelection.instanceType}
dataList={filteredInstanceTypes}
loading={instanceTypesLoading}
onChange={handleInstanceTypeChange}
<div className={styles.colWrapper}>
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
<div className={styles.panelBody}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: 'var(--ant-color-bg-elevated)'
}}
>
<ColTitle style={{ paddingBottom: 0 }}>
{intl.formatMessage({
id: 'gpuservice.instance.templates'
})}
</ColTitle>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
placeholder={intl.formatMessage({
id: 'gpuservice.instance.search.template.placeholder'
})}
value={templateKeyword}
onChange={(e) => setTemplateKeyword(e.target.value)}
/>
</div>
</ColumnWrapper>
<Separator></Separator>
</div>
<div className={styles.colWrapper}>
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
<div className={styles.panelBody}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: 'var(--ant-color-bg-elevated)'
}}
>
<ColTitle style={{ paddingBottom: 0 }}>
{intl.formatMessage({
id: 'gpuservice.instance.templates'
})}
</ColTitle>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
placeholder={intl.formatMessage({
id: 'gpuservice.instance.search.template.placeholder'
})}
value={templateKeyword}
onChange={(e) => setTemplateKeyword(e.target.value)}
/>
</div>
<TemplateSelector
value={templateId}
loading={templateLoading || !initialized}
groups={templateGroups}
onChange={handleTemplateChange}
/>
</div>
</ColumnWrapper>
<Separator></Separator>
</div>
</>
<TemplateSelector
value={templateId}
loading={templateLoading || !initialized}
groups={templateGroups}
onChange={handleTemplateChange}
/>
</div>
</ColumnWrapper>
<Separator></Separator>
</div>
)}
<div className={styles.formWrapper}>
<ColumnWrapper
@@ -54,6 +54,7 @@ const Meta = styled.div<{ $columns?: number }>`
interface InstanceTypeItemProps {
item: InstanceTypeItemModel;
action?: React.ReactNode;
}
interface MetadataSectionProps {
@@ -212,7 +213,10 @@ export const InstanceMetadataSection: React.FC<MetadataSectionProps> = ({
);
};
const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({
item,
action
}) => {
const specData = item.spec || {};
const { acceleratable, manufacturer, displayName, cpuManufacturer } =
@@ -264,6 +268,7 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
name="InstanceTypeBillingBadge"
context={{ instanceType: item }}
/>
{action && <div style={{ marginLeft: 8 }}>{action}</div>}
</Flex>
</Title>
<InstanceMetadataSection
+39 -38
View File
@@ -335,16 +335,15 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = 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<InstanceFormProps> = 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<InstanceFormProps> = 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<InstanceFormProps> = forwardRef(
children: (
<TemplateBasicForm
page="instance"
disabled={disabled || formAction === PageAction.EDIT}
disabled={disabled}
onceMaxRequest={onceMaxRequest}
/>
)
@@ -778,10 +782,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}),
forceRender: true,
children: (
<StorageVolume
disabled={disabled || formAction === PageAction.EDIT}
action={formAction}
/>
<StorageVolume disabled={disabled} action={formAction} />
)
}
]}
@@ -95,8 +95,14 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
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<InstanceTypeFormItemProps> = ({
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<InstanceTypeFormItemProps> = ({
};
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<InstanceTypeFormItemProps> = ({
}
]}
>
{action === PageAction.CREATE && (
{readonlyType ? (
renderInstanceType()
) : (
<InstanceTypePicker
selectedInstanceType={selectedInstanceType}
noAvailable={noAvailableTypes}
/>
)}
{action === PageAction.EDIT && renderInstanceType()}
</Form.Item>
</FieldBlock>
{!noAvailableTypes && (
@@ -275,7 +281,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
: ['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<InstanceTypeFormItemProps> = ({
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<InstanceTypeFormItemProps> = ({
</Form.Item>
</FieldBlock>
)}
{/* 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 && (
<>
<Form.Item<FormData>
name={['spec', 'resources', 'acceleratorSlicedMemoryPercentage']}
@@ -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
);
};
@@ -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'])
}
});
};