1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-03-21 14:50:08 +03:00

F #5422: Add global & context attributes to template form (#1576)

This commit is contained in:
Sergio Betanzos 2021-11-18 11:01:01 +01:00 committed by GitHub
parent 4463493b12
commit ab1893257b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 714 additions and 300 deletions

View File

@ -69,7 +69,6 @@ const ButtonToTriggerForm = ({
aria-expanded={open ? 'true' : undefined}
aria-haspopup={isGroupButton ? 'true' : false}
disabled={!options.length}
endicon={isGroupButton ? <NavArrowDown /> : undefined}
onClick={evt => !isGroupButton
? openDialogForm(options[0])

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { JSXElementConstructor } from 'react'
import { useMemo, JSXElementConstructor } from 'react'
import { Stack, FormControl, Button } from '@mui/material'
import { useFormContext } from 'react-hook-form'
@ -23,21 +23,20 @@ import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateFo
import { SSH_PUBLIC_KEY, SCRIPT_FIELDS, OTHER_FIELDS } from './schema'
import { T } from 'client/constants'
export const SECTION_ID = 'CONTEXT'
const SSH_KEY_USER = '$USER[SSH_PUBLIC_KEY]'
/** @returns {JSXElementConstructor} - Configuration section */
const ConfigurationSection = () => {
const { setValue, getValues } = useFormContext()
const SSH_PUBLIC_KEY_PATH = `${EXTRA_ID}.${SSH_PUBLIC_KEY.name}`
const SSH_PUBLIC_KEY_PATH = useMemo(() => `${EXTRA_ID}.${SSH_PUBLIC_KEY.name}`, [])
const handleClearKey = () => setValue(SSH_PUBLIC_KEY_PATH)
const handleAddUserKey = () => {
const currentSshPublicKey = getValues(SSH_PUBLIC_KEY_PATH) ?? ''
let currentKey = getValues(SSH_PUBLIC_KEY_PATH)
currentKey &&= currentKey + '\n'
setValue(SSH_PUBLIC_KEY_PATH, currentSshPublicKey + `\n${SSH_KEY_USER}`)
setValue(SSH_PUBLIC_KEY_PATH, `${currentKey ?? ''}${SSH_KEY_USER}`)
}
return (

View File

@ -0,0 +1,54 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { string, ObjectSchema } from 'yup'
import { T, INPUT_TYPES, HYPERVISORS } from 'client/constants'
import { Field, filterFieldsByHypervisor, getObjectSchemaFromFields } from 'client/utils'
const { vcenter } = HYPERVISORS
/** @type {Field} Files field */
export const FILES_DS = {
name: 'CONTEXT.FILES_DS',
label: T.ContextFiles,
tooltip: T.ContextFilesConcept,
notOnHypervisors: [vcenter],
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.notRequired(),
grid: { md: 12 }
}
/** @type {Field} Init scripts field */
export const INIT_SCRIPTS = {
name: 'CONTEXT.INIT_SCRIPTS',
label: T.InitScripts,
tooltip: T.InitScriptsConcept,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.notRequired(),
grid: { md: 12 }
}
/** @type {Field[]} List of Context Files fields */
export const FILES_FIELDS = hypervisor =>
filterFieldsByHypervisor([FILES_DS, INIT_SCRIPTS], hypervisor)
/** @type {ObjectSchema} Context Files schema */
export const FILES_SCHEMA = hypervisor =>
getObjectSchemaFromFields(FILES_FIELDS(hypervisor))

View File

@ -0,0 +1,48 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { JSXElementConstructor } from 'react'
import PropTypes from 'prop-types'
import { FormWithSchema } from 'client/components/Forms'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { FILES_FIELDS } from './schema'
import { T } from 'client/constants'
export const SECTION_ID = 'CONTEXT'
/**
* @param {object} props - Props
* @param {string} props.hypervisor - VM hypervisor
* @returns {JSXElementConstructor} - Files section
*/
const FilesSection = ({ hypervisor }) => (
<FormWithSchema
cy={`create-vm-template-${EXTRA_ID}.context-files`}
legend={T.Files}
fields={() => FILES_FIELDS(hypervisor)}
id={EXTRA_ID}
/>
)
FilesSection.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
hypervisor: PropTypes.string,
control: PropTypes.object
}
export default FilesSection

View File

@ -17,17 +17,19 @@ import PropTypes from 'prop-types'
import { Folder as ContextIcon } from 'iconoir-react'
import { TabType } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import ConfigurationSection, { SECTION_ID as CONFIGURATION_ID } from './configurationSection'
import UserInputsSection, { SECTION_ID as USER_INPUTS_ID } from './userInputsSection'
import ConfigurationSection from './configurationSection'
import FilesSection from './filesSection'
import { T } from 'client/constants'
export const TAB_ID = [CONFIGURATION_ID, USER_INPUTS_ID]
export const TAB_ID = ['CONTEXT', USER_INPUTS_ID]
const Context = () => {
const Context = props => {
return (
<>
<ConfigurationSection />
<FilesSection {...props} />
<UserInputsSection />
</>
)

View File

@ -13,14 +13,21 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { object } from 'yup'
import { object, ObjectSchema } from 'yup'
import { USER_INPUTS_SCHEMA } from './userInputsSchema'
import { CONFIGURATION_SCHEMA } from './configurationSchema'
import { FILES_SCHEMA } from './filesSchema'
export const SCHEMA = object()
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {ObjectSchema} Context schema
*/
export const SCHEMA = hypervisor => object()
.concat(CONFIGURATION_SCHEMA)
.concat(USER_INPUTS_SCHEMA)
.concat(FILES_SCHEMA(hypervisor))
export * from './userInputsSchema'
export * from './configurationSchema'
export * from './filesSchema'

View File

@ -183,5 +183,6 @@ export const USER_INPUT_SCHEMA = getObjectSchemaFromFields(USER_INPUT_FIELDS)
/** @type {ObjectSchema} User Inputs schema */
export const USER_INPUTS_SCHEMA = object({
USER_INPUTS: array(USER_INPUT_SCHEMA).ensure()
USER_INPUTS: array(USER_INPUT_SCHEMA).ensure(),
INPUTS_ORDER: string().trim().strip()
})

View File

@ -0,0 +1,108 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo, JSXElementConstructor } from 'react'
import PropTypes from 'prop-types'
import { Edit, Trash } from 'iconoir-react'
import ButtonToTriggerForm from 'client/components/Forms/ButtonToTriggerForm'
import SelectCard, { Action } from 'client/components/Cards/SelectCard'
import { AttachNicForm } from 'client/components/Forms/Vm'
import { Translate } from 'client/components/HOC'
import { stringToBoolean } from 'client/models/Helper'
import { T } from 'client/constants'
/**
* @param {object} props - Props
* @param {number} props.index - Index in list
* @param {object} props.item - NIC
* @param {string} props.handleRemove - Remove function
* @param {string} props.handleUpdate - Update function
* @returns {JSXElementConstructor} - NIC card
*/
const NicItem = memo(({
item,
nics,
handleRemove,
handleUpdate
}) => {
const { id, NAME, RDP, SSH, NETWORK, PARENT, EXTERNAL } = item
const hasAlias = nics?.some(nic => nic.PARENT === NAME)
return (
<SelectCard
key={id ?? NAME}
title={[NAME, NETWORK].filter(Boolean).join(' - ')}
subheader={<>
{Object
.entries({
RDP: stringToBoolean(RDP),
SSH: stringToBoolean(SSH),
EXTERNAL: stringToBoolean(EXTERNAL),
[`PARENT: ${PARENT}`]: PARENT
})
.map(([k, v]) => v ? `${k}` : '')
.filter(Boolean)
.join(' | ')
}
</>}
action={
<>
{!hasAlias &&
<Action
data-cy={`remove-${NAME}`}
tooltip={<Translate word={T.Remove} />}
handleClick={handleRemove}
color='error'
icon={<Trash />}
/>
}
<ButtonToTriggerForm
buttonProps={{
'data-cy': `edit-${NAME}`,
icon: <Edit />,
tooltip: <Translate word={T.Edit} />
}}
options={[{
dialogProps: {
title: (
<Translate
word={T.EditSomething}
values={[`${NAME} - ${NETWORK}`]}
/>
)
},
form: () => AttachNicForm({ nics }, item),
onSubmit: handleUpdate
}]}
/>
</>
}
/>
)
}, (prev, next) => prev.item?.NAME === next.item?.NAME)
NicItem.propTypes = {
index: PropTypes.number,
item: PropTypes.object,
nics: PropTypes.array,
handleRemove: PropTypes.func,
handleUpdate: PropTypes.func
}
NicItem.displayName = 'NicItem'
export default NicItem

View File

@ -15,19 +15,19 @@
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { Stack } from '@mui/material'
import { ServerConnection as NetworkIcon, Edit, Trash } from 'iconoir-react'
import { ServerConnection as NetworkIcon } from 'iconoir-react'
import { useFormContext, useFieldArray } from 'react-hook-form'
import ButtonToTriggerForm from 'client/components/Forms/ButtonToTriggerForm'
import SelectCard, { Action } from 'client/components/Cards/SelectCard'
import { AttachNicForm } from 'client/components/Forms/Vm'
import { Translate } from 'client/components/HOC'
import { FormWithSchema } from 'client/components/Forms'
import { STEP_ID as EXTRA_ID, TabType } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { mapNameByIndex } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema'
import { BOOT_ORDER_NAME, reorderBootAfterRemove } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting'
import { stringToBoolean } from 'client/models/Helper'
import { FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/networking/schema'
import { T } from 'client/constants'
import NicItem from './NicItem'
export const TAB_ID = 'NIC'
@ -48,6 +48,10 @@ const Networking = () => {
setValue(BOOT_ORDER_NAME(), updatedBootOrder)
}
const handleUpdate = (updatedNic, index) => {
update(index, mapNameFunction(updatedNic, index))
}
return (
<>
<ButtonToTriggerForm
@ -63,69 +67,31 @@ const Networking = () => {
onSubmit: nic => append(mapNameFunction(nic, nics.length))
}]}
/>
<Stack
pb='1em'
display='grid'
gridTemplateColumns='repeat(auto-fit, minmax(300px, 0.5fr))'
gap='1em'
mt='1em'
<Stack pb='1em' display='grid' gap='1em' mt='1em'
sx={{
gridTemplateColumns: {
sm: '1fr',
md: 'repeat(auto-fit, minmax(300px, 0.5fr))'
}
}}
>
{nics?.map((item, index) => {
const { id, NAME, RDP, SSH, NETWORK, PARENT, EXTERNAL } = item
const hasAlias = nics?.some(nic => nic.PARENT === NAME)
return (
<SelectCard
key={id ?? NAME}
title={[NAME, NETWORK].filter(Boolean).join(' - ')}
subheader={<>
{Object
.entries({
RDP: stringToBoolean(RDP),
SSH: stringToBoolean(SSH),
EXTERNAL: stringToBoolean(EXTERNAL),
[`PARENT: ${PARENT}`]: PARENT
})
.map(([k, v]) => v ? `${k}` : '')
.filter(Boolean)
.join(' | ')
}
</>}
action={
<>
{!hasAlias &&
<Action
data-cy={`remove-${NAME}`}
handleClick={() => removeAndReorder(NAME)}
icon={<Trash />}
/>
}
<ButtonToTriggerForm
buttonProps={{
'data-cy': `edit-${NAME}`,
icon: <Edit />,
tooltip: <Translate word={T.Edit} />
}}
options={[{
dialogProps: {
title: (
<Translate
word={T.EditSomething}
values={[`${NAME} - ${NETWORK}`]}
/>
)
},
form: () => AttachNicForm({ nics }, item),
onSubmit: updatedNic =>
update(index, mapNameFunction(updatedNic, index))
}]}
/>
</>
}
/>
)
})}
{nics?.map(({ id, ...item }, index) => (
<NicItem
key= {id ?? item?.NAME}
item={item}
nics={nics}
handleRemove={() => removeAndReorder(item?.NAME)}
handleUpdate={updatedNic => handleUpdate(updatedNic, index)}
/>
))}
</Stack>
<FormWithSchema
cy={`create-vm-template-${EXTRA_ID}.network-options`}
fields={FIELDS}
legend={T.NetworkDefaults}
legendTooltip={T.NetworkDefaultsConcept}
id={EXTRA_ID}
/>
</>
)
}

View File

@ -0,0 +1,54 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { object, string, array, ObjectSchema } from 'yup'
import { T, INPUT_TYPES } from 'client/constants'
import { Field, getObjectSchemaFromFields } from 'client/utils'
import { mapNameByIndex } from '../schema'
/** @returns {Field} NIC filter field */
const FILTER = {
name: 'NIC_DEFAULT.FILTER',
label: T.DefaultNicFilter,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @returns {Field} NIC model field */
const MODEL = {
name: 'NIC_DEFAULT.MODEL',
label: T.DefaultNicModel,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field[]} List of Network defaults fields */
const FIELDS = [FILTER, MODEL]
/** @type {ObjectSchema} Network schema */
const SCHEMA = object({
NIC: array()
.ensure()
.transform(nics => nics.map(mapNameByIndex('NIC')))
}).concat(getObjectSchemaFromFields(FIELDS))
export { FIELDS, SCHEMA }

View File

@ -21,6 +21,8 @@ import { FIELDS as OS_FIELDS } from 'client/components/Forms/VmTemplate/CreateFo
import { FIELDS as NUMA_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/numa/schema'
import { SCHEMA as IO_SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/inputOutput/schema'
import { SCHEMA as CONTEXT_SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/schema'
import { SCHEMA as STORAGE_SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/storage/schema'
import { SCHEMA as NETWORK_SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/networking/schema'
import { getObjectSchemaFromFields } from 'client/utils'
export const mapNameByIndex = (prefixName) => (resource, idx) => ({
@ -30,28 +32,19 @@ export const mapNameByIndex = (prefixName) => (resource, idx) => ({
: resource?.NAME
})
export const DISK_SCHEMA = array()
.ensure()
.transform(disks => disks.map(mapNameByIndex('DISK')))
export const NIC_SCHEMA = array()
.ensure()
.transform(nics => nics.map(mapNameByIndex('NIC')))
export const SCHED_ACTION_SCHEMA = array()
.ensure()
.transform(actions => actions.map(mapNameByIndex('SCHED_ACTION')))
export const SCHEMA = hypervisor => object({
DISK: DISK_SCHEMA,
NIC: NIC_SCHEMA,
SCHED_ACTION: SCHED_ACTION_SCHEMA
})
.concat(CONTEXT_SCHEMA)
.concat(NETWORK_SCHEMA)
.concat(STORAGE_SCHEMA)
.concat(CONTEXT_SCHEMA(hypervisor))
.concat(IO_SCHEMA(hypervisor))
.concat(getObjectSchemaFromFields([
...PLACEMENT_FIELDS,
...OS_FIELDS(hypervisor),
...NUMA_FIELDS(hypervisor)
]))
.noUnknown(false)

View File

@ -1,191 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import { Stack } from '@mui/material'
import { Db as DatastoreIcon, Edit, Trash } from 'iconoir-react'
import { useFormContext, useFieldArray } from 'react-hook-form'
import ButtonToTriggerForm from 'client/components/Forms/ButtonToTriggerForm'
import SelectCard, { Action } from 'client/components/Cards/SelectCard'
import { ImageSteps, VolatileSteps } from 'client/components/Forms/Vm'
import { StatusCircle, StatusChip } from 'client/components/Status'
import { Translate } from 'client/components/HOC'
import { STEP_ID as EXTRA_ID, TabType } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { mapNameByIndex } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema'
import { BOOT_ORDER_NAME, reorderBootAfterRemove } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting'
import { getState, getDiskType } from 'client/models/Image'
import { stringToBoolean } from 'client/models/Helper'
import { prettyBytes } from 'client/utils'
import { T } from 'client/constants'
export const TAB_ID = 'DISK'
const mapNameFunction = mapNameByIndex('DISK')
const Storage = ({ hypervisor }) => {
const { getValues, setValue } = useFormContext()
const { fields: disks, replace, update, append } = useFieldArray({
name: `${EXTRA_ID}.${TAB_ID}`
})
const removeAndReorder = diskName => {
const updatedDisks = disks.filter(({ NAME }) => NAME !== diskName).map(mapNameFunction)
const currentBootOrder = getValues(BOOT_ORDER_NAME())
const updatedBootOrder = reorderBootAfterRemove(diskName, disks, currentBootOrder)
replace(updatedDisks)
setValue(BOOT_ORDER_NAME(), updatedBootOrder)
}
return (
<>
<ButtonToTriggerForm
buttonProps={{
color: 'secondary',
'data-cy': 'add-disk',
label: T.AttachDisk,
variant: 'outlined'
}}
options={[
{
cy: 'attach-image-disk',
name: T.Image,
dialogProps: { title: T.AttachImage },
form: () => ImageSteps({ hypervisor }),
onSubmit: image => append(mapNameFunction(image, disks.length))
},
{
cy: 'attach-volatile-disk',
name: T.Volatile,
dialogProps: { title: T.AttachVolatile },
form: () => VolatileSteps({ hypervisor }),
onSubmit: image => append(mapNameFunction(image, disks.length))
}
]}
/>
<Stack
pb='1em'
display='grid'
gridTemplateColumns='repeat(auto-fit, minmax(300px, 0.5fr))'
gap='1em'
mt='1em'
>
{disks?.map((item, index) => {
const {
id,
NAME,
TYPE,
IMAGE,
IMAGE_ID,
IMAGE_STATE,
ORIGINAL_SIZE,
SIZE = ORIGINAL_SIZE,
READONLY,
DATASTORE,
PERSISTENT
} = item
const isVolatile = !IMAGE && !IMAGE_ID
const isPersistent = stringToBoolean(PERSISTENT)
const state = !isVolatile && getState({ STATE: IMAGE_STATE })
const type = isVolatile ? TYPE : getDiskType(item)
const originalSize = +ORIGINAL_SIZE ? prettyBytes(+ORIGINAL_SIZE, 'MB') : '-'
const size = prettyBytes(+SIZE, 'MB')
return (
<SelectCard
key={id ?? NAME}
title={isVolatile ? (
<>
{`${NAME} - `}
<Translate word={T.VolatileDisk} />
</>
) : (
<Stack component='span' alignItems='center' gap='0.5em'>
<StatusCircle color={state?.color} tooltip={state?.name} />
{`${NAME}: ${IMAGE}`}
{isPersistent && <StatusChip text='PERSISTENT' />}
</Stack>
)}
subheader={<>
{Object
.entries({
[DATASTORE]: DATASTORE,
READONLY: stringToBoolean(READONLY),
PERSISTENT: stringToBoolean(PERSISTENT),
[isVolatile || ORIGINAL_SIZE === SIZE
? size : `${originalSize}/${size}`]: true,
[type]: type
})
.map(([k, v]) => v ? `${k}` : '')
.filter(Boolean)
.join(' | ')
}
</>}
action={
<>
<Action
data-cy={`remove-${NAME}`}
tooltip={<Translate word={T.Remove} />}
handleClick={() => removeAndReorder(NAME, index)}
icon={<Trash />}
/>
<ButtonToTriggerForm
buttonProps={{
'data-cy': `edit-${NAME}`,
icon: <Edit />,
tooltip: <Translate word={T.Edit} />
}}
options={[{
dialogProps: {
title: <Translate word={T.EditSomething} values={[NAME]} />
},
form: () => isVolatile
? VolatileSteps({ hypervisor }, item)
: ImageSteps({ hypervisor }, item),
onSubmit: updatedDisk =>
update(index, mapNameFunction(updatedDisk, index))
}]}
/>
</>
}
/>
)
})}
</Stack>
</>
)
}
Storage.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
hypervisor: PropTypes.string,
control: PropTypes.object
}
/** @type {TabType} */
const TAB = {
id: 'storage',
name: T.Storage,
icon: DatastoreIcon,
Content: Storage,
getError: error => !!error?.[TAB_ID]
}
export default TAB

View File

@ -0,0 +1,139 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo, JSXElementConstructor } from 'react'
import PropTypes from 'prop-types'
import { Stack } from '@mui/material'
import { Edit, Trash } from 'iconoir-react'
import ButtonToTriggerForm from 'client/components/Forms/ButtonToTriggerForm'
import SelectCard, { Action } from 'client/components/Cards/SelectCard'
import { ImageSteps, VolatileSteps } from 'client/components/Forms/Vm'
import { StatusCircle, StatusChip } from 'client/components/Status'
import { Translate } from 'client/components/HOC'
import { getState, getDiskType } from 'client/models/Image'
import { stringToBoolean } from 'client/models/Helper'
import { prettyBytes } from 'client/utils'
import { T } from 'client/constants'
/**
* The disk item will be included in the VM Template.
*
* @param {object} props - Props
* @param {number} props.index - Index in list
* @param {object} props.item - Disk
* @param {string} props.hypervisor - VM hypervisor
* @param {string} props.handleRemove - Remove function
* @param {string} props.handleUpdate - Update function
* @returns {JSXElementConstructor} - Disk card
*/
const DiskItem = memo(({
item,
hypervisor,
handleRemove,
handleUpdate
}) => {
const {
NAME,
TYPE,
IMAGE,
IMAGE_ID,
IMAGE_STATE,
ORIGINAL_SIZE,
SIZE = ORIGINAL_SIZE,
READONLY,
DATASTORE,
PERSISTENT
} = item
const isVolatile = !IMAGE && !IMAGE_ID
const isPersistent = stringToBoolean(PERSISTENT)
const state = !isVolatile && getState({ STATE: IMAGE_STATE })
const type = isVolatile ? TYPE : getDiskType(item)
const originalSize = +ORIGINAL_SIZE ? prettyBytes(+ORIGINAL_SIZE, 'MB') : '-'
const size = prettyBytes(+SIZE, 'MB')
return (
<SelectCard
title={isVolatile ? (
<>
{`${NAME} - `}
<Translate word={T.VolatileDisk} />
</>
) : (
<Stack component='span' alignItems='center' gap='0.5em'>
<StatusCircle color={state?.color} tooltip={state?.name} />
{`${NAME}: ${IMAGE}`}
{isPersistent && <StatusChip text='PERSISTENT' />}
</Stack>
)}
subheader={<>
{Object
.entries({
[DATASTORE]: DATASTORE,
READONLY: stringToBoolean(READONLY),
PERSISTENT: stringToBoolean(PERSISTENT),
[isVolatile || ORIGINAL_SIZE === SIZE
? size : `${originalSize}/${size}`]: true,
[type]: type
})
.map(([k, v]) => v ? `${k}` : '')
.filter(Boolean)
.join(' | ')
}
</>}
action={
<>
<Action
data-cy={`remove-${NAME}`}
tooltip={<Translate word={T.Remove} />}
handleClick={handleRemove}
color='error'
icon={<Trash />}
/>
<ButtonToTriggerForm
buttonProps={{
'data-cy': `edit-${NAME}`,
icon: <Edit />,
tooltip: <Translate word={T.Edit} />
}}
options={[{
dialogProps: {
title: <Translate word={T.EditSomething} values={[NAME]} />
},
form: () => isVolatile
? VolatileSteps({ hypervisor }, item)
: ImageSteps({ hypervisor }, item),
onSubmit: handleUpdate
}]}
/>
</>
}
/>
)
}, (prev, next) => prev.item?.NAME === next.item?.NAME)
DiskItem.propTypes = {
index: PropTypes.number,
item: PropTypes.object,
hypervisor: PropTypes.string,
handleRemove: PropTypes.func,
handleUpdate: PropTypes.func
}
DiskItem.displayName = 'DiskItem'
export default DiskItem

View File

@ -0,0 +1,124 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { Stack } from '@mui/material'
import { Db as DatastoreIcon } from 'iconoir-react'
import { useFieldArray, useFormContext } from 'react-hook-form'
import ButtonToTriggerForm from 'client/components/Forms/ButtonToTriggerForm'
import { ImageSteps, VolatileSteps } from 'client/components/Forms/Vm'
import { FormWithSchema } from 'client/components/Forms'
import { STEP_ID as EXTRA_ID, TabType } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { mapNameByIndex } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema'
import { BOOT_ORDER_NAME, reorderBootAfterRemove } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting'
import { FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/storage/schema'
import { T } from 'client/constants'
import DiskItem from './DiskItem'
export const TAB_ID = 'DISK'
const mapNameFunction = mapNameByIndex('DISK')
const Storage = ({ hypervisor }) => {
const { getValues, setValue } = useFormContext()
const { fields: disks, append, update, replace } = useFieldArray({
name: `${EXTRA_ID}.${TAB_ID}`
})
const removeAndReorder = diskName => {
const updatedDisks = disks.filter(({ NAME }) => NAME !== diskName).map(mapNameFunction)
const currentBootOrder = getValues(BOOT_ORDER_NAME())
const updatedBootOrder = reorderBootAfterRemove(diskName, disks, currentBootOrder)
replace(updatedDisks)
setValue(BOOT_ORDER_NAME(), updatedBootOrder)
}
const handleUpdate = (updatedDisk, index) => {
update(index, mapNameFunction(updatedDisk, index))
}
return (
<>
<ButtonToTriggerForm
buttonProps={{
color: 'secondary',
'data-cy': 'add-disk',
label: T.AttachDisk,
variant: 'outlined'
}}
options={[
{
cy: 'attach-image-disk',
name: T.Image,
dialogProps: { title: T.AttachImage },
form: () => ImageSteps({ hypervisor }),
onSubmit: image => append(mapNameFunction(image, disks.length))
},
{
cy: 'attach-volatile-disk',
name: T.Volatile,
dialogProps: { title: T.AttachVolatile },
form: () => VolatileSteps({ hypervisor }),
onSubmit: image => append(mapNameFunction(image, disks.length))
}
]}
/>
<Stack pb='1em' display='grid' gap='1em' mt='1em'
sx={{
gridTemplateColumns: {
sm: '1fr',
md: 'repeat(auto-fit, minmax(300px, 0.5fr))'
}
}}
>
{disks?.map(({ id, ...item }, index) => (
<DiskItem
key={id ?? item?.NAME}
item={item}
handleRemove={() => removeAndReorder(item?.NAME)}
handleUpdate={updatedDisk => handleUpdate(updatedDisk, index)}
/>
))}
</Stack>
<FormWithSchema
cy={`create-vm-template-${EXTRA_ID}.storage-options`}
fields={FIELDS}
legend={T.StorageOptions}
id={EXTRA_ID}
/>
</>
)
}
Storage.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
hypervisor: PropTypes.string,
control: PropTypes.object
}
/** @type {TabType} */
const TAB = {
id: 'storage',
name: T.Storage,
icon: DatastoreIcon,
Content: Storage,
getError: error => !!error?.[TAB_ID]
}
export default TAB

View File

@ -0,0 +1,53 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { object, string, array, ObjectSchema } from 'yup'
import { useDatastore } from 'client/features/One'
import { getDeployMode } from 'client/models/Datastore'
import { T, INPUT_TYPES } from 'client/constants'
import { Field, arrayToOptions, getValidationFromFields } from 'client/utils'
import { mapNameByIndex } from '../schema'
/** @returns {Field} Deploy mode field */
const TM_MAD_SYSTEM = {
name: 'TM_MAD_SYSTEM',
label: T.DeployMode,
tooltip: T.DeployModeConcept,
type: INPUT_TYPES.SELECT,
values: () => {
const datastores = useDatastore()
const modes = datastores?.map(getDeployMode)?.flat()
return arrayToOptions([...new Set(modes)], { addEmpty: 'Default' })
},
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field[]} List of Storage fields */
const FIELDS = [TM_MAD_SYSTEM]
/** @type {ObjectSchema} Storage schema */
const SCHEMA = object({
...getValidationFromFields(FIELDS),
DISK: array()
.ensure()
.transform(disks => disks.map(mapNameByIndex('DISK')))
})
export { FIELDS, SCHEMA }

View File

@ -40,10 +40,16 @@ export const MEMORY = {
/** @type {Field} Hot reloading on memory field */
export const ENABLE_HR_MEMORY = {
name: 'ENABLE_HR_MEMORY',
name: 'HOT_RESIZE.MEMORY_HOT_ADD_ENABLED',
label: T.EnableHotResize,
type: INPUT_TYPES.SWITCH,
validation: boolean().default(() => false),
validation: boolean()
.transform(value => {
if (typeof value === 'boolean') return value
return String(value).toUpperCase() === 'YES'
})
.default(() => false),
grid: { xs: 4, md: 6 }
}
@ -85,10 +91,16 @@ export const VIRTUAL_CPU = {
/** @type {Field} Hot reloading on virtual CPU field */
export const ENABLE_HR_VCPU = {
name: 'ENABLE_HR_VCPU',
name: 'HOT_RESIZE.CPU_HOT_ADD_ENABLED',
label: T.EnableHotResize,
type: INPUT_TYPES.SWITCH,
validation: boolean().default(() => false),
validation: boolean()
.transform(value => {
if (typeof value === 'boolean') return value
return String(value).toUpperCase() === 'YES'
})
.default(() => false),
grid: { xs: 4, md: 6 }
}

View File

@ -31,7 +31,7 @@ const Steps = createSteps(
...vmTemplate?.TEMPLATE,
USER_INPUTS: userInputsToArray(vmTemplate?.TEMPLATE?.USER_INPUTS)
}
}, { context: { [EXTRA_ID]: vmTemplate.TEMPLATE } })
}, { stripUnknown: true, context: { [EXTRA_ID]: vmTemplate.TEMPLATE } })
}),
transformBeforeSubmit: formData => {
const {
@ -42,22 +42,28 @@ const Steps = createSteps(
// const templateXML = jsonToXml({ ...general, ...extraTemplate })
// return { template: templateXML }
const userInputs = userInputsToObject(USER_INPUTS)
const inputsOrder = USER_INPUTS.map(({ name }) => name).join(',')
const { START_SCRIPT, ENCODE_START_SCRIPT, ...restOfContext } = CONTEXT
const context = {
...restOfContext,
// transform start script to base64 if needed
[ENCODE_START_SCRIPT ? 'START_SCRIPT_BASE64' : 'START_SCRIPT']:
ENCODE_START_SCRIPT && !isBase64(START_SCRIPT)
? btoa(unescape(encodeURIComponent(START_SCRIPT)))
: START_SCRIPT
}
const userInputs = userInputsToObject(USER_INPUTS)
const inputsOrder = USER_INPUTS.map(({ name }) => name).join(',')
// add user inputs to context
for (const { name } of USER_INPUTS) {
const upperName = String(name).toUpperCase()
context[upperName] = `$${upperName}`
}
return {
...general,
...extraTemplate,
...general,
CONTEXT: context,
USER_INPUTS: userInputs,
INPUTS_ORDER: inputsOrder

View File

@ -22,7 +22,7 @@ import { yupResolver } from '@hookform/resolvers/yup'
import { useFetch } from 'client/hooks'
// import { useUserApi, useVmGroupApi, useVmTemplateApi } from 'client/features/One'
import { useVmTemplateApi, useHostApi, useImageApi } from 'client/features/One'
import { useVmTemplateApi, useHostApi, useImageApi, useDatastoreApi } from 'client/features/One'
import FormStepper, { SkeletonStepsForm } from 'client/components/FormStepper'
import Steps from 'client/components/Forms/VmTemplate/CreateForm/Steps'
@ -52,6 +52,7 @@ const PreFetchingForm = ({ templateId, onSubmit }) => {
// const { getVmGroups } = useVmGroupApi()
const { getHosts } = useHostApi()
const { getImages } = useImageApi()
const { getDatastores } = useDatastoreApi()
const { getVmTemplate } = useVmTemplateApi()
const { fetchRequest, data } = useFetch(
() => getVmTemplate(templateId, { extended: true })
@ -61,6 +62,7 @@ const PreFetchingForm = ({ templateId, onSubmit }) => {
templateId && fetchRequest()
getHosts()
getImages()
getDatastores()
// getUsers()
// getVmGroups()
}, [])

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useState, memo, JSXElementConstructor } from 'react'
import { useState, memo, useEffect, JSXElementConstructor } from 'react'
import PropTypes from 'prop-types'
import { DEFAULT_IMAGE, IMAGE_FORMATS } from 'client/constants'
@ -46,6 +46,10 @@ const Image = memo(
...imgProps
}
useEffect(() => {
error && setError(INITIAL_STATE)
}, [src])
/** Increment retries by one in error state. */
const addRetry = () => {
setError(prev => ({ ...prev, retries: prev.retries + 1 }))

View File

@ -37,7 +37,7 @@ export default [
{
Header: 'Type',
id: 'TYPE',
accessor: row => DatastoreModel.getType(row)
accessor: row => DatastoreModel.getType(row)?.name
},
{
Header: 'Clusters IDs',

View File

@ -16,10 +16,7 @@
import * as STATES from 'client/constants/states'
import COLOR from 'client/constants/color'
/**
* @type {{name: string, shortName: string}}
* Datastore type information
*/
/** @type {{name: string, shortName: string}} Datastore type information */
export const DATASTORE_TYPES = [
{
name: 'IMAGE',

View File

@ -354,6 +354,17 @@ module.exports = {
/* VM schema - ownership */
InstantiateAsUser: 'Instantiate as different User',
InstantiateAsGroup: 'Instantiate as different Group',
/* VM Template schema - storage */
StorageOptions: 'Storage Options',
DeployMode: 'Deploy Mode',
DeployModeConcept: 'Set an alternative mode to deploy VM disks to the hosts',
/* VM Template schema - network */
NetworkDefaults: 'Network Defaults',
NetworkDefaultsConcept: `
Values that will be copied to each new NIC.
Final users may not be aware of this`,
DefaultNicModel: 'Default hardware model to emulate for all NICs',
DefaultNicFilter: 'Default network filtering rule for all NICs',
/* VM Template schema - capacity */
MaxMemory: 'Max memory',
MemoryModification: 'Memory modification',
@ -485,9 +496,16 @@ module.exports = {
ReportReadyToOneGateConcept: 'Sends READY=YES to OneGate, useful for OneFlow',
StartScript: 'Start script',
StartScriptConcept: `
Text of the script executed when the machine starts up. It can contain
shebang in case it is not shell script`,
Text of the script executed when the machine starts up. It can contain
shebang in case it is not shell script`,
EncodeScriptInBase64: 'Encode script in Base64',
ContextFiles: 'Files Datastores',
ContextFilesConcept: 'List of File images to include in context device',
InitScripts: 'Init scripts',
InitScriptsConcept: `
The contextualization package executes an init.sh file if it exists.
If more than one script file is added, this list contains the scripts
to run and their order`,
/* VM Template schema - Input/Output */
InputOrOutput: 'Input / Output',
Inputs: 'Inputs',

View File

@ -34,6 +34,21 @@ export const getType = ({ TYPE } = {}) => DATASTORE_TYPES[TYPE]
*/
export const getState = ({ STATE = 0 } = {}) => DATASTORE_STATES[STATE]
/**
* Return the TM_MAD_SYSTEM attribute.
*
* @param {object} datastore - Datastore
* @returns {string[]} - The list of deploy modes available
*/
export const getDeployMode = (datastore = {}) => {
const { TEMPLATE = {} } = datastore
const isImage = getType(datastore)?.name === DATASTORE_TYPES[0]?.name
return isImage
? TEMPLATE?.TM_MAD_SYSTEM?.split(',')?.filter(Boolean) ?? []
: []
}
/**
* Returns information about datastore capacity.
*

View File

@ -328,7 +328,7 @@ export const mapUserInputs = (userInputs = {}) =>
*
* @param {any[]} array - List of option values
* @param {object} [options] - Options to conversion
* @param {boolean} [options.addEmpty] - If `true`, add an empty option
* @param {boolean|string} [options.addEmpty] - If `true`, add an empty option
* @param {function(any):any} [options.getText] - Function to get the text option
* @param {function(any):any} [options.getValue] - Function to get the value option
* @returns {SelectOption} Options
@ -338,7 +338,11 @@ export const arrayToOptions = (array = [], options = {}) => {
const values = array.map(item => ({ text: getText(item), value: getValue(item) }))
addEmpty && values.unshift({ text: '-', value: '' })
if (addEmpty) {
typeof addEmpty === 'string'
? values.unshift({ text: addEmpty, value: '' })
: values.unshift({ text: '-', value: '' })
}
return values
}