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'; } 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
+39 -38
View File
@@ -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'])
}
});
};