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:
@@ -13,15 +13,16 @@ import {
|
|||||||
} from '@gpustack/core-ui';
|
} from '@gpustack/core-ui';
|
||||||
import { useIntl, useModel } from '@umijs/max';
|
import { useIntl, useModel } from '@umijs/max';
|
||||||
import { Input, Typography } from 'antd';
|
import { Input, Typography } from 'antd';
|
||||||
import _ from 'lodash';
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ListItem as TemplateItem } from '../../templates/config/types';
|
import { ListItem as TemplateItem } from '../../templates/config/types';
|
||||||
import useQueryTemplates from '../../templates/services/use-query-templates';
|
import useQueryTemplates from '../../templates/services/use-query-templates';
|
||||||
|
import { InstanceStatusValueMap } from '../config';
|
||||||
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
|
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
|
||||||
import GPUServiceInstanceForm from '../forms';
|
import GPUServiceInstanceForm from '../forms';
|
||||||
import TemplateSelector, { TemplateGroup } from '../forms/template-selector';
|
import TemplateSelector, { TemplateGroup } from '../forms/template-selector';
|
||||||
import useQueryInstanceTypes from '../services/use-query-instance-types';
|
import useQueryInstanceTypes from '../services/use-query-instance-types';
|
||||||
import styles from '../styles/instances.module.less';
|
import styles from '../styles/instances.module.less';
|
||||||
|
import { saveInstanceDataInDescription } from '../utils/instance-description';
|
||||||
import InstanceTypeList from './instance-type-list';
|
import InstanceTypeList from './instance-type-list';
|
||||||
|
|
||||||
type AddModalProps = {
|
type AddModalProps = {
|
||||||
@@ -103,6 +104,12 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
manufacturer: undefined
|
manufacturer: undefined
|
||||||
});
|
});
|
||||||
const [templateId, setTemplateId] = useState<number | 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 [instanceKeyword, setInstanceKeyword] = useState('');
|
||||||
const [templateKeyword, setTemplateKeyword] = useState('');
|
const [templateKeyword, setTemplateKeyword] = useState('');
|
||||||
const { loading, guard, run, release } = useSubmitLock();
|
const { loading, guard, run, release } = useSubmitLock();
|
||||||
@@ -169,6 +176,13 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
const isRecreate = realAction === PageAction.CREATE;
|
const isRecreate = realAction === PageAction.CREATE;
|
||||||
const showResourceSelectors = action === PageAction.CREATE || isRecreate;
|
const showResourceSelectors = action === PageAction.CREATE || isRecreate;
|
||||||
const shouldAutoSelectResource = 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 = (
|
const findTemplateByManufacturer = (
|
||||||
manufacturer: string | undefined,
|
manufacturer: string | undefined,
|
||||||
@@ -179,20 +193,6 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
: undefined;
|
: 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
|
// GPU types carry their accelerator vendor; non-acceleratable (CPU) types
|
||||||
// all map to the single 'cpu' bucket used to match templates.
|
// all map to the single 'cpu' bucket used to match templates.
|
||||||
const manufacturerOf = (instanceType: InstanceTypeItem) =>
|
const manufacturerOf = (instanceType: InstanceTypeItem) =>
|
||||||
@@ -359,6 +359,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
manufacturer: undefined
|
manufacturer: undefined
|
||||||
});
|
});
|
||||||
setTemplateId(undefined);
|
setTemplateId(undefined);
|
||||||
|
setEditSelectedType(undefined);
|
||||||
setInstanceKeyword('');
|
setInstanceKeyword('');
|
||||||
setTemplateKeyword('');
|
setTemplateKeyword('');
|
||||||
setScopeOrgId(undefined);
|
setScopeOrgId(undefined);
|
||||||
@@ -367,6 +368,10 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
|
|
||||||
if (action === PageAction.CREATE) {
|
if (action === PageAction.CREATE) {
|
||||||
loadCreateResources();
|
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]);
|
}, [open, shouldAutoSelectResource, action]);
|
||||||
|
|
||||||
@@ -506,6 +511,17 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
applySelection(item, template);
|
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) => {
|
const handleTemplateChange = (id: number, item: TemplateItem) => {
|
||||||
setTemplateId(id);
|
setTemplateId(id);
|
||||||
const formValues = form.current?.getFieldsValue();
|
const formValues = form.current?.getFieldsValue();
|
||||||
@@ -543,87 +559,102 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
footer={false}
|
footer={false}
|
||||||
>
|
>
|
||||||
<div className={styles.container}>
|
<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 && (
|
{showResourceSelectors && (
|
||||||
<>
|
<div className={styles.colWrapper}>
|
||||||
<div className={styles.colWrapper}>
|
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
|
||||||
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
|
<div className={styles.panelBody}>
|
||||||
<div className={styles.panelBody}>
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
display: 'flex',
|
||||||
display: 'flex',
|
flexDirection: 'column',
|
||||||
flexDirection: 'column',
|
gap: 16,
|
||||||
gap: 16,
|
position: 'sticky',
|
||||||
position: 'sticky',
|
top: 0,
|
||||||
top: 0,
|
zIndex: 10,
|
||||||
zIndex: 10,
|
backgroundColor: 'var(--ant-color-bg-elevated)'
|
||||||
backgroundColor: 'var(--ant-color-bg-elevated)'
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<ColTitle style={{ paddingBottom: 0 }}>
|
||||||
<ColTitle style={{ paddingBottom: 0 }}>
|
{intl.formatMessage({
|
||||||
{intl.formatMessage({
|
id: 'gpuservice.instance.templates'
|
||||||
id: 'gpuservice.instance.types'
|
})}
|
||||||
})}
|
</ColTitle>
|
||||||
</ColTitle>
|
<Input
|
||||||
<Input
|
allowClear
|
||||||
allowClear
|
prefix={<SearchOutlined className="text-tertiary" />}
|
||||||
prefix={<SearchOutlined className="text-tertiary" />}
|
placeholder={intl.formatMessage({
|
||||||
placeholder={intl.formatMessage({
|
id: 'gpuservice.instance.search.template.placeholder'
|
||||||
id: 'gpuservice.instance.search.type.placeholder'
|
})}
|
||||||
})}
|
value={templateKeyword}
|
||||||
value={instanceKeyword}
|
onChange={(e) => setTemplateKeyword(e.target.value)}
|
||||||
onChange={(e) => setInstanceKeyword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<InstanceTypeList
|
|
||||||
value={instanceTypeSelection.instanceType}
|
|
||||||
dataList={filteredInstanceTypes}
|
|
||||||
loading={instanceTypesLoading}
|
|
||||||
onChange={handleInstanceTypeChange}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ColumnWrapper>
|
<TemplateSelector
|
||||||
<Separator></Separator>
|
value={templateId}
|
||||||
</div>
|
loading={templateLoading || !initialized}
|
||||||
<div className={styles.colWrapper}>
|
groups={templateGroups}
|
||||||
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
|
onChange={handleTemplateChange}
|
||||||
<div className={styles.panelBody}>
|
/>
|
||||||
<div
|
</div>
|
||||||
style={{
|
</ColumnWrapper>
|
||||||
display: 'flex',
|
<Separator></Separator>
|
||||||
flexDirection: 'column',
|
</div>
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<div className={styles.formWrapper}>
|
<div className={styles.formWrapper}>
|
||||||
<ColumnWrapper
|
<ColumnWrapper
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const Meta = styled.div<{ $columns?: number }>`
|
|||||||
|
|
||||||
interface InstanceTypeItemProps {
|
interface InstanceTypeItemProps {
|
||||||
item: InstanceTypeItemModel;
|
item: InstanceTypeItemModel;
|
||||||
|
action?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MetadataSectionProps {
|
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 specData = item.spec || {};
|
||||||
|
|
||||||
const { acceleratable, manufacturer, displayName, cpuManufacturer } =
|
const { acceleratable, manufacturer, displayName, cpuManufacturer } =
|
||||||
@@ -264,6 +268,7 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
|
|||||||
name="InstanceTypeBillingBadge"
|
name="InstanceTypeBillingBadge"
|
||||||
context={{ instanceType: item }}
|
context={{ instanceType: item }}
|
||||||
/>
|
/>
|
||||||
|
{action && <div style={{ marginLeft: 8 }}>{action}</div>}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Title>
|
</Title>
|
||||||
<InstanceMetadataSection
|
<InstanceMetadataSection
|
||||||
|
|||||||
@@ -335,16 +335,15 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
spec: {
|
spec: {
|
||||||
resources: {
|
resources: {
|
||||||
// Display the precise (rounded) fractional values — the inputs are
|
// Floor the scaled unit resources to whole units; CPU never drops
|
||||||
// disabled, so decimals are fine and match the submitted
|
// below 1 core so a small slice still gets a usable vCPU.
|
||||||
// millicore / MiB allocation better than a floored integer.
|
|
||||||
cpu:
|
cpu:
|
||||||
cpuCores != null && percentage > 0
|
cpuCores != null && percentage > 0
|
||||||
? _.round((cpuCores * percentage) / 100, 2)
|
? Math.max(1, _.floor((cpuCores * percentage) / 100))
|
||||||
: null,
|
: null,
|
||||||
ram:
|
ram:
|
||||||
ramValue != null && percentage > 0
|
ramValue != null && percentage > 0
|
||||||
? _.round((ramValue * percentage) / 100, 2)
|
? _.floor((ramValue * percentage) / 100)
|
||||||
: null
|
: 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) => {
|
const onTargetChange = (key: string) => {
|
||||||
scrollTabsRef.current?.handleTargetChange(key);
|
scrollTabsRef.current?.handleTargetChange(key);
|
||||||
};
|
};
|
||||||
@@ -614,34 +645,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
form.setFieldsValue(values as any);
|
form.setFieldsValue(values as any);
|
||||||
},
|
},
|
||||||
getFieldsValue: () => form.getFieldsValue(),
|
getFieldsValue: () => form.getFieldsValue(),
|
||||||
applyInstanceType: (instanceType?: InstanceTypeItem) => {
|
applyInstanceType
|
||||||
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 handleAddSSHKey = () => {
|
const handleAddSSHKey = () => {
|
||||||
@@ -766,7 +770,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
children: (
|
children: (
|
||||||
<TemplateBasicForm
|
<TemplateBasicForm
|
||||||
page="instance"
|
page="instance"
|
||||||
disabled={disabled || formAction === PageAction.EDIT}
|
disabled={disabled}
|
||||||
onceMaxRequest={onceMaxRequest}
|
onceMaxRequest={onceMaxRequest}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -778,10 +782,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
}),
|
}),
|
||||||
forceRender: true,
|
forceRender: true,
|
||||||
children: (
|
children: (
|
||||||
<StorageVolume
|
<StorageVolume disabled={disabled} action={formAction} />
|
||||||
disabled={disabled || formAction === PageAction.EDIT}
|
|
||||||
action={formAction}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -95,8 +95,14 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
const form = Form.useFormInstance();
|
const form = Form.useFormInstance();
|
||||||
const { isGPUType } = useContext(FormContext);
|
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(() => {
|
const maxComputeUnitCount = useMemo(() => {
|
||||||
if (action === PageAction.EDIT) {
|
if (readonlyType) {
|
||||||
const description = parseJsonSafe(
|
const description = parseJsonSafe(
|
||||||
currentData?.description || '{}',
|
currentData?.description || '{}',
|
||||||
{} as any
|
{} as any
|
||||||
@@ -104,25 +110,24 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
return description.spec?.maxComputeUnitCount || 0;
|
return description.spec?.maxComputeUnitCount || 0;
|
||||||
}
|
}
|
||||||
return selectedInstanceType?.spec?.maxComputeUnitCount || 0;
|
return selectedInstanceType?.spec?.maxComputeUnitCount || 0;
|
||||||
}, [action, currentData, selectedInstanceType]);
|
}, [readonlyType, currentData, selectedInstanceType]);
|
||||||
|
|
||||||
const isGPU = useMemo(() => {
|
const isGPU = useMemo(() => {
|
||||||
if (action === PageAction.EDIT) {
|
if (readonlyType) {
|
||||||
return _.toNumber(currentData?.spec?.resources?.accelerator) > 0;
|
return _.toNumber(currentData?.spec?.resources?.accelerator) > 0;
|
||||||
}
|
}
|
||||||
return selectedInstanceType?.spec?.acceleratable;
|
return selectedInstanceType?.spec?.acceleratable;
|
||||||
}, [selectedInstanceType, action]);
|
}, [selectedInstanceType, readonlyType, currentData]);
|
||||||
|
|
||||||
const handleOnGPUCountChange = (value: number) => {
|
const handleOnGPUCountChange = (value: number) => {
|
||||||
onGPUCountChange?.(value);
|
onGPUCountChange?.(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sliced mode is only offered for sliceable accelerator types, and only when
|
// 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 =
|
const showModeSwitch =
|
||||||
action !== PageAction.EDIT &&
|
!readonlyType && isGPUType && !!selectedInstanceType?.spec?.sliceable;
|
||||||
isGPUType &&
|
|
||||||
!!selectedInstanceType?.spec?.sliceable;
|
|
||||||
|
|
||||||
const handleModeChange = (value: string) => {
|
const handleModeChange = (value: string) => {
|
||||||
onSliceModeChange?.(value as 'whole' | 'sliced');
|
onSliceModeChange?.(value as 'whole' | 'sliced');
|
||||||
@@ -191,7 +196,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderMemoryLabel = (): React.ReactNode => {
|
const renderMemoryLabel = (): React.ReactNode => {
|
||||||
if (isGPUType || action === PageAction.EDIT || !onceMaxRequest?.memory) {
|
if (isGPUType || readonlyType || !onceMaxRequest?.memory) {
|
||||||
return intl.formatMessage({ id: 'gpuservice.template.memory' });
|
return intl.formatMessage({ id: 'gpuservice.template.memory' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,13 +262,14 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{action === PageAction.CREATE && (
|
{readonlyType ? (
|
||||||
|
renderInstanceType()
|
||||||
|
) : (
|
||||||
<InstanceTypePicker
|
<InstanceTypePicker
|
||||||
selectedInstanceType={selectedInstanceType}
|
selectedInstanceType={selectedInstanceType}
|
||||||
noAvailable={noAvailableTypes}
|
noAvailable={noAvailableTypes}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{action === PageAction.EDIT && renderInstanceType()}
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
{!noAvailableTypes && (
|
{!noAvailableTypes && (
|
||||||
@@ -275,7 +281,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
: ['spec', 'resources', 'cpu']
|
: ['spec', 'resources', 'cpu']
|
||||||
}
|
}
|
||||||
preserve
|
preserve
|
||||||
hidden={action === PageAction.EDIT || isSliced}
|
hidden={readonlyType || isSliced}
|
||||||
normalize={(value) => (value != null ? _.toString(value) : undefined)}
|
normalize={(value) => (value != null ? _.toString(value) : undefined)}
|
||||||
getValueProps={(value) => ({
|
getValueProps={(value) => ({
|
||||||
value: value != null ? _.toNumber(value) : undefined
|
value: value != null ? _.toNumber(value) : undefined
|
||||||
@@ -317,7 +323,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
step={1}
|
step={1}
|
||||||
required
|
required
|
||||||
labelExtra={sliceMode === 'whole' ? modeSegmented : undefined}
|
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(
|
label={`${intl.formatMessage({ id: 'common.max.count' }, { label: numberSelectionLabel.label })} (${intl.formatMessage(
|
||||||
{
|
{
|
||||||
id: 'common.max'
|
id: 'common.max'
|
||||||
@@ -391,10 +397,10 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
)}
|
)}
|
||||||
{/* Edit renders a readonly card (no sliced UI), so register the slice
|
{/* A not-yet-re-typed edit renders a readonly card (no sliced UI), so
|
||||||
percentages as hidden fields — otherwise their persisted values are
|
register the slice percentages as hidden fields — otherwise their
|
||||||
dropped from the submit payload. */}
|
persisted values are dropped from the submit payload. */}
|
||||||
{action === PageAction.EDIT && (
|
{readonlyType && (
|
||||||
<>
|
<>
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
name={['spec', 'resources', 'acceleratorSlicedMemoryPercentage']}
|
name={['spec', 'resources', 'acceleratorSlicedMemoryPercentage']}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { PageActionType } from '@/config/types';
|
|||||||
import useBodyScroll from '@/hooks/use-body-scroll';
|
import useBodyScroll from '@/hooks/use-body-scroll';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { InstanceStatusValueMap } from '../config';
|
||||||
import type { ListItem } from '../config/types';
|
import type { ListItem } from '../config/types';
|
||||||
|
|
||||||
const useCreateInstance = () => {
|
const useCreateInstance = () => {
|
||||||
@@ -52,11 +53,14 @@ const useCreateInstance = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openEditInstanceModal = (row: ListItem) => {
|
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(
|
openModal(
|
||||||
PageAction.EDIT,
|
PageAction.EDIT,
|
||||||
intl.formatMessage({ id: 'gpuservice.instance.edit' }),
|
intl.formatMessage({ id: 'gpuservice.instance.edit' }),
|
||||||
row,
|
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'])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user