724 lines
22 KiB
TypeScript
724 lines
22 KiB
TypeScript
import { PageAction } from '@/config';
|
|
import { PageActionType } from '@/config/types';
|
|
import {
|
|
ceilMilliToCore,
|
|
parseJsonSafe,
|
|
parseQuantityToGi
|
|
} from '@/pages/gpu-service/utils';
|
|
import { PlusOutlined } from '@ant-design/icons';
|
|
import {
|
|
CheckboxField,
|
|
Input as CInput,
|
|
CollapsePanel,
|
|
IconFont,
|
|
MultipleSelect,
|
|
ScrollSpyTabs,
|
|
useAppUtils,
|
|
useFinishFailed,
|
|
useScrollActiveChange,
|
|
useWrapperContext
|
|
} from '@gpustack/core-ui';
|
|
import { useIntl } from '@umijs/max';
|
|
import { Button, Flex, Form } from 'antd';
|
|
import _ from 'lodash';
|
|
import {
|
|
forwardRef,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState
|
|
} from 'react';
|
|
import { FormData as PublicKeyFormData } from '../../public-keys/config/types';
|
|
import useQuerySshkeys from '../../public-keys/services/use-query-sshkeys';
|
|
import { DefaultImagePullPolicy } from '../../templates/config';
|
|
import TemplateBasicForm, {
|
|
BasicResourceMax
|
|
} from '../../templates/forms/basic';
|
|
import { pickCandidateForAccelerator, StorageModeValueMap } from '../config';
|
|
import { FormContext } from '../config/form-context';
|
|
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
|
|
import instanceStyles from '../styles/instances.module.less';
|
|
import Basic from './basic';
|
|
import InstanceTypeFormItem from './instance-type';
|
|
import PublicKeyOverlay from './public-key-overlay';
|
|
import StorageVolume from './storage-volume';
|
|
|
|
const SSH_PORT = 22;
|
|
|
|
type InstanceFormValues = FormData & {
|
|
// ssh holder
|
|
enable_ssh?: boolean;
|
|
};
|
|
|
|
interface InstanceFormProps {
|
|
ref?: any;
|
|
open: boolean;
|
|
action: PageActionType;
|
|
realAction?: PageActionType | string;
|
|
currentData?: ListItem | null;
|
|
namespace?: string;
|
|
instanceTypeList?: InstanceTypeItem[];
|
|
// True when the (org-scoped) instance-type list is empty and not loading —
|
|
// surfaces a "no available instance type" message in the scheduling tab.
|
|
noAvailableInstanceTypes?: boolean;
|
|
disabled?: 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
|
|
// (the watched field stays undefined). Lets the parent re-scope the
|
|
// tenant-scoped instance-type / template offerings.
|
|
onScopeChange?: (orgId: number | null | undefined) => void;
|
|
onFinish: (values: FormData) => Promise<void>;
|
|
onFinishFailed?: (errorInfo: any) => void;
|
|
}
|
|
|
|
const TABKeysMap = {
|
|
BASIC: 'basic',
|
|
INSTANCE_TYPE: 'instanceType',
|
|
TEMPLATE: 'template',
|
|
STORAGE: 'storage'
|
|
};
|
|
|
|
const requiredFields = {
|
|
[TABKeysMap.BASIC]: {
|
|
sort: 1,
|
|
fields: ['name']
|
|
},
|
|
[TABKeysMap.INSTANCE_TYPE]: {
|
|
sort: 2,
|
|
fields: ['spec.type', 'spec.resources.accelerator']
|
|
},
|
|
[TABKeysMap.TEMPLATE]: {
|
|
sort: 3,
|
|
fields: ['spec.image', 'spec.ports', 'spec.env']
|
|
},
|
|
[TABKeysMap.STORAGE]: {
|
|
sort: 4,
|
|
fields: [
|
|
'spec.volume.persistent.name',
|
|
'spec.volume.ephemeral.capacity',
|
|
'spec.sshPublicKeys'
|
|
]
|
|
}
|
|
};
|
|
|
|
const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|
(props, ref) => {
|
|
const {
|
|
action,
|
|
realAction,
|
|
currentData,
|
|
disabled,
|
|
open,
|
|
instanceTypeList = [],
|
|
noAvailableInstanceTypes,
|
|
onScopeChange,
|
|
onFinish,
|
|
onFinishFailed
|
|
} = props;
|
|
const intl = useIntl();
|
|
const { getRuleMessage } = useAppUtils();
|
|
const [form] = Form.useForm<InstanceFormValues>();
|
|
const scrollTabsRef = useRef<any>(null);
|
|
const formAction =
|
|
realAction === PageAction.CREATE ? PageAction.CREATE : action;
|
|
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
|
|
// exists/changes when a platform admin retargets the form. Watch it
|
|
// so the parent can re-scope offerings (see onScopeChange).
|
|
const scopeOrgId = Form.useWatch('organization_id', form);
|
|
const ports = Form.useWatch(['spec', 'ports'], form) || [];
|
|
const scopeInitRef = useRef(true);
|
|
// Keep the latest callback in a ref so the scope-change effect can call it
|
|
// without depending on its identity (parent may pass a new fn each render).
|
|
const onScopeChangeRef = useRef(onScopeChange);
|
|
onScopeChangeRef.current = onScopeChange;
|
|
const { sshkeyOptions, fetchData: fetchSSHData } = useQuerySshkeys();
|
|
const [sshOverlayOpen, setSshOverlayOpen] = useState(false);
|
|
const { getScrollElementScrollableHeight } = useWrapperContext();
|
|
const {
|
|
activeKey,
|
|
collapseKeys,
|
|
handleActiveChange,
|
|
handleOnCollapseChange,
|
|
updateActiveKey
|
|
} = useScrollActiveChange({
|
|
initalActiveKeys: [TABKeysMap.BASIC],
|
|
initialCollapseKeys:
|
|
action === PageAction.EDIT
|
|
? []
|
|
: [TABKeysMap.INSTANCE_TYPE, TABKeysMap.TEMPLATE, TABKeysMap.STORAGE]
|
|
});
|
|
|
|
const hasSSHPort = useMemo(
|
|
() =>
|
|
ports.some(
|
|
(item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT
|
|
),
|
|
[ports]
|
|
);
|
|
|
|
const isGPUType = useMemo(() => {
|
|
const spec = parseJsonSafe(description || '{}', {} as any)?.spec;
|
|
return spec?.acceleratable;
|
|
}, [description]);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
const initSSHKeys = async () => {
|
|
// await 200 ms
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 200);
|
|
});
|
|
fetchSSHData({ page: -1 });
|
|
};
|
|
initSSHKeys();
|
|
}
|
|
}, [open]);
|
|
|
|
// Skip the first run (initial mount value); thereafter notify the
|
|
// parent whenever the chosen create scope changes so it can reload
|
|
// the tenant-scoped instance-type / template lists.
|
|
useEffect(() => {
|
|
if (scopeInitRef.current) {
|
|
scopeInitRef.current = false;
|
|
return;
|
|
}
|
|
onScopeChangeRef.current?.(scopeOrgId);
|
|
}, [scopeOrgId]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
hasSSHPort &&
|
|
!form.getFieldValue('enable_ssh') &&
|
|
action === PageAction.CREATE
|
|
) {
|
|
form.setFieldValue('enable_ssh', true);
|
|
}
|
|
}, [hasSSHPort, form, action]);
|
|
|
|
const segmentOptions = useMemo(
|
|
() => [
|
|
{
|
|
value: TABKeysMap.BASIC,
|
|
label: intl.formatMessage({
|
|
id: 'common.title.basicInfo'
|
|
}),
|
|
icon: <IconFont type="icon-basic" />,
|
|
field: 'name'
|
|
},
|
|
{
|
|
value: TABKeysMap.INSTANCE_TYPE,
|
|
label: intl.formatMessage({ id: 'gpuservice.instance.section.type' }),
|
|
icon: <IconFont type="icon-gpu1" />,
|
|
field: 'instanceType'
|
|
},
|
|
{
|
|
value: TABKeysMap.TEMPLATE,
|
|
label: intl.formatMessage({
|
|
id: 'gpuservice.instance.section.template'
|
|
}),
|
|
icon: <IconFont type="icon-model" />,
|
|
field: 'template'
|
|
},
|
|
{
|
|
value: TABKeysMap.STORAGE,
|
|
label: intl.formatMessage({
|
|
id: 'gpuservice.instance.section.storage'
|
|
}),
|
|
icon: <IconFont type="icon-storage-outlined" />,
|
|
field: 'storage'
|
|
}
|
|
],
|
|
[intl]
|
|
);
|
|
|
|
const [selectedInstanceType, setSelectedInstanceType] = useState<
|
|
InstanceTypeItem | undefined
|
|
>(undefined);
|
|
const [onceMaxRequest, setOnceMaxRequest] = useState<BasicResourceMax>({
|
|
cpu: null,
|
|
memory: null,
|
|
localStorage: null
|
|
});
|
|
|
|
const buildResourcesData = (
|
|
instanceType: InstanceTypeItem | undefined,
|
|
options: {
|
|
count: number;
|
|
}
|
|
) => {
|
|
const unitResourcesParsed = instanceType?.spec?.unitResourcesParsed;
|
|
const { count = 0 } = options;
|
|
|
|
console.log('building resources data with', {
|
|
unitResourcesParsed,
|
|
count
|
|
});
|
|
|
|
const result = {
|
|
accelerator: _.toString(count),
|
|
cpu: unitResourcesParsed?.cpu?.cores
|
|
? count * unitResourcesParsed?.cpu?.cores
|
|
: null,
|
|
ram: unitResourcesParsed?.ram?.value
|
|
? count * unitResourcesParsed?.ram?.value
|
|
: null
|
|
};
|
|
console.log('built resources data', result);
|
|
return result;
|
|
};
|
|
|
|
const buildResourcesDataForSubmit = (values: FormData) => {
|
|
const unitResourcesParsed = getUnitResources();
|
|
const accelerator = _.toNumber(values.spec?.resources?.accelerator) || 0;
|
|
const cpuCount = _.toNumber(values.spec?.resources?.cpu) || 0;
|
|
|
|
const cpuNum = unitResourcesParsed?.cpu?.num;
|
|
const ramNum = unitResourcesParsed?.ram?.num;
|
|
|
|
const fallbackCpu = values.spec?.resources?.cpu;
|
|
|
|
const factor = isGPUType ? accelerator : cpuCount;
|
|
|
|
return {
|
|
cpu: cpuNum
|
|
? `${factor * cpuNum}${unitResourcesParsed?.cpu?.unit || ''}`
|
|
: // Don't stringify an unset value — `${undefined}` becomes the
|
|
// literal "undefined", which fails k8s quantity validation.
|
|
fallbackCpu
|
|
? `${fallbackCpu}`
|
|
: undefined,
|
|
ram: ramNum
|
|
? `${factor * ramNum}${unitResourcesParsed?.ram?.unit || ''}`
|
|
: values.spec?.resources?.ram
|
|
};
|
|
};
|
|
|
|
const resolveAndApply = (
|
|
instanceType: InstanceTypeItem | undefined,
|
|
count: number
|
|
) => {
|
|
if (!instanceType) {
|
|
setSelectedInstanceType(undefined);
|
|
setOnceMaxRequest({ cpu: null, memory: null, localStorage: null });
|
|
const spec = form.getFieldValue('spec') || {};
|
|
|
|
form.setFieldsValue({
|
|
clusterId: null,
|
|
spec: {
|
|
...spec,
|
|
type: null,
|
|
resources: {
|
|
...spec.resources,
|
|
accelerator: null,
|
|
cpu: null,
|
|
ram: null
|
|
}
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
setSelectedInstanceType(instanceType);
|
|
|
|
const candidate = pickCandidateForAccelerator(
|
|
instanceType.status?.tiers,
|
|
{
|
|
count,
|
|
acceleratable: instanceType.spec?.acceleratable
|
|
}
|
|
);
|
|
|
|
console.log('picked candidate', candidate, instanceType, count);
|
|
|
|
setOnceMaxRequest({
|
|
cpu: ceilMilliToCore(candidate?.cpu?.onceMaxRequest)?.cores,
|
|
memory: parseQuantityToGi(candidate?.ram?.onceMaxRequest)?.value,
|
|
localStorage: parseQuantityToGi(candidate?.localStorage?.onceMaxRequest)
|
|
?.value
|
|
});
|
|
|
|
form.setFieldsValue({
|
|
clusterId: candidate?.cluster ? _.toNumber(candidate.cluster) : null,
|
|
spec: {
|
|
type: candidate?.name || '',
|
|
resources: {
|
|
...buildResourcesData(instanceType, {
|
|
count
|
|
})
|
|
}
|
|
} as any
|
|
});
|
|
};
|
|
|
|
const handleAcceleratorChange = (count: number) => {
|
|
resolveAndApply(selectedInstanceType, count);
|
|
};
|
|
|
|
const onTargetChange = (key: string) => {
|
|
scrollTabsRef.current?.handleTargetChange(key);
|
|
};
|
|
|
|
const { handleOnFinishFailed: rawHandleOnFinishFailed } = useFinishFailed({
|
|
requiredFields,
|
|
onTargetChange,
|
|
updateActiveKey
|
|
});
|
|
|
|
const handleOnFinishFailed = (errorInfo: any) => {
|
|
console.log('formvalues===', form.getFieldsValue());
|
|
const errorFields = (errorInfo?.errorFields || []).map((field: any) => ({
|
|
...field,
|
|
name: [
|
|
Array.isArray(field.name) ? field.name.join('.') : String(field.name)
|
|
]
|
|
}));
|
|
rawHandleOnFinishFailed({ ...errorInfo, errorFields });
|
|
onFinishFailed?.(errorInfo);
|
|
};
|
|
const detectMode = (volume?: FormData['spec']['volume']) => {
|
|
if (volume?.persistent?.name || volume?.persistentTemplate?.name) {
|
|
return StorageModeValueMap.Persistent;
|
|
}
|
|
return StorageModeValueMap.Temporary;
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
form.resetFields();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
action === PageAction.EDIT ||
|
|
action === PageAction.VIEW ||
|
|
realAction === PageAction.CREATE
|
|
) {
|
|
console.log('currentData', currentData);
|
|
const currentSpec = parseJsonSafe(
|
|
currentData?.description || '{}',
|
|
{} as any
|
|
)?.spec;
|
|
|
|
const count = currentSpec?.acceleratable
|
|
? _.toNumber(currentData?.spec?.resources?.accelerator)
|
|
: _.toNumber(currentData?.spec?.resources?.cpu) || 0;
|
|
|
|
form.setFieldsValue({
|
|
...currentData,
|
|
spec: {
|
|
...currentData?.spec,
|
|
resources: {
|
|
...currentData?.spec?.resources,
|
|
...buildResourcesData(
|
|
parseJsonSafe<any>(currentData?.description || '{}', {}),
|
|
{
|
|
count
|
|
}
|
|
)
|
|
}
|
|
},
|
|
enable_ssh: !!currentData?.spec?.sshPublicKeys?.length,
|
|
storageMode: detectMode(currentData?.spec?.volume)
|
|
});
|
|
}
|
|
}, [action, currentData, form, open, realAction, instanceTypeList]);
|
|
|
|
const getUnitResources = () => {
|
|
if (selectedInstanceType?.spec?.unitResourcesParsed) {
|
|
return selectedInstanceType.spec.unitResourcesParsed;
|
|
}
|
|
try {
|
|
return (
|
|
parseJsonSafe<any>(currentData?.description || '{}', {})?.spec
|
|
?.unitResourcesParsed ?? undefined
|
|
);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const handleFinish = async (values: InstanceFormValues) => {
|
|
const submittedPorts = [...(values.spec?.ports ?? [])];
|
|
const submittedHasSSHPort = submittedPorts.some(
|
|
(item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT
|
|
);
|
|
|
|
if (!submittedHasSSHPort) {
|
|
submittedPorts.push({
|
|
protocol: 'TCP',
|
|
port: SSH_PORT,
|
|
name: 'SSH'
|
|
});
|
|
}
|
|
|
|
await onFinish({
|
|
..._.omit(values, ['enable_ssh']),
|
|
spec: {
|
|
...values.spec,
|
|
ports: submittedPorts,
|
|
resources: {
|
|
...values.spec?.resources,
|
|
...buildResourcesDataForSubmit(values)
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const segmentedTop =
|
|
action === PageAction.EDIT
|
|
? {
|
|
top: 0,
|
|
offsetTop: 100
|
|
}
|
|
: {
|
|
top: 40,
|
|
offsetTop: 130
|
|
};
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
submit: () => {
|
|
form.submit();
|
|
},
|
|
resetFields: () => {
|
|
form.resetFields();
|
|
},
|
|
setFieldsValue: (values: Partial<InstanceFormValues>) => {
|
|
form.setFieldsValue(values as any);
|
|
},
|
|
getFieldsValue: () => form.getFieldsValue(),
|
|
applyInstanceType: (instanceType?: InstanceTypeItem) => {
|
|
if (!instanceType) {
|
|
resolveAndApply(undefined, 0);
|
|
return;
|
|
}
|
|
|
|
// set default to 1, for all instance types: GPU or non-GPU
|
|
resolveAndApply(instanceType, 1);
|
|
}
|
|
}));
|
|
|
|
const handleAddSSHKey = () => {
|
|
setSshOverlayOpen(true);
|
|
};
|
|
|
|
const handleCreateSSHKey = async (values: PublicKeyFormData) => {
|
|
try {
|
|
await fetchSSHData({ page: -1 });
|
|
const current: Array<{ name: string } | string> =
|
|
form.getFieldValue(['spec', 'sshPublicKeys']) || [];
|
|
form.setFieldValue(
|
|
['spec', 'sshPublicKeys'],
|
|
[...current, { name: values.name }]
|
|
);
|
|
|
|
setSshOverlayOpen(false);
|
|
} catch (error) {
|
|
// it's handled in interceptor
|
|
}
|
|
};
|
|
|
|
const handleOnEnableSSHChange = (e: any) => {
|
|
const checked = e.target.checked;
|
|
if (!checked) {
|
|
form.setFieldValue(['spec', 'sshPublicKeys'], []);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ScrollSpyTabs
|
|
ref={scrollTabsRef}
|
|
defaultTarget={TABKeysMap.BASIC}
|
|
segmentOptions={segmentOptions}
|
|
activeKey={activeKey}
|
|
setActiveKey={handleActiveChange}
|
|
segmentedTop={segmentedTop}
|
|
getScrollElementScrollableHeight={getScrollElementScrollableHeight}
|
|
>
|
|
<FormContext.Provider
|
|
value={{
|
|
action: formAction,
|
|
currentData: currentData,
|
|
isGPUType: isGPUType
|
|
}}
|
|
>
|
|
<Form
|
|
name="gpuServiceInstanceForm"
|
|
form={form}
|
|
disabled={disabled}
|
|
onFinish={handleFinish}
|
|
onFinishFailed={handleOnFinishFailed}
|
|
preserve={false}
|
|
initialValues={{
|
|
spec: {
|
|
description: '',
|
|
displayName: '',
|
|
image: '',
|
|
imagePullPolicy: DefaultImagePullPolicy,
|
|
command: [],
|
|
ports: [
|
|
{
|
|
protocol: 'TCP',
|
|
port: SSH_PORT,
|
|
name: 'SSH'
|
|
}
|
|
],
|
|
env: [],
|
|
volumeMount: '',
|
|
resources: {
|
|
accelerator: 1
|
|
},
|
|
volume: {
|
|
ephemeral: {
|
|
capacity: '50Gi'
|
|
},
|
|
persistent: {
|
|
name: ''
|
|
}
|
|
}
|
|
},
|
|
enable_ssh: false,
|
|
storageMode: StorageModeValueMap.Temporary
|
|
}}
|
|
>
|
|
<Basic action={formAction} disabled={disabled} />
|
|
<Form.Item name="clusterId" hidden>
|
|
<CInput.Input />
|
|
</Form.Item>
|
|
<CollapsePanel
|
|
activeKey={collapseKeys}
|
|
accordion={false}
|
|
onChange={handleOnCollapseChange}
|
|
items={[
|
|
{
|
|
key: TABKeysMap.INSTANCE_TYPE,
|
|
label: intl.formatMessage({
|
|
id: 'gpuservice.instance.section.type'
|
|
}),
|
|
forceRender: true,
|
|
children: (
|
|
<InstanceTypeFormItem
|
|
action={formAction}
|
|
disabled={disabled}
|
|
selectedInstanceType={selectedInstanceType}
|
|
currentData={currentData as any}
|
|
onceMaxRequest={onceMaxRequest}
|
|
noAvailableTypes={noAvailableInstanceTypes}
|
|
onGPUCountChange={handleAcceleratorChange}
|
|
/>
|
|
)
|
|
},
|
|
{
|
|
key: TABKeysMap.TEMPLATE,
|
|
label: intl.formatMessage({
|
|
id: 'gpuservice.instance.section.template'
|
|
}),
|
|
forceRender: true,
|
|
children: (
|
|
<TemplateBasicForm
|
|
page="instance"
|
|
disabled={disabled || formAction === PageAction.EDIT}
|
|
onceMaxRequest={onceMaxRequest}
|
|
/>
|
|
)
|
|
},
|
|
{
|
|
key: TABKeysMap.STORAGE,
|
|
label: intl.formatMessage({
|
|
id: 'gpuservice.instance.section.storage'
|
|
}),
|
|
forceRender: true,
|
|
children: (
|
|
<StorageVolume
|
|
disabled={disabled || formAction === PageAction.EDIT}
|
|
action={formAction}
|
|
/>
|
|
)
|
|
}
|
|
]}
|
|
/>
|
|
|
|
<Flex align="center" justify="space-between">
|
|
<Form.Item<FormData>
|
|
name="enable_ssh"
|
|
valuePropName="checked"
|
|
style={{ marginBottom: 8 }}
|
|
>
|
|
<CheckboxField
|
|
onChange={handleOnEnableSSHChange}
|
|
disabled={disabled}
|
|
label={intl.formatMessage({
|
|
id: 'gpuservice.instance.ssh.enable'
|
|
})}
|
|
></CheckboxField>
|
|
</Form.Item>
|
|
<Button
|
|
size="small"
|
|
type="link"
|
|
icon={<PlusOutlined />}
|
|
onClick={handleAddSSHKey}
|
|
>
|
|
{intl.formatMessage({
|
|
id: 'gpuservice.instance.ssh.addKey'
|
|
})}
|
|
</Button>
|
|
</Flex>
|
|
|
|
<div className={instanceStyles.sshkeySelection}>
|
|
<Form.Item<FormData>
|
|
name={['spec', 'sshPublicKeys']}
|
|
style={{
|
|
marginBottom: 12
|
|
}}
|
|
hidden={!sshEnabled}
|
|
normalize={(value) =>
|
|
Array.isArray(value)
|
|
? value?.map((item) => ({ name: item }))
|
|
: []
|
|
}
|
|
getValueProps={(value) => ({
|
|
value: Array.isArray(value)
|
|
? value.map((item) => item?.name ?? item)
|
|
: []
|
|
})}
|
|
rules={[
|
|
{
|
|
required: sshEnabled,
|
|
message: getRuleMessage('select', 'gpuservice.publicKey')
|
|
}
|
|
]}
|
|
>
|
|
<MultipleSelect
|
|
required
|
|
disabled={disabled}
|
|
mode="multiple"
|
|
maxTagCount={'responsive'}
|
|
label={intl.formatMessage({
|
|
id: 'gpuservice.instance.ssh.assignKey'
|
|
})}
|
|
styles={{
|
|
wrapper: {
|
|
width: '100%'
|
|
}
|
|
}}
|
|
options={sshkeyOptions}
|
|
></MultipleSelect>
|
|
</Form.Item>
|
|
</div>
|
|
</Form>
|
|
</FormContext.Provider>
|
|
<PublicKeyOverlay
|
|
open={sshOverlayOpen}
|
|
onCancel={() => setSshOverlayOpen(false)}
|
|
onSubmit={handleCreateSSHKey}
|
|
/>
|
|
</ScrollSpyTabs>
|
|
);
|
|
}
|
|
);
|
|
|
|
export default GPUServiceInstanceForm;
|