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

F #5422: Add new sections to VM template form (#1566)

This commit is contained in:
Sergio Betanzos 2021-11-10 10:23:41 +01:00 committed by GitHub
parent 434be6e5af
commit a6744fb8eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 731 additions and 478 deletions

View File

@ -68,7 +68,7 @@ const TextController = memo(
InputProps={{
endAdornment: tooltip && <Tooltip title={tooltip} />
}}
inputProps={{ 'data-cy': cy, ...fieldProps }}
inputProps={{ 'data-cy': cy }}
error={Boolean(error)}
helperText={Boolean(error) && <ErrorHelper label={error?.message} />}
FormHelperTextProps={{ 'data-cy': `${cy}-error` }}
@ -97,14 +97,7 @@ TextController.propTypes = {
PropTypes.string,
PropTypes.arrayOf(PropTypes.string)
]),
fieldProps: PropTypes.object,
formContext: PropTypes.shape({
setValue: PropTypes.func,
setError: PropTypes.func,
clearErrors: PropTypes.func,
watch: PropTypes.func,
register: PropTypes.func
})
fieldProps: PropTypes.object
}
TextController.displayName = 'TextController'

View File

@ -54,7 +54,7 @@ const FormWithSchema = ({ id, cy, fields, rootProps, className, legend, legendTo
const getFields = useMemo(() => typeof fields === 'function' ? fields() : fields, [])
if (getFields.length === 0) return null
if (!getFields || getFields?.length === 0) return null
const addIdToName = name => name.startsWith('$')
? name.slice(1) // removes character '$' and returns

View File

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { useMemo } from 'react'
import PropTypes from 'prop-types'
import { Stack, Box } from '@mui/material'
import { Stack, FormControl } from '@mui/material'
import { SystemShut as OsIcon } from 'iconoir-react'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
@ -44,10 +44,11 @@ const Booting = ({ hypervisor, ...props }) => {
!!props.data?.[STORAGE_ID]?.length ||
!!props.data?.[NIC_ID]?.length
) && (
<Box component='fieldset' gridColumn='1/-1'>
<FormControl component='fieldset' sx={{ width: '100%', gridColumn: '1 / -1' }}>
<Legend title={T.BootOrder} tooltip={T.BootOrderConcept} />
<BootOrder {...props} />
</Box>
</FormControl>
)}
{sections.map(({ id, ...section }) => (
<FormWithSchema

View File

@ -0,0 +1,117 @@
/* ------------------------------------------------------------------------- *
* 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, boolean, ref, ObjectSchema } from 'yup'
import { T, INPUT_TYPES } from 'client/constants'
import { Field, getObjectSchemaFromFields } from 'client/utils'
const switchField = {
type: INPUT_TYPES.SWITCH,
validation: boolean()
.notRequired()
.transform(value => {
if (typeof value === 'boolean') return value
return String(value).toUpperCase() === 'YES'
}),
grid: { md: 12 }
}
/** @type {Field} SSH public key field */
export const SSH_PUBLIC_KEY = {
name: 'CONTEXT.SSH_PUBLIC_KEY',
label: T.SshPublicKey,
type: INPUT_TYPES.TEXT,
multiline: true,
validation: string()
.trim()
.notRequired(),
grid: { md: 12 },
fieldProps: { rows: 4 }
}
/** @type {Field} Network context field */
const NETWORK = {
name: 'CONTEXT.NETWORK',
label: T.AddNetworkContextualization,
tooltip: T.AddNetworkContextualizationConcept,
...switchField
}
/** @type {Field} Token OneGate token field */
const TOKEN = {
name: 'CONTEXT.TOKEN',
label: T.AddOneGateToken,
tooltip: T.AddOneGateTokenConcept,
...switchField
}
/** @type {Field} Report READY to OneGate field */
const REPORT_READY = {
name: 'CONTEXT.REPORT_READY',
label: T.ReportReadyToOneGate,
tooltip: T.ReportReadyToOneGateConcept,
...switchField
}
/** @type {Field} Encode start script field */
export const ENCODE_START_SCRIPT = {
name: 'CONTEXT.ENCODE_START_SCRIPT',
label: T.EncodeScriptInBase64,
...switchField,
validation: boolean()
.transform(value => Boolean(value))
.default(() => ref('$extra.CONTEXT.START_SCRIPT_BASE64'))
}
/** @type {Field} Start script field */
export const START_SCRIPT = {
name: 'CONTEXT.START_SCRIPT',
label: T.StartScript,
tooltip: T.StartScriptConcept,
type: INPUT_TYPES.TEXT,
multiline: true,
validation: string()
.trim()
.notRequired()
.when(
'$extra.CONTEXT.START_SCRIPT_BASE64',
(scriptEncoded, schema) => scriptEncoded
? schema.default(() => decodeURIComponent(escape(atob(scriptEncoded))))
: schema
),
grid: { md: 12 },
fieldProps: { rows: 4 }
}
/** @type {Field} Start script in base64 field */
export const START_SCRIPT_BASE64 = {
name: 'CONTEXT.START_SCRIPT_BASE64',
validation: string().strip()
}
export const SCRIPT_FIELDS = [START_SCRIPT, ENCODE_START_SCRIPT]
/** @type {Field[]} List of other fields */
export const OTHER_FIELDS = [NETWORK, TOKEN, REPORT_READY]
/** @type {ObjectSchema} User context configuration schema */
export const CONFIGURATION_SCHEMA = getObjectSchemaFromFields([
SSH_PUBLIC_KEY,
START_SCRIPT_BASE64,
...SCRIPT_FIELDS,
...OTHER_FIELDS
])

View File

@ -0,0 +1,82 @@
/* ------------------------------------------------------------------------- *
* 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 { Stack, FormControl, Button } from '@mui/material'
import { useFormContext } from 'react-hook-form'
import { FormWithSchema, Legend } from 'client/components/Forms'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
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 handleClearKey = () => setValue(SSH_PUBLIC_KEY_PATH)
const handleAddUserKey = () => {
const currentSshPublicKey = getValues(SSH_PUBLIC_KEY_PATH) ?? ''
setValue(SSH_PUBLIC_KEY_PATH, currentSshPublicKey + `\n${SSH_KEY_USER}`)
}
return (
<FormControl component='fieldset' sx={{ width: '100%' }}>
<Legend title={T.Configuration} />
<Stack
display='grid'
gap='1em'
sx={{ gridTemplateColumns: { sm: '1fr', md: '1fr 1fr' } }}
>
<FormWithSchema
cy={`create-vm-template-${EXTRA_ID}.context-configuration-others`}
fields={OTHER_FIELDS}
id={EXTRA_ID}
/>
<div>
<FormWithSchema
cy={`create-vm-template-${EXTRA_ID}.context-ssh-public-key`}
fields={[SSH_PUBLIC_KEY]}
id={EXTRA_ID}
/>
<Stack direction='row' gap='1em'>
<Button onClick={handleAddUserKey} variant='contained'>
{T.AddUserSshPublicKey}
</Button>
<Button onClick={handleClearKey} variant='outlined'>
{T.Clear}
</Button>
</Stack>
</div>
<FormWithSchema
cy={`create-vm-template-${EXTRA_ID}.context-script`}
fields={SCRIPT_FIELDS}
id={EXTRA_ID}
rootProps={{ sx: { width: '100%', gridColumn: '1 / -1' } }}
/>
</Stack>
</FormControl>
)
}
export default ConfigurationSection

View File

@ -13,177 +13,23 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { forwardRef } from 'react'
import PropTypes from 'prop-types'
import {
Folder as ContextIcon,
WarningCircledOutline as WarningIcon,
DeleteCircledOutline,
AddCircledOutline
} from 'iconoir-react'
import {
styled,
FormControl,
Stack,
IconButton,
Button,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText
} from '@mui/material'
import { Folder as ContextIcon } from 'iconoir-react'
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'
import { useFieldArray, useForm, FormProvider, useFormContext, get } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
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 { Tooltip } from 'client/components/FormControl'
import { FormWithSchema, Legend } from 'client/components/Forms'
import { Translate } from 'client/components/HOC'
import { STEP_ID as EXTRA_ID, TabType } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { FIELDS, USER_INPUT_SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/schema'
import { getUserInputString } from 'client/models/Helper'
import { T } from 'client/constants'
export const TAB_ID = 'USER_INPUTS'
const UserItemDraggable = styled(ListItem)(({ theme }) => ({
'&:before': {
content: "''",
display: 'block',
width: 16,
height: 10,
background: `linear-gradient(
to bottom,
${theme.palette.action.active} 4px,
transparent 4px,
transparent 6px,
${theme.palette.action.active} 6px
)`
}
}))
const UserInputItem = forwardRef(({
removeAction,
error,
userInput: { name, ...ui } = {},
...props
}, ref) => (
<UserItemDraggable
ref={ref}
secondaryAction={
<IconButton onClick={removeAction}>
<DeleteCircledOutline />
</IconButton>
}
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
{...props}
>
{!!error && (
<ListItemIcon sx={{ '& svg': { color: 'error.dark' } }}>
<Tooltip title={error?.default.message}>
<WarningIcon />
</Tooltip>
</ListItemIcon>
)}
<ListItemText
inset={!error}
primary={name}
primaryTypographyProps={{ variant: 'body1' }}
secondary={getUserInputString(ui)}
/>
</UserItemDraggable>
))
UserInputItem.propTypes = {
removeAction: PropTypes.func,
error: PropTypes.object,
userInput: PropTypes.object
}
UserInputItem.displayName = 'UserInputItem'
export const TAB_ID = [CONFIGURATION_ID, USER_INPUTS_ID]
const Context = () => {
const { formState: { errors } } = useFormContext()
const { fields: userInputs, append, remove, move } = useFieldArray({
name: `${EXTRA_ID}.${TAB_ID}`
})
const methods = useForm({
defaultValues: USER_INPUT_SCHEMA.default(),
resolver: yupResolver(USER_INPUT_SCHEMA)
})
const onSubmit = newInput => {
append(newInput)
methods.reset()
}
/** @param {DropResult} result - Drop result */
const onDragEnd = result => {
const { destination, source } = result ?? {}
if (destination && destination.index !== source.index) {
move(source.index, destination.index)
}
}
return (
<FormControl component='fieldset' sx={{ width: '100%' }}>
<Legend title={T.UserInputs} tooltip={T.UserInputsConcept} />
<FormProvider {...methods}>
<Stack
direction='row' alignItems='flex-start' gap='0.5rem'
component='form'
onSubmit={methods.handleSubmit(onSubmit)}
>
<FormWithSchema
cy={`create-vm-template-${EXTRA_ID}.context-user-input`}
fields={FIELDS}
rootProps={{ sx: { m: 0 } }}
/>
<Button
variant='outlined'
type='submit'
startIcon={<AddCircledOutline />}
sx={{ mt: '1em' }}
>
<Translate word={T.Add} />
</Button>
</Stack>
</FormProvider>
<Divider />
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId='context'>
{({ droppableProps, innerRef: outerRef, placeholder }) => (
<List ref={outerRef} {...droppableProps}>
{userInputs?.map(({ id, ...userInput }, index) => (
<Draggable
key={`ui[${index}]`}
draggableId={`ui-${index}`}
index={index}
>
{({ draggableProps, dragHandleProps, innerRef }) => (
<UserInputItem
key={id}
ref={innerRef}
userInput={userInput}
error={get(errors, `${EXTRA_ID}.${TAB_ID}.${index}`)}
removeAction={() => remove(index)}
{...draggableProps}
{...dragHandleProps}
/>
)}
</Draggable>
))}
{placeholder}
</List>
)}
</Droppable>
</DragDropContext>
</FormControl>
<>
<ConfigurationSection />
<UserInputsSection />
</>
)
}
@ -200,7 +46,7 @@ const TAB = {
name: T.Context,
icon: ContextIcon,
Content: Context,
getError: error => !!error?.[TAB_ID]
getError: error => TAB_ID.some(id => error?.[id])
}
export default TAB

View File

@ -13,161 +13,14 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { array, string, boolean, number, ref } from 'yup'
import { object } from 'yup'
import { UserInputType, T, INPUT_TYPES, USER_INPUT_TYPES } from 'client/constants'
import { Field, arrayToOptions, sentenceCase, getObjectSchemaFromFields } from 'client/utils'
import { USER_INPUTS_SCHEMA } from './userInputsSchema'
import { CONFIGURATION_SCHEMA } from './configurationSchema'
const {
password: uiPassword,
list: uiList,
listMultiple: uiListMultiple,
number: uiNumber,
numberFloat: uiNumberFloat,
range: uiRange,
rangeFloat: uiRangeFloat,
boolean: uiBoolean
} = USER_INPUT_TYPES
export const SCHEMA = object()
.concat(CONFIGURATION_SCHEMA)
.concat(USER_INPUTS_SCHEMA)
const { array: _, ...userInputTypes } = USER_INPUT_TYPES
/** @type {UserInputType[]} User inputs types */
const valuesOfUITypes = Object.values(userInputTypes)
/** @type {Field} Type field */
export const TYPE = {
name: 'type',
label: T.Type,
type: INPUT_TYPES.SELECT,
values: arrayToOptions(valuesOfUITypes, {
addEmpty: false,
getText: type => sentenceCase(type)
}),
validation: string()
.trim()
.required()
.oneOf(valuesOfUITypes)
.default(() => valuesOfUITypes[0]),
grid: { sm: 6, md: 4 }
}
/** @type {Field} Name field */
export const NAME = {
name: 'name',
label: T.Name,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required()
.default(() => undefined),
grid: { sm: 6, md: 4 }
}
/** @type {Field} Description field */
export const DESCRIPTION = {
name: 'description',
label: T.Description,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { sm: 6, md: 4 }
}
/** @type {Field} Options field */
const OPTIONS = {
name: 'options',
label: T.Options,
tooltip: 'Press ENTER key to add a value',
dependOf: TYPE.name,
type: INPUT_TYPES.AUTOCOMPLETE,
multiple: true,
htmlType: type =>
![uiList, uiListMultiple].includes(type) && INPUT_TYPES.HIDDEN,
validation: array(string().trim())
.default(() => [])
.when(
TYPE.name,
(type, schema) => [uiList, uiListMultiple].includes(type)
? schema.required()
: schema.strip().notRequired()
),
fieldProps: {
freeSolo: true,
placeholder: 'optA,optB,optC'
},
grid: { md: 8 }
}
/** @type {{ MIN: Field, MAX: Field }} Range fields */
const { MIN, MAX } = (() => {
const validation = number()
.positive()
.default(() => undefined)
.when(TYPE.name, (type, schema) => [uiRange, uiRangeFloat].includes(type)
? schema.required()
: schema.strip().notRequired()
)
const common = {
dependOf: TYPE.name,
type: INPUT_TYPES.TEXT,
htmlType: type =>
[uiRange, uiRangeFloat].includes(type) ? 'number' : INPUT_TYPES.HIDDEN,
grid: { sm: 6, md: 4 },
fieldProps: type => ({ step: type === uiRangeFloat ? 0.01 : 1 })
}
return {
MIN: { ...common, name: 'min', label: T.Min, validation: validation.lessThan(ref('max')) },
MAX: { ...common, name: 'max', label: T.Max, validation: validation.moreThan(ref('min')) }
}
})()
/** @type {Field} Default value field */
const DEFAULT_VALUE = {
name: 'default',
label: T.DefaultValue,
dependOf: [TYPE.name, OPTIONS.name],
type: ([type] = []) => [uiBoolean, uiList, uiListMultiple].includes(type)
? INPUT_TYPES.SELECT
: INPUT_TYPES.TEXT,
htmlType: ([type] = []) => ({
[uiNumber]: 'number',
[uiNumberFloat]: 'number',
[uiPassword]: INPUT_TYPES.HIDDEN
}[type]),
multiple: ([type] = []) => type === uiListMultiple,
values: ([type, options = []] = []) => type === uiBoolean
? arrayToOptions(['NO', 'YES'])
: arrayToOptions(options),
validation: string()
.trim()
.default(() => undefined)
.when([TYPE.name, OPTIONS.name], (type, options = [], schema) => {
return {
[uiList]: schema.oneOf(options).notRequired(),
[uiListMultiple]: schema.includesInOptions(options),
[uiRange]: number().min(ref(MIN.name)).max(ref(MAX.name)).integer(),
[uiRangeFloat]: number().min(ref(MIN.name)).max(ref(MAX.name)),
[uiPassword]: schema.strip().notRequired()
}[type] ?? schema
}),
grid: { sm: 6, md: 4 }
}
/** @type {Field} Mandatory field */
const MANDATORY = {
name: 'mandatory',
label: T.Mandatory,
type: INPUT_TYPES.SWITCH,
validation: boolean().default(() => false),
grid: { md: 12 }
}
export const FIELDS = [TYPE, NAME, DESCRIPTION, DEFAULT_VALUE, OPTIONS, MIN, MAX, MANDATORY]
export const USER_INPUT_SCHEMA = getObjectSchemaFromFields(FIELDS)
export const SCHEMA = array(USER_INPUT_SCHEMA).ensure()
export * from './userInputsSchema'
export * from './configurationSchema'

View File

@ -0,0 +1,187 @@
/* ------------------------------------------------------------------------- *
* 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, array, string, boolean, number, ref, ObjectSchema } from 'yup'
import { UserInputType, T, INPUT_TYPES, USER_INPUT_TYPES } from 'client/constants'
import { Field, arrayToOptions, sentenceCase, getObjectSchemaFromFields } from 'client/utils'
const {
password: uiPassword,
list: uiList,
listMultiple: uiListMultiple,
number: uiNumber,
numberFloat: uiNumberFloat,
range: uiRange,
rangeFloat: uiRangeFloat,
boolean: uiBoolean
} = USER_INPUT_TYPES
const { array: _, ...userInputTypes } = USER_INPUT_TYPES
/** @type {UserInputType[]} User inputs types */
const valuesOfUITypes = Object.values(userInputTypes)
/** @type {Field} Type field */
const TYPE = {
name: 'type',
label: T.Type,
type: INPUT_TYPES.SELECT,
values: arrayToOptions(valuesOfUITypes, {
addEmpty: false,
getText: type => sentenceCase(type)
}),
validation: string()
.trim()
.required()
.oneOf(valuesOfUITypes)
.default(() => valuesOfUITypes[0]),
grid: { sm: 6, md: 4 }
}
/** @type {Field} Name field */
const NAME = {
name: 'name',
label: T.Name,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required()
.default(() => undefined),
grid: { sm: 6, md: 4 }
}
/** @type {Field} Description field */
const DESCRIPTION = {
name: 'description',
label: T.Description,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { sm: 6, md: 4 }
}
/** @type {Field} Options field */
const OPTIONS = {
name: 'options',
label: T.Options,
tooltip: 'Press ENTER key to add a value',
dependOf: TYPE.name,
type: INPUT_TYPES.AUTOCOMPLETE,
multiple: true,
htmlType: type =>
![uiList, uiListMultiple].includes(type) && INPUT_TYPES.HIDDEN,
validation: array(string().trim())
.default(() => [])
.when(
TYPE.name,
(type, schema) => [uiList, uiListMultiple].includes(type)
? schema.required()
: schema.strip().notRequired()
),
fieldProps: {
freeSolo: true,
placeholder: 'optA,optB,optC'
},
grid: { md: 8 }
}
/** @type {{ MIN: Field, MAX: Field }} Range fields */
const { MIN, MAX } = (() => {
const validation = number()
.positive()
.default(() => undefined)
.when(TYPE.name, (type, schema) => [uiRange, uiRangeFloat].includes(type)
? schema.required()
: schema.strip().notRequired()
)
const common = {
dependOf: TYPE.name,
type: INPUT_TYPES.TEXT,
htmlType: type =>
[uiRange, uiRangeFloat].includes(type) ? 'number' : INPUT_TYPES.HIDDEN,
grid: { sm: 6, md: 4 },
fieldProps: type => ({ step: type === uiRangeFloat ? 0.01 : 1 })
}
return {
MIN: { ...common, name: 'min', label: T.Min, validation: validation.lessThan(ref('max')) },
MAX: { ...common, name: 'max', label: T.Max, validation: validation.moreThan(ref('min')) }
}
})()
/** @type {Field} Default value field */
const DEFAULT_VALUE = {
name: 'default',
label: T.DefaultValue,
dependOf: [TYPE.name, OPTIONS.name],
type: ([type] = []) => [uiBoolean, uiList, uiListMultiple].includes(type)
? INPUT_TYPES.SELECT
: INPUT_TYPES.TEXT,
htmlType: ([type] = []) => ({
[uiNumber]: 'number',
[uiNumberFloat]: 'number',
[uiPassword]: INPUT_TYPES.HIDDEN
}[type]),
multiple: ([type] = []) => type === uiListMultiple,
values: ([type, options = []] = []) => type === uiBoolean
? arrayToOptions(['NO', 'YES'])
: arrayToOptions(options),
validation: string()
.trim()
.default(() => undefined)
.when([TYPE.name, OPTIONS.name], (type, options = [], schema) => {
return {
[uiList]: schema.oneOf(options).notRequired(),
[uiListMultiple]: schema.includesInOptions(options),
[uiRange]: number().min(ref(MIN.name)).max(ref(MAX.name)).integer(),
[uiRangeFloat]: number().min(ref(MIN.name)).max(ref(MAX.name)),
[uiPassword]: schema.strip().notRequired()
}[type] ?? schema
}),
grid: { sm: 6, md: 4 }
}
/** @type {Field} Mandatory field */
const MANDATORY = {
name: 'mandatory',
label: T.Mandatory,
type: INPUT_TYPES.SWITCH,
validation: boolean().default(() => false),
grid: { md: 12 }
}
/** @type {Field[]} List of User Inputs fields */
export const USER_INPUT_FIELDS = [
TYPE,
NAME,
DESCRIPTION,
DEFAULT_VALUE,
OPTIONS,
MIN,
MAX,
MANDATORY
]
/** @type {ObjectSchema} User Input object schema */
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()
})

View File

@ -0,0 +1,191 @@
/* ------------------------------------------------------------------------- *
* 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 { forwardRef, JSXElementConstructor } from 'react'
import PropTypes from 'prop-types'
import {
WarningCircledOutline as WarningIcon,
DeleteCircledOutline,
AddCircledOutline
} from 'iconoir-react'
import {
styled,
FormControl,
Stack,
IconButton,
Button,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText
} from '@mui/material'
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'
import { useFieldArray, useForm, FormProvider, useFormContext, get } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import { Tooltip } from 'client/components/FormControl'
import { FormWithSchema, Legend } from 'client/components/Forms'
import { Translate } from 'client/components/HOC'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { USER_INPUT_SCHEMA, USER_INPUT_FIELDS } from './schema'
import { getUserInputString } from 'client/models/Helper'
import { T } from 'client/constants'
export const SECTION_ID = 'USER_INPUTS'
const UserItemDraggable = styled(ListItem)(({ theme }) => ({
'&:before': {
content: "''",
display: 'block',
width: 16,
height: 10,
background: `linear-gradient(
to bottom,
${theme.palette.action.active} 4px,
transparent 4px,
transparent 6px,
${theme.palette.action.active} 6px
)`
}
}))
const UserInputItem = forwardRef(({
removeAction,
error,
userInput: { name, ...ui } = {},
...props
}, ref) => (
<UserItemDraggable
ref={ref}
secondaryAction={
<IconButton onClick={removeAction}>
<DeleteCircledOutline />
</IconButton>
}
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
{...props}
>
{!!error && (
<ListItemIcon sx={{ '& svg': { color: 'error.dark' } }}>
<Tooltip title={error?.default.message}>
<WarningIcon />
</Tooltip>
</ListItemIcon>
)}
<ListItemText
inset={!error}
primary={name}
primaryTypographyProps={{ variant: 'body1' }}
secondary={getUserInputString(ui)}
/>
</UserItemDraggable>
))
UserInputItem.propTypes = {
removeAction: PropTypes.func,
error: PropTypes.object,
userInput: PropTypes.object
}
UserInputItem.displayName = 'UserInputItem'
/** @returns {JSXElementConstructor} - User Inputs section */
const UserInputsSection = () => {
const { formState: { errors } } = useFormContext()
const { fields: userInputs, append, remove, move } = useFieldArray({
name: `${EXTRA_ID}.${SECTION_ID}`
})
const methods = useForm({
defaultValues: USER_INPUT_SCHEMA.default(),
resolver: yupResolver(USER_INPUT_SCHEMA)
})
const onSubmit = newInput => {
append(newInput)
methods.reset()
}
/** @param {DropResult} result - Drop result */
const onDragEnd = result => {
const { destination, source } = result ?? {}
if (destination && destination.index !== source.index) {
move(source.index, destination.index)
}
}
return (
<FormControl component='fieldset' sx={{ width: '100%' }}>
<Legend title={T.UserInputs} tooltip={T.UserInputsConcept} />
<FormProvider {...methods}>
<Stack
direction='row' alignItems='flex-start' gap='0.5rem'
component='form'
onSubmit={methods.handleSubmit(onSubmit)}
>
<FormWithSchema
cy={`create-vm-template-${EXTRA_ID}.context-user-input`}
fields={USER_INPUT_FIELDS}
rootProps={{ sx: { m: 0 } }}
/>
<Button
variant='contained'
type='submit'
color='secondary'
startIcon={<AddCircledOutline />}
sx={{ mt: '1em' }}
>
<Translate word={T.Add} />
</Button>
</Stack>
</FormProvider>
<Divider />
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId='context'>
{({ droppableProps, innerRef: outerRef, placeholder }) => (
<List ref={outerRef} {...droppableProps}>
{userInputs?.map(({ id, ...userInput }, index) => (
<Draggable
key={`ui[${index}]`}
draggableId={`ui-${index}`}
index={index}
>
{({ draggableProps, dragHandleProps, innerRef }) => (
<UserInputItem
key={id}
ref={innerRef}
userInput={userInput}
error={get(errors, `${EXTRA_ID}.${SECTION_ID}.${index}`)}
removeAction={() => remove(index)}
{...draggableProps}
{...dragHandleProps}
/>
)}
</Draggable>
))}
{placeholder}
</List>
)}
</Droppable>
</DragDropContext>
</FormControl>
)
}
export default UserInputsSection

View File

@ -67,8 +67,9 @@ const InputsSection = ({ fields }) => {
rootProps={{ sx: { m: 0 } }}
/>
<Button
variant='outlined'
variant='contained'
type='submit'
color='secondary'
startIcon={<AddCircledOutline />}
sx={{ mt: '1em' }}
>

View File

@ -13,11 +13,11 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { string, array, object, ObjectSchema, ArraySchema } from 'yup'
import { string, array, ObjectSchema, ArraySchema } from 'yup'
import { useHost } from 'client/features/One'
import { getPciDevices } from 'client/models/Host'
import { Field, arrayToOptions, filterFieldsByHypervisor, getValidationFromFields } from 'client/utils'
import { Field, arrayToOptions, filterFieldsByHypervisor, getObjectSchemaFromFields } from 'client/utils'
import { T, INPUT_TYPES, HYPERVISORS } from 'client/constants'
const { vcenter, lxc, firecracker } = HYPERVISORS
@ -86,7 +86,7 @@ export const PCI_FIELDS = (hypervisor) =>
filterFieldsByHypervisor([DEVICE_NAME, DEVICE, VENDOR, CLASS], hypervisor)
/** @type {ObjectSchema} PCI devices object schema */
export const PCI_SCHEMA = object(getValidationFromFields([DEVICE, VENDOR, CLASS]))
export const PCI_SCHEMA = getObjectSchemaFromFields([DEVICE, VENDOR, CLASS])
/** @type {ArraySchema} PCI devices schema */
export const PCI_DEVICES_SCHEMA = array(PCI_SCHEMA).ensure()

View File

@ -72,8 +72,9 @@ const PciDevicesSection = ({ fields }) => {
rootProps={{ sx: { m: 0 } }}
/>
<Button
variant='outlined'
variant='contained'
type='submit'
color='secondary'
startIcon={<AddCircledOutline />}
sx={{ mt: '1em' }}
>
@ -84,8 +85,8 @@ const PciDevicesSection = ({ fields }) => {
<Divider />
<List>
{pciDevices?.map(({ id, DEVICE, VENDOR, CLASS }, index) => {
const deviceName = pciDevicesAvailable
.find(pciDevice => pciDevice?.DEVICE === DEVICE)?.DEVICE_NAME
const { DEVICE_NAME, VENDOR_NAME } = pciDevicesAvailable
.find(pciDevice => pciDevice?.DEVICE === DEVICE) ?? {}
return (
<ListItem
@ -98,11 +99,11 @@ const PciDevicesSection = ({ fields }) => {
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
>
<ListItemText
primary={deviceName}
primary={DEVICE_NAME}
primaryTypographyProps={{ variant: 'body1' }}
secondary={[
`#${DEVICE}`,
`Vendor: ${VENDOR}`,
`Vendor: ${VENDOR_NAME}(${VENDOR})`,
`Class: ${CLASS}`].join(' | ')}
/>
</ListItem>

View File

@ -45,9 +45,9 @@ export const SCHED_ACTION_SCHEMA = array()
export const SCHEMA = hypervisor => object({
DISK: DISK_SCHEMA,
NIC: NIC_SCHEMA,
SCHED_ACTION: SCHED_ACTION_SCHEMA,
USER_INPUTS: CONTEXT_SCHEMA
SCHED_ACTION: SCHED_ACTION_SCHEMA
})
.concat(CONTEXT_SCHEMA)
.concat(IO_SCHEMA(hypervisor))
.concat(getObjectSchemaFromFields([
...PLACEMENT_FIELDS,

View File

@ -17,7 +17,7 @@ import General, { STEP_ID as GENERAL_ID } from 'client/components/Forms/VmTempla
import ExtraConfiguration, { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
// import { jsonToXml } from 'client/models/Helper'
import { userInputsToArray, userInputsToObject } from 'client/models/Helper'
import { createSteps } from 'client/utils'
import { createSteps, isBase64 } from 'client/utils'
const Steps = createSteps(
[General, ExtraConfiguration],
@ -36,18 +36,29 @@ const Steps = createSteps(
transformBeforeSubmit: formData => {
const {
[GENERAL_ID]: general = {},
[EXTRA_ID]: { USER_INPUTS, ...extraTemplate } = {}
[EXTRA_ID]: { USER_INPUTS, CONTEXT, ...extraTemplate } = {}
} = formData ?? {}
// const templateXML = jsonToXml({ ...general, ...extraTemplate })
// return { template: templateXML }
const { START_SCRIPT, ENCODE_START_SCRIPT, ...restOfContext } = CONTEXT
const context = {
...restOfContext,
[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(',')
return {
...general,
...extraTemplate,
CONTEXT: context,
USER_INPUTS: userInputs,
INPUTS_ORDER: inputsOrder
}

View File

@ -55,7 +55,7 @@ const User = () => {
color='secondary'
href={`${APP_URL}/${appName}`}
>
<DevTypography label={appName} />
<DevTypography>{appName}</DevTypography>
</Link>
</MenuItem>
))

View File

@ -37,7 +37,7 @@ import { useGeneral } from 'client/features/General'
import SidebarLink from 'client/components/Sidebar/SidebarLink'
import sidebarStyles from 'client/components/Sidebar/styles'
const SidebarCollapseItem = ({ label, routes, icon: Icon }) => {
const SidebarCollapseItem = ({ label = '', routes = [], icon: Icon }) => {
const classes = sidebarStyles()
const { pathname } = useLocation()
const { isFixMenu } = useGeneral()
@ -63,29 +63,26 @@ const SidebarCollapseItem = ({ label, routes, icon: Icon }) => {
</ListItemIcon>
)}
<ListItemText
className={classes.itemText}
data-max-label={label}
data-cy={label}
data-min-label={label.slice(0, 3)}
primary={label}
primaryTypographyProps={{ variant: 'body1' }}
/>
{expanded ? <CollapseIcon/> : <ExpandMoreIcon />}
</ListItemButton>
{routes
?.filter(({ sidebar = false, label }) => sidebar && typeof label === 'string')
?.map((subItem, index) => (
<Collapse
key={`subitem-${index}`}
in={expanded}
timeout='auto'
unmountOnExit
className={clsx({ [classes.subItemWrapper]: isUpLg && !isFixMenu })}
>
<List component='div'>
<SidebarLink {...subItem} isSubItem />
</List>
</Collapse>
))
}
<Collapse
in={expanded}
timeout='auto'
unmountOnExit
className={clsx({ [classes.subItemWrapper]: isUpLg && !isFixMenu })}
>
<List component='div' disablePadding>
{routes
?.filter(({ sidebar = false, label }) => sidebar && typeof label === 'string')
?.map((subItem, index) => (
<SidebarLink key={`subitem-${index}`} isSubItem {...subItem} />
))}
</List>
</Collapse>
</>
)
}
@ -107,10 +104,4 @@ SidebarCollapseItem.propTypes = {
)
}
SidebarCollapseItem.defaultProps = {
label: '',
icon: null,
routes: []
}
export default SidebarCollapseItem

View File

@ -14,6 +14,7 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { memo } from 'react'
import PropTypes from 'prop-types'
import { useHistory, useLocation } from 'react-router-dom'
@ -25,17 +26,19 @@ import {
} from '@mui/material'
import { useGeneralApi } from 'client/features/General'
import sidebarStyles from 'client/components/Sidebar/styles'
import { DevTypography } from 'client/components/Typography'
const STATIC_LABEL_PROPS = {
'data-cy': 'main-menu-item-text',
variant: 'body1'
}
const SidebarLink = ({ label, path, icon: Icon, devMode, isSubItem }) => {
const classes = sidebarStyles()
const isUpLg = useMediaQuery(theme => theme.breakpoints.up('lg'), { noSsr: true })
const SidebarLink = memo(({
label = '',
path = '/',
icon: Icon,
devMode = false,
isSubItem = false
}) => {
const isUpLg = useMediaQuery(
theme => theme.breakpoints.up('lg'),
{ noSsr: true }
)
const history = useHistory()
const { pathname } = useLocation()
@ -48,10 +51,10 @@ const SidebarLink = ({ label, path, icon: Icon, devMode, isSubItem }) => {
return (
<ListItemButton
data-cy='main-menu-item'
onClick={handleClick}
selected={pathname === path}
className={isSubItem && classes.subItem}
data-cy='main-menu-item'
{...(isSubItem && { sx: { pl: 4 } })}
>
{Icon && (
<ListItemIcon>
@ -59,41 +62,25 @@ const SidebarLink = ({ label, path, icon: Icon, devMode, isSubItem }) => {
</ListItemIcon>
)}
<ListItemText
disableTypography={devMode}
primaryTypographyProps={STATIC_LABEL_PROPS}
primary={
devMode ? (
<DevTypography label={label} labelProps={STATIC_LABEL_PROPS}/>
) : label
}
primary={label}
primaryTypographyProps={{
...(devMode && { component: DevTypography }),
'data-cy': 'main-menu-item-text',
variant: 'body1'
}}
/>
</ListItemButton>
)
}
})
SidebarLink.propTypes = {
label: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
icon: PropTypes.oneOfType([
PropTypes.element,
PropTypes.node,
PropTypes.func,
PropTypes.string,
PropTypes.symbol,
PropTypes.object
]),
icon: PropTypes.any,
devMode: PropTypes.bool,
isSubItem: PropTypes.bool
}
SidebarLink.defaultProps = {
label: '',
path: '/',
icon: undefined,
devMode: false,
isSubItem: false
}
SidebarLink.displayName = 'SidebarLink'
export default SidebarLink

View File

@ -66,7 +66,6 @@ const Sidebar = ({ endpoints }) => {
return (
<Drawer
variant='permanent'
className={clsx({ [classes.drawerFixed]: isFixMenu })}
classes={{
paper: clsx(classes.drawerPaper, {
[classes.drawerFixed]: isFixMenu
@ -81,7 +80,7 @@ const Sidebar = ({ endpoints }) => {
width='100%'
height={50}
withText
className={classes.svg}
className={classes.logo}
disabledBetaText
/>
{!isUpLg || isFixMenu ? (
@ -96,7 +95,7 @@ const Sidebar = ({ endpoints }) => {
</Box>
<Divider />
<Box className={classes.menu}>
<List className={classes.list} data-cy='main-menu'>
<List data-cy='main-menu'>
{SidebarEndpoints}
</List>
</Box>

View File

@ -54,9 +54,6 @@ export default makeStyles(theme => ({
},
'& $subItemWrapper': {
display: 'none'
},
'& $itemText::before': {
content: 'attr(data-min-label)'
}
}
}
@ -78,9 +75,6 @@ export default makeStyles(theme => ({
},
'& $subItemWrapper': {
display: 'block !important'
},
'& $itemText::before': {
content: 'attr(data-max-label) !important'
}
}
},
@ -104,7 +98,7 @@ export default makeStyles(theme => ({
minHeight: toolbar.sm
}
},
svg: {
logo: {
minWidth: 100
},
// -------------------------------
@ -114,11 +108,7 @@ export default makeStyles(theme => ({
overflowY: 'auto',
overflowX: 'hidden',
textTransform: 'capitalize',
color: 'transparent',
transition: 'color 0.3s',
'&:hover': {
color: theme.palette.primary.light
},
'&::-webkit-scrollbar': {
width: 14
},
@ -130,19 +120,5 @@ export default makeStyles(theme => ({
color: theme.palette.secondary.light
}
},
list: {
color: theme.palette.text.primary
},
itemText: {
'&::before': {
...theme.typography.body1,
display: 'block',
minWidth: 100,
content: 'attr(data-max-label)'
}
},
subItemWrapper: {},
subItem: {
paddingLeft: theme.spacing(4)
}
subItemWrapper: {}
}))

View File

@ -50,7 +50,7 @@ const GlobalSelectedRows = ({ withAlert = false, useTableProps }) => {
const allSelected = numberOfRowSelected === preFilteredRows.length
return withAlert ? (
<MessageStyled icon={false} severity='debug' variant='outlined'>
<MessageStyled icon={false} severity='info' variant='outlined'>
<span>
<Translate word={T.NumberOfResourcesSelected} values={numberOfRowSelected} />{'.'}
</span>

View File

@ -42,9 +42,7 @@ const Accordion = styled(props => (
'&:before': { display: 'none' }
}))
const Summary = styled(props => (
<AccordionSummary expandIcon={<ExpandIcon />} {...props} />
))(({
const Summary = styled(AccordionSummary)(({
[`&.${accordionSummaryClasses.root}`]: {
backgroundColor: 'rgba(0, 0, 0, .03)',
flexDirection: 'row-reverse',
@ -90,7 +88,8 @@ const NetworkItem = ({ nic = {}, actions }) => {
const { detachNic } = useVmApi()
const { handleRefetch, data: vm } = useContext(TabContext)
const { NIC_ID, NETWORK = '-', IP, MAC, PCI_ID, ALIAS, SECURITY_GROUPS } = nic
const { NIC_ID, NETWORK = '-', IP, MAC, PCI_ID, ADDRESS, ALIAS, SECURITY_GROUPS } = nic
const isPciDevice = PCI_ID !== undefined
const hasDetails = useMemo(
() => !!ALIAS.length || !!SECURITY_GROUPS?.length,
@ -117,7 +116,7 @@ const NetworkItem = ({ nic = {}, actions }) => {
return (
<>
<Accordion>
<Summary>
<Summary {...(hasDetails && { expandIcon: <ExpandIcon /> })}>
<Row>
<Typography noWrap>
{`${NIC_ID} | ${NETWORK}`}
@ -126,10 +125,10 @@ const NetworkItem = ({ nic = {}, actions }) => {
<MultipleTags
clipboard
limitTags={isMobile ? 1 : 3}
tags={[IP, MAC, PCI_ID].filter(Boolean)}
tags={[IP, MAC, ADDRESS].filter(Boolean)}
/>
</Labels>
{!isMobile && detachAction(NIC_ID)}
{!isMobile && !isPciDevice && detachAction(NIC_ID)}
</Row>
</Summary>
{hasDetails && (
@ -146,7 +145,7 @@ const NetworkItem = ({ nic = {}, actions }) => {
tags={[IP, MAC, BRIDGE && `BRIDGE - ${BRIDGE}`].filter(Boolean)}
/>
</Labels>
{!isMobile && detachAction(NIC_ID, true)}
{!isMobile && !isPciDevice && detachAction(NIC_ID, true)}
</Row>
))}
{!!SECURITY_GROUPS?.length && (

View File

@ -26,8 +26,8 @@ const NetworkList = ({ nics, actions }) => (
paddingBlock: '0.8em'
}}>
{nics.map(nic => {
const { IP, MAC } = nic
const key = IP ?? MAC
const { IP, MAC, ADDRESS } = nic
const key = IP ?? MAC ?? ADDRESS // address only exists form PCI nics
return <NetworkItem key={key} actions={actions} nic={nic} />
})}

View File

@ -18,7 +18,12 @@ import PropTypes from 'prop-types'
import { Typography, Chip, Box } from '@mui/material'
const DevTypography = memo(({ label, labelProps, color, chipProps }) => (
const DevTypography = memo(({
labelProps = {},
color = 'secondary',
chipProps = {},
children = ''
}) => (
<Box
component='span'
display='inline-flex'
@ -31,7 +36,7 @@ const DevTypography = memo(({ label, labelProps, color, chipProps }) => (
sx={{ textTransform: 'capitalize' }}
{...labelProps}
>
{label}
{children}
</Typography>
<Chip
size='small'
@ -49,18 +54,8 @@ const DevTypography = memo(({ label, labelProps, color, chipProps }) => (
DevTypography.propTypes = {
chipProps: PropTypes.object,
color: PropTypes.string,
label: PropTypes.oneOfType([
PropTypes.object,
PropTypes.string.isRequired
]),
labelProps: PropTypes.object
}
DevTypography.defaultProps = {
chipProps: undefined,
color: 'secondary',
label: '',
labelProps: undefined
labelProps: PropTypes.object,
children: PropTypes.any
}
DevTypography.displayName = 'DevTypography'

View File

@ -459,8 +459,8 @@ module.exports = {
`Number of vCPU queues to use in the virtio-scsi controller.
Leave blank to use the default value`,
IoThreads: 'Iothreads',
IoThreadsConcept:
`Number of iothreads for virtio disks.
IoThreadsConcept: `
Number of iothreads for virtio disks.
By default threads will be assign to disk by round robin algorithm.
Disk thread id can be forced by disk IOTHREAD attribute`,
RawData: 'Raw data',
@ -470,6 +470,24 @@ module.exports = {
By default, the data will be checked against the libvirt schema`,
/* VM Template schema - context */
Context: 'Context',
SshPublicKey: 'SSH public key',
AddUserSshPublicKey: 'Add user SSH public key',
AddNetworkContextualization: 'Add Network contextualization',
AddNetworkContextualizationConcept: `
Add network contextualization parameters. For each NIC defined in
the NETWORK section, ETH$i_IP, ETH$i_NETWORK... parameters will be
included in the CONTEXT section and will be available in the Virtual Machine`,
AddOneGateToken: 'Add OneGate token',
AddOneGateTokenConcept: `
Add a file (token.txt) to the context containing the token to push
custom metrics to the Virtual Machine through OneGate`,
ReportReadyToOneGate: 'Report Ready to OneGate',
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`,
EncodeScriptInBase64: 'Encode script in Base64',
/* VM Template schema - Input/Output */
InputOrOutput: 'Input / Output',
Inputs: 'Inputs',

View File

@ -159,11 +159,13 @@ export const getNics = (vm, options = {}) => {
const { NIC = [], NIC_ALIAS = [], PCI = [] } = TEMPLATE
const { GUEST_IP, GUEST_IP_ADDRESSES = '' } = MONITORING
const pciNics = PCI.filter(({ NIC_ID } = {}) => NIC_ID !== undefined)
const extraIps = [GUEST_IP, ...GUEST_IP_ADDRESSES?.split(',')]
.filter(Boolean)
.map(ip => ({ NIC_ID: '-', IP: ip, NETWORK: 'Additional IP', BRIDGE: '-' }))
let nics = [NIC, NIC_ALIAS, PCI, extraIps].flat().filter(Boolean)
let nics = [NIC, NIC_ALIAS, pciNics, extraIps].flat().filter(Boolean)
if (groupAlias) {
nics = nics

View File

@ -14,7 +14,7 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import DOMPurify from 'dompurify'
import { object, reach, BaseSchema } from 'yup'
import { object, reach, ObjectSchema, BaseSchema } from 'yup'
import { HYPERVISORS } from 'client/constants'
/**
@ -107,7 +107,7 @@ export const getValidationFromFields = fields =>
* Returns fields in schema object.
*
* @param {{name: string, validation: BaseSchema}[]} fields - Fields
* @returns {BaseSchema} Object schema
* @returns {ObjectSchema} Object schema
* @example
* [{ name: 'VM.NAME', validation: string() }]
* => object({ 'VM': object({ NAME: string() }) })

View File

@ -197,7 +197,10 @@ const parseUserInputValue = value => {
return 'YES'
} else if (value === false) {
return 'NO'
} else if (Array.isArray(value)) {
} else if (
Array.isArray(value) &&
value.every(v => typeof v === 'string')
) {
return value.join(',')
} else return value
}
@ -215,9 +218,9 @@ const parseUserInputValue = value => {
*/
export const schemaUserInput = ({ mandatory, name, type, options, default: defaultValue }) => {
switch (type) {
case [USER_INPUT_TYPES.text]:
case [USER_INPUT_TYPES.text64]:
case [USER_INPUT_TYPES.password]: return {
case USER_INPUT_TYPES.text:
case USER_INPUT_TYPES.text64:
case USER_INPUT_TYPES.password: return {
type: INPUT_TYPES.TEXT,
htmlType: type === 'password' ? 'password' : 'text',
validation: string()
@ -225,8 +228,8 @@ export const schemaUserInput = ({ mandatory, name, type, options, default: defau
.concat(requiredSchema(mandatory, name, string()))
.default(defaultValue || undefined)
}
case [USER_INPUT_TYPES.number]:
case [USER_INPUT_TYPES.numberFloat]: return {
case USER_INPUT_TYPES.number:
case USER_INPUT_TYPES.numberFloat: return {
type: INPUT_TYPES.TEXT,
htmlType: 'number',
validation: number()
@ -235,8 +238,8 @@ export const schemaUserInput = ({ mandatory, name, type, options, default: defau
.transform(value => !isNaN(value) ? value : null)
.default(() => parseFloat(defaultValue) ?? undefined)
}
case [USER_INPUT_TYPES.range]:
case [USER_INPUT_TYPES.rangeFloat]: {
case USER_INPUT_TYPES.range:
case USER_INPUT_TYPES.rangeFloat: {
const [min, max] = getRange(options)
return {
@ -251,13 +254,13 @@ export const schemaUserInput = ({ mandatory, name, type, options, default: defau
fieldProps: { min, max, step: type === 'range-float' ? 0.01 : 1 }
}
}
case [USER_INPUT_TYPES.boolean]: return {
case USER_INPUT_TYPES.boolean: return {
type: INPUT_TYPES.CHECKBOX,
validation: boolean()
.concat(requiredSchema(mandatory, name, boolean()))
.default(defaultValue === 'YES' ?? false)
}
case [USER_INPUT_TYPES.list]: {
case USER_INPUT_TYPES.list: {
const values = getOptionsFromList(options)
const firstOption = values?.[0]?.value ?? undefined
@ -271,7 +274,7 @@ export const schemaUserInput = ({ mandatory, name, type, options, default: defau
.default(defaultValue ?? firstOption)
}
}
case [USER_INPUT_TYPES.array]: {
case USER_INPUT_TYPES.array: {
const defaultValues = getValuesFromArray(defaultValue)
return {
@ -283,7 +286,7 @@ export const schemaUserInput = ({ mandatory, name, type, options, default: defau
fieldProps: { freeSolo: true }
}
}
case [USER_INPUT_TYPES.listMultiple]: {
case USER_INPUT_TYPES.listMultiple: {
const values = getOptionsFromList(options)
const defaultValues = defaultValue?.split(',') ?? undefined