mirror of
https://github.com/OpenNebula/one.git
synced 2025-03-28 14:50:08 +03:00
F #6680,#6682: Improve OneFlow data model and add support to VR
This commits adds the following improvements: * Improving Usability & Consistency in flow documents: - vm_template -> template_id, generic as also VR templates are considered - vm_template_contents -> template_contents, same as above - custom_attrs -> user_inputs, use same name convention as VM Templates - custom_attrs_values -> user_inputs_values, same as above * Easier OneFlow operations - template_contents (old vm_template_contents) is now in JSON format instead of a String. - better error reporting when merging template contents * Role can use Virtual Routers as main element, supporting all role features (user inputs, dependencies and network creation). Related changes: - Role data model structure has been totally refactored to accomodate the new type (vmrole.rb and vrrole.rb) - VR roles use VR OpenNebula API to create associated VMs. - VR HA uses the cardinaility of the role New attributes: - type, new attribute to identify the role type VM or VR - vrouter_ips, floating IP of each network managed - vrouter_id, of the associated VR * Improved Sunstone interface for flows, including support for the new features. co-authored-by: Victor Palma <vpalma@opennebula.io> co-authored-by: Miguel E. Ruiz <mruiz@opennebula.io> co-authored-by: Victor Hansson <vhansson@opennebula.io>
This commit is contained in:
parent
bd0d225b7f
commit
e2539c32c8
@ -2759,6 +2759,8 @@ ONEFLOW_LIB_FILES="src/flow/lib/grammar.treetop \
|
||||
ONEFLOW_LIB_STRATEGY_FILES="src/flow/lib/strategy/straight.rb"
|
||||
|
||||
ONEFLOW_LIB_MODELS_FILES="src/flow/lib/models/role.rb \
|
||||
src/flow/lib/models/vmrole.rb \
|
||||
src/flow/lib/models/vrrole.rb \
|
||||
src/flow/lib/models/service.rb"
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
@ -182,35 +182,35 @@ class OneFlowTemplateHelper < OpenNebulaHelper::OneHelper
|
||||
end
|
||||
end
|
||||
|
||||
# Get custom attributes values from user
|
||||
# Get user inputs values from user
|
||||
#
|
||||
# @param custom_attrs [Hash] Custom attributes from template
|
||||
# @param user_inputs [Hash] User inputs from template
|
||||
#
|
||||
# @return [Hash] Custom attributes values
|
||||
def custom_attrs(custom_attrs)
|
||||
# @return [Hash] User Input values
|
||||
def user_inputs(user_inputs)
|
||||
# rubocop:disable Layout/LineLength
|
||||
return if custom_attrs.nil? || custom_attrs.empty?
|
||||
return if user_inputs.nil? || user_inputs.empty?
|
||||
|
||||
ret = {}
|
||||
ret['custom_attrs_values'] = OpenNebulaHelper.parse_user_inputs(custom_attrs)
|
||||
ret['user_inputs_values'] = OpenNebulaHelper.parse_user_inputs(user_inputs)
|
||||
|
||||
# rubocop:enable Layout/LineLength
|
||||
ret
|
||||
end
|
||||
|
||||
# Get custom role attributes values from user
|
||||
# Get user input values from user
|
||||
#
|
||||
# @param role [Hash] Service role with custom attributes
|
||||
# @param role [Hash] Service role with user inputs
|
||||
#
|
||||
# @return [Hash] Role with custom attributes values
|
||||
def custom_role_attrs(roles)
|
||||
# @return [Hash] Role with user inputs values
|
||||
def role_user_inputs(roles)
|
||||
return if roles.nil? || roles.empty?
|
||||
|
||||
ret = {}
|
||||
role_with_custom_attrs = false
|
||||
role_with_user_inputs = false
|
||||
|
||||
roles.each do |role|
|
||||
next unless role.key?('custom_attrs')
|
||||
next unless role.key?('user_inputs')
|
||||
|
||||
####################################################################
|
||||
# Display Role Information
|
||||
@ -218,11 +218,11 @@ class OneFlowTemplateHelper < OpenNebulaHelper::OneHelper
|
||||
header = "> Please insert the user inputs for the role \"#{role['name']}\""
|
||||
puts header
|
||||
|
||||
role.merge!(custom_attrs(role['custom_attrs']))
|
||||
role_with_custom_attrs = true
|
||||
role.merge!(user_inputs(role['user_inputs']))
|
||||
role_with_user_inputs = true
|
||||
end
|
||||
|
||||
ret['roles'] = roles if role_with_custom_attrs
|
||||
ret['roles'] = roles if role_with_user_inputs
|
||||
|
||||
ret
|
||||
end
|
||||
|
@ -296,13 +296,13 @@ CommandParser::CmdParser.new(ARGV) do
|
||||
params['merge_template'] = {}
|
||||
body = JSON.parse(response.body)['DOCUMENT']['TEMPLATE']['BODY']
|
||||
|
||||
# Check global custom attributes
|
||||
custom_attrs = helper.custom_attrs(body['custom_attrs'])
|
||||
params['merge_template'].merge!(custom_attrs) unless custom_attrs.nil?
|
||||
# Check global user inputs
|
||||
user_inputs = helper.user_inputs(body['user_inputs'])
|
||||
params['merge_template'].merge!(user_inputs) unless user_inputs.nil?
|
||||
|
||||
# Check role level custom attributes
|
||||
custom_role_attrs = helper.custom_role_attrs(body['roles'])
|
||||
params['merge_template'].merge!(custom_role_attrs) unless custom_role_attrs.nil?
|
||||
# Check role level user inputs
|
||||
user_inputs_attrs = helper.role_user_inputs(body['roles'])
|
||||
params['merge_template'].merge!(user_inputs_attrs) unless user_inputs_attrs.nil?
|
||||
|
||||
# Check vnets attributes
|
||||
vnets = helper.networks(body['networks'])
|
||||
|
@ -98,14 +98,7 @@ const AddressRangeCard = memo(
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-cy="ar"
|
||||
className={classes.root}
|
||||
sx={{
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Box data-cy="ar" className={classes.root}>
|
||||
<div className={classes.main}>
|
||||
<div className={classes.title}>
|
||||
<Typography noWrap component="span" data-cy="id">
|
||||
|
@ -96,7 +96,7 @@ const SecurityGroupCard = memo(
|
||||
)
|
||||
|
||||
return (
|
||||
<div {...rootProps} data-cy={`secgroup-${ID}`}>
|
||||
<div {...rootProps} className={classes.root} data-cy={`secgroup-${ID}`}>
|
||||
<div className={clsx(classes.main, internalClasses.internalContainer)}>
|
||||
<div>
|
||||
<div className={classes.title}>
|
||||
|
@ -35,6 +35,7 @@ const AutocompleteController = memo(
|
||||
fieldProps: { separators, ...fieldProps } = {},
|
||||
readOnly = false,
|
||||
optionsOnly = false,
|
||||
clearInvalid = false,
|
||||
onConditionChange,
|
||||
watcher,
|
||||
dependencies,
|
||||
@ -100,6 +101,27 @@ const AutocompleteController = memo(
|
||||
watcherValue !== undefined && onChange(watcherValue)
|
||||
}, [watch, watcher, dependencies])
|
||||
|
||||
useEffect(() => {
|
||||
if (clearInvalid && optionsOnly) {
|
||||
if (multiple) {
|
||||
if (renderValue?.length) {
|
||||
const filteredValues =
|
||||
renderValue?.filter((val) =>
|
||||
values.some((option) => option.value === val)
|
||||
) || []
|
||||
if (filteredValues?.length !== renderValue?.length) {
|
||||
onChange(filteredValues)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const isValid = values.some((option) => option.value === renderValue)
|
||||
if (!isValid) {
|
||||
onChange('')
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [clearInvalid, optionsOnly, renderValue, values, multiple, onChange])
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
@ -216,6 +238,7 @@ AutocompleteController.propTypes = {
|
||||
fieldProps: PropTypes.object,
|
||||
readOnly: PropTypes.bool,
|
||||
optionsOnly: PropTypes.bool,
|
||||
clearInvalid: PropTypes.bool,
|
||||
onConditionChange: PropTypes.func,
|
||||
watcher: PropTypes.func,
|
||||
dependencies: PropTypes.oneOfType([
|
||||
|
@ -48,9 +48,14 @@ const TableController = memo(
|
||||
onConditionChange,
|
||||
zoneId,
|
||||
dependOf,
|
||||
fieldProps: { initialState, preserveState, ...fieldProps } = {},
|
||||
fieldProps: {
|
||||
initialState,
|
||||
preserveState,
|
||||
onRowsChange,
|
||||
...fieldProps
|
||||
} = {},
|
||||
}) => {
|
||||
const { clearErrors } = useFormContext()
|
||||
const formContext = useFormContext()
|
||||
|
||||
const {
|
||||
field: { value, onChange },
|
||||
@ -84,15 +89,19 @@ const TableController = memo(
|
||||
const rowValues = rows?.map(({ original }) => getRowId(original))
|
||||
|
||||
onChange(singleSelect ? rowValues?.[0] : rowValues)
|
||||
clearErrors(name)
|
||||
formContext.clearErrors(name)
|
||||
|
||||
if (typeof onConditionChange === 'function') {
|
||||
onConditionChange(singleSelect ? rowValues?.[0] : rowValues)
|
||||
}
|
||||
|
||||
if (typeof onRowsChange === 'function') {
|
||||
onRowsChange(rows, { name, formContext })
|
||||
}
|
||||
},
|
||||
[
|
||||
onChange,
|
||||
clearErrors,
|
||||
formContext.clearErrors,
|
||||
name,
|
||||
onConditionChange,
|
||||
readOnly,
|
||||
|
@ -54,6 +54,7 @@ const StepperStyled = styled(Stepper)(({ theme }) => ({
|
||||
position: 'sticky',
|
||||
top: -15,
|
||||
minHeight: 100,
|
||||
borderRadius: '8px 8px 0 0',
|
||||
zIndex: theme.zIndex.mobileStepper,
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
}))
|
||||
@ -234,25 +235,21 @@ const CustomStepper = ({
|
||||
minHeight: '40px',
|
||||
}}
|
||||
>
|
||||
{
|
||||
enableShowMandatoryOnly && (
|
||||
// <Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
onChange={handleShowMandatoryOnly}
|
||||
name={'aaaaaa'}
|
||||
checked={showMandatoryOnly}
|
||||
color="secondary"
|
||||
inputProps={{ 'data-cy': 'switch-mandatory' }}
|
||||
/>
|
||||
}
|
||||
label={<Label>{T.MandatoryUserInputs}</Label>}
|
||||
labelPlacement="end"
|
||||
/>
|
||||
)
|
||||
// </Box>
|
||||
}
|
||||
{enableShowMandatoryOnly && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
onChange={handleShowMandatoryOnly}
|
||||
name={'aaaaaa'}
|
||||
checked={showMandatoryOnly}
|
||||
color="secondary"
|
||||
inputProps={{ 'data-cy': 'switch-mandatory' }}
|
||||
/>
|
||||
}
|
||||
label={<Label>{T.MandatoryUserInputs}</Label>}
|
||||
labelPlacement="end"
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', marginLeft: 'auto' }}
|
||||
>
|
||||
|
@ -123,6 +123,7 @@ const FormStepper = ({
|
||||
reset,
|
||||
formState: { errors },
|
||||
setError,
|
||||
setFocus,
|
||||
} = useFormContext()
|
||||
const { setModifiedFields } = useGeneralApi()
|
||||
const { isLoading } = useGeneral()
|
||||
@ -211,22 +212,38 @@ const FormStepper = ({
|
||||
const setErrors = ({ inner = [], message = { word: 'Error' } } = {}) => {
|
||||
const errorsByPath = groupBy(inner, 'path') ?? {}
|
||||
const jsonErrorsByPath = deepStringify(errorsByPath, 6) || ''
|
||||
const totalErrors = (jsonErrorsByPath.match(/\bmessage\b/g) || []).length
|
||||
const individualErrorMessages = [
|
||||
...new Set(
|
||||
[message]
|
||||
.concat(inner.map((error) => error?.message ?? ''))
|
||||
.filter(Boolean)
|
||||
),
|
||||
]
|
||||
|
||||
const extractedErrors = (jsonErrorsByPath.match(/\bmessage\b/g) || [])
|
||||
.length
|
||||
const individualErrors = individualErrorMessages?.length
|
||||
|
||||
const totalErrors = extractedErrors || individualErrors || 0
|
||||
|
||||
const translationError =
|
||||
totalErrors > 0 ? [T.ErrorsOcurred, totalErrors] : Object.values(message)
|
||||
|
||||
const individualErrorMessages = inner.map((error) => error?.message ?? '')
|
||||
|
||||
setError(stepId, {
|
||||
type: 'manual',
|
||||
message: translationError,
|
||||
individualErrorMessages,
|
||||
})
|
||||
|
||||
inner?.forEach(({ path, type, errors: innerMessage }, index) => {
|
||||
inner?.forEach(({ path, type, errors: innerMessage }) => {
|
||||
setError(`${stepId}.${path}`, { type, message: innerMessage })
|
||||
})
|
||||
|
||||
const firstErrorPath = inner?.find((error) => error?.path)?.path
|
||||
|
||||
if (firstErrorPath) {
|
||||
setFocus(`${stepId}.${firstErrorPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStep = (stepToAdvance) => {
|
||||
@ -289,6 +306,7 @@ const FormStepper = ({
|
||||
setActiveStep((prevActiveStep) => prevActiveStep + 1)
|
||||
}
|
||||
} catch (validateError) {
|
||||
console.error(validateError)
|
||||
setErrors(validateError)
|
||||
}
|
||||
}
|
||||
|
@ -21,29 +21,30 @@ import { FIELDS } from '@modules/components/Forms/Commons/VNetwork/Tabs/configur
|
||||
import FormWithSchema from '@modules/components/Forms/FormWithSchema'
|
||||
import { T } from '@ConstantsModule'
|
||||
|
||||
const ConfigurationContent =
|
||||
(stepId) =>
|
||||
({ oneConfig, adminGroup, isUpdate, isVnet }) => {
|
||||
const InnerComponent = (
|
||||
<>
|
||||
<FormWithSchema
|
||||
id={stepId}
|
||||
cy="configuration"
|
||||
fields={FIELDS(oneConfig, adminGroup, isUpdate, isVnet)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
const ConfigurationContent = (stepId) => {
|
||||
const ConfigurationComponent = ({
|
||||
oneConfig,
|
||||
adminGroup,
|
||||
isUpdate,
|
||||
isVnet,
|
||||
}) => (
|
||||
<FormWithSchema
|
||||
id={stepId}
|
||||
cy="configuration"
|
||||
fields={FIELDS(oneConfig, adminGroup, isUpdate, isVnet)}
|
||||
/>
|
||||
)
|
||||
|
||||
InnerComponent.displayName = `InnerComponent`
|
||||
ConfigurationComponent.displayName = 'ConfigurationComponent'
|
||||
|
||||
return InnerComponent
|
||||
ConfigurationComponent.propTypes = {
|
||||
oneConfig: PropTypes.object,
|
||||
adminGroup: PropTypes.bool,
|
||||
isUpdate: PropTypes.bool,
|
||||
isVnet: PropTypes.bool,
|
||||
}
|
||||
|
||||
ConfigurationContent.displayName = 'ConfigurationContent'
|
||||
|
||||
ConfigurationContent.propTypes = {
|
||||
oneConfig: PropTypes.object,
|
||||
adminGroup: PropTypes.bool,
|
||||
return ConfigurationComponent
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -24,6 +24,7 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { Accordion, AccordionSummary, FormControl, Grid } from '@mui/material'
|
||||
import { useFormContext, useFormState, useWatch } from 'react-hook-form'
|
||||
@ -309,13 +310,14 @@ const FieldComponent = memo(
|
||||
|
||||
const currentState = useSelector((state) => state)
|
||||
|
||||
// Potentially prefixes form ID + split ID
|
||||
const addIdToName = useCallback(
|
||||
(n) => {
|
||||
// removes character '$' and returns
|
||||
if (n?.startsWith('$')) return n.slice(1)
|
||||
(fieldName, formId, split = 0) => {
|
||||
if (fieldName?.startsWith('$')) return fieldName.slice(1)
|
||||
|
||||
// concat form ID if exists
|
||||
return id ? `${id}.${n}` : n
|
||||
return `${formId ? `${formId}.` : ''}${fieldName}${
|
||||
split > 0 ? `_${split}` : ''
|
||||
}`
|
||||
},
|
||||
[id]
|
||||
)
|
||||
@ -324,8 +326,8 @@ const FieldComponent = memo(
|
||||
if (!dependOf) return null
|
||||
|
||||
return Array.isArray(dependOf)
|
||||
? dependOf.map(addIdToName)
|
||||
: addIdToName(dependOf)
|
||||
? dependOf.map((fieldName) => addIdToName(fieldName, id))
|
||||
: addIdToName(dependOf, id)
|
||||
}, [dependOf, addIdToName])
|
||||
|
||||
const valueOfDependField = useWatch({
|
||||
@ -335,12 +337,7 @@ const FieldComponent = memo(
|
||||
|
||||
const handleConditionChange = useCallback(
|
||||
(value) => {
|
||||
// Ensure step control as an array
|
||||
const ensureStepControl = Array.isArray(stepControl)
|
||||
? stepControl
|
||||
: stepControl
|
||||
? [stepControl]
|
||||
: []
|
||||
const ensureStepControl = [].concat(stepControl)
|
||||
|
||||
// Iterate over each step control to evaluate it
|
||||
ensureStepControl.forEach((stepControlItem) => {
|
||||
@ -360,62 +357,70 @@ const FieldComponent = memo(
|
||||
[stepControl, disableSteps, currentState]
|
||||
)
|
||||
|
||||
const { name, type, htmlType, grid, condition, ...fieldProps } =
|
||||
Object.entries(attributes).reduce((field, attribute) => {
|
||||
const [attrKey, value] = attribute
|
||||
const isNotDependAttribute = NOT_DEPEND_ATTRIBUTES.includes(attrKey)
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
htmlType,
|
||||
grid,
|
||||
condition,
|
||||
splits = 1,
|
||||
...fieldProps
|
||||
} = Object.entries(attributes).reduce((field, [attrKey, attrValue]) => {
|
||||
const isNotDependAttribute = NOT_DEPEND_ATTRIBUTES.includes(attrKey)
|
||||
|
||||
const finalValue =
|
||||
typeof value === 'function' &&
|
||||
!isNotDependAttribute &&
|
||||
!isValidElement(value())
|
||||
? value(valueOfDependField, formContext)
|
||||
: value
|
||||
const finalValue =
|
||||
typeof attrValue === 'function' &&
|
||||
!isNotDependAttribute &&
|
||||
!isValidElement(attrValue())
|
||||
? attrValue(valueOfDependField, formContext)
|
||||
: attrValue
|
||||
|
||||
return { ...field, [attrKey]: finalValue }
|
||||
}, {})
|
||||
return { ...field, [attrKey]: finalValue }
|
||||
}, {})
|
||||
|
||||
const dataCy = useMemo(
|
||||
() => `${cy}-${name ?? ''}`.replaceAll('.', '-'),
|
||||
[cy]
|
||||
)
|
||||
const inputName = useMemo(() => addIdToName(name), [addIdToName, name])
|
||||
const isHidden = useMemo(() => htmlType === INPUT_TYPES.HIDDEN, [htmlType])
|
||||
// Key is computed in first hand based on it's type, meaning we re-render if type changes.
|
||||
const key = useMemo(
|
||||
() =>
|
||||
fieldProps
|
||||
? `${name}-${simpleHash(
|
||||
deepStringify(
|
||||
fieldProps?.type ??
|
||||
fieldProps?.values ??
|
||||
Object.values(fieldProps),
|
||||
3 // Max object depth
|
||||
)
|
||||
)}`
|
||||
: undefined,
|
||||
[fieldProps]
|
||||
`${name}_${simpleHash(
|
||||
deepStringify(
|
||||
fieldProps?.type ??
|
||||
fieldProps?.identifier ??
|
||||
fieldProps?.values ??
|
||||
Object.values(fieldProps),
|
||||
3 // Max object depth
|
||||
)
|
||||
)}` || uuidv4(),
|
||||
[(name, type, htmlType, condition, fieldProps)]
|
||||
)
|
||||
|
||||
if (isHidden) return null
|
||||
|
||||
return (
|
||||
INPUT_CONTROLLER[type] && (
|
||||
<Grid item xs={12} md={6} {...grid}>
|
||||
{createElement(INPUT_CONTROLLER[type], {
|
||||
key,
|
||||
control: formContext.control,
|
||||
cy: dataCy,
|
||||
dependencies: nameOfDependField,
|
||||
name: inputName,
|
||||
type: htmlType === false ? undefined : htmlType,
|
||||
dependOf,
|
||||
onConditionChange: handleConditionChange,
|
||||
...fieldProps,
|
||||
})}
|
||||
</Grid>
|
||||
)
|
||||
)
|
||||
function* generateInputs() {
|
||||
for (let i = 0; i < splits; i++) {
|
||||
yield (
|
||||
<Grid key={`split-${i}`} item xs={12} md={6} {...grid}>
|
||||
{createElement(INPUT_CONTROLLER[type], {
|
||||
key: `${key}-${i}`,
|
||||
control: formContext.control,
|
||||
cy: dataCy,
|
||||
dependencies: nameOfDependField,
|
||||
name: addIdToName(name, id, i),
|
||||
type: htmlType === false ? undefined : htmlType,
|
||||
dependOf,
|
||||
onConditionChange: handleConditionChange,
|
||||
...fieldProps,
|
||||
})}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return INPUT_CONTROLLER[type] && <>{[...generateInputs()]}</>
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -13,9 +13,8 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { createForm, bindSecGroupTemplate } from '@UtilsModule'
|
||||
import { createForm } from '@UtilsModule'
|
||||
import { SCHEMA, FIELDS } from './schema'
|
||||
import { jsonToXml } from '@ModelsModule'
|
||||
|
||||
const ChangeSecurityGroup = createForm(SCHEMA, FIELDS, {
|
||||
transformInitialValue: (secGroupId, schema) => {
|
||||
@ -25,13 +24,7 @@ const ChangeSecurityGroup = createForm(SCHEMA, FIELDS, {
|
||||
...schema.cast({ secGroup }, { stripUnknown: true }),
|
||||
}
|
||||
},
|
||||
transformBeforeSubmit: (formData, vnet) => {
|
||||
const { secgroups } = formData
|
||||
|
||||
const newTemplate = bindSecGroupTemplate(vnet, secgroups)
|
||||
|
||||
return jsonToXml(newTemplate)
|
||||
},
|
||||
transformBeforeSubmit: (formData) => formData,
|
||||
})
|
||||
|
||||
export default ChangeSecurityGroup
|
||||
|
@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { string, object, ObjectSchema, mixed } from 'yup'
|
||||
import { string, object, mixed } from 'yup'
|
||||
import {
|
||||
Field,
|
||||
arrayToOptions,
|
||||
@ -146,7 +146,7 @@ export const RANGE_TYPE = {
|
||||
}),
|
||||
validation: string()
|
||||
.trim()
|
||||
.afterSubmit((value) => undefined),
|
||||
.afterSubmit(() => undefined),
|
||||
grid: { xs: 12 },
|
||||
}
|
||||
|
||||
@ -179,7 +179,7 @@ export const TARGET = {
|
||||
),
|
||||
validation: string()
|
||||
.trim()
|
||||
.afterSubmit((value) => undefined),
|
||||
.afterSubmit(() => undefined),
|
||||
grid: { xs: 12 },
|
||||
}
|
||||
|
||||
@ -248,6 +248,6 @@ export const FIELDS = [
|
||||
|
||||
/**
|
||||
* @param {object} [stepProps] - Step props
|
||||
* @returns {ObjectSchema} Schema
|
||||
* @returns {object} Schema
|
||||
*/
|
||||
export const SCHEMA = object(getValidationFromFields(FIELDS))
|
||||
|
@ -63,14 +63,10 @@ export const ACTION_FIELD_NAME = 'ACTION'
|
||||
const createArgField = (argName, htmlType) => ({
|
||||
name: `ARGS.${argName}`,
|
||||
dependOf: ACTION_FIELD_NAME,
|
||||
htmlType: (action) => {
|
||||
const prueba = getRequiredArgsByAction(action)
|
||||
console.log(argName, prueba.includes(argName))
|
||||
|
||||
return !getRequiredArgsByAction(action)?.includes(argName)
|
||||
htmlType: (action) =>
|
||||
!getRequiredArgsByAction(action)?.includes(argName)
|
||||
? INPUT_TYPES.HIDDEN
|
||||
: htmlType
|
||||
},
|
||||
: htmlType,
|
||||
})
|
||||
|
||||
/** @type {Field} Snapshot name field */
|
||||
|
@ -13,52 +13,50 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { useForm, FormProvider } from 'react-hook-form'
|
||||
import { useMemo, memo } from 'react'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import {
|
||||
ADVANCED_PARAMS_FIELDS,
|
||||
ADVANCED_PARAMS_SCHEMA,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/schema'
|
||||
import { FormWithSchema, Legend } from '@modules/components/Forms'
|
||||
import { Stack, Divider, FormControl } from '@mui/material'
|
||||
/* eslint-disable jsdoc/require-jsdoc */
|
||||
import PropTypes from 'prop-types'
|
||||
import { ADVANCED_PARAMS_FIELDS } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/schema'
|
||||
import { FormWithSchema } from '@modules/components/Forms'
|
||||
import { Box, Stack, Divider } from '@mui/material'
|
||||
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
|
||||
|
||||
import { ServerConnection as NetworkIcon } from 'iconoir-react'
|
||||
|
||||
import { T } from '@ConstantsModule'
|
||||
|
||||
export const SECTION_ID = 'ADVANCED'
|
||||
export const TAB_ID = 'advanced'
|
||||
|
||||
const AdvancedParamsSection = () => {
|
||||
const fields = useMemo(() => ADVANCED_PARAMS_FIELDS, [])
|
||||
|
||||
const { handleSubmit, ...methods } = useForm({
|
||||
defaultValues: ADVANCED_PARAMS_SCHEMA?.default(),
|
||||
mode: 'all',
|
||||
resolver: yupResolver(ADVANCED_PARAMS_SCHEMA),
|
||||
})
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
component="fieldset"
|
||||
sx={{ width: '100%', gridColumn: '1 / -1' }}
|
||||
const Content = () => (
|
||||
<Box sx={{ width: '100%', height: '100%' }}>
|
||||
<Stack
|
||||
key={`inputs-${TAB_ID}`}
|
||||
direction="column"
|
||||
alignItems="flex-start"
|
||||
gap="0.5rem"
|
||||
component="form"
|
||||
width="100%"
|
||||
>
|
||||
<Legend title={T.AdvancedParams} />
|
||||
<FormProvider {...methods}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
gap="0.5rem"
|
||||
component="form"
|
||||
>
|
||||
<FormWithSchema
|
||||
cy={SECTION_ID}
|
||||
fields={fields}
|
||||
rootProps={{ sx: { m: 0 } }}
|
||||
/>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
<Divider />
|
||||
</FormControl>
|
||||
)
|
||||
<FormWithSchema
|
||||
cy={TAB_ID}
|
||||
legend={T.AdvancedParams}
|
||||
id={`${EXTRA_ID}.${TAB_ID}`}
|
||||
fields={ADVANCED_PARAMS_FIELDS}
|
||||
/>
|
||||
</Stack>
|
||||
<Divider />
|
||||
</Box>
|
||||
)
|
||||
|
||||
Content.propTypes = {
|
||||
stepId: PropTypes.string,
|
||||
}
|
||||
|
||||
export default memo(AdvancedParamsSection)
|
||||
const TAB = {
|
||||
id: TAB_ID,
|
||||
name: T.AdvancedOptions,
|
||||
icon: NetworkIcon,
|
||||
Content,
|
||||
getError: (error) => !!error?.[TAB_ID],
|
||||
}
|
||||
|
||||
export default TAB
|
||||
|
@ -25,14 +25,17 @@ const STRATEGY_TYPES = {
|
||||
|
||||
const VM_SHUTDOWN_TYPES = {
|
||||
terminate: 'Terminate',
|
||||
terminateHard: 'Terminate hard',
|
||||
'terminate-hard': 'Terminate hard',
|
||||
shutdown: 'Shutdown',
|
||||
'shutdown-hard': 'Shutdown hard',
|
||||
}
|
||||
|
||||
const STRATEGY_TYPE = {
|
||||
label: T.Strategy,
|
||||
name: 'ADVANCED.DEPLOYMENT',
|
||||
name: 'deployment',
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
optionsOnly: true,
|
||||
tooltip: T.StraightStrategyConcept,
|
||||
values: arrayToOptions(Object.keys(STRATEGY_TYPES), {
|
||||
addEmpty: false,
|
||||
getText: (key) => STRATEGY_TYPES[key],
|
||||
@ -41,40 +44,44 @@ const STRATEGY_TYPE = {
|
||||
|
||||
validation: string()
|
||||
.trim()
|
||||
.required()
|
||||
.notRequired()
|
||||
.oneOf(Object.keys(STRATEGY_TYPES))
|
||||
.default(() => Object.keys(STRATEGY_TYPES)[0]),
|
||||
grid: { sm: 2, md: 2 },
|
||||
.default(() => undefined),
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
const VM_SHUTDOWN_TYPE = {
|
||||
label: T.VMShutdownAction,
|
||||
name: 'ADVANCED.VMSHUTDOWN',
|
||||
name: 'shutdown_action',
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
optionsOnly: true,
|
||||
values: arrayToOptions(Object.values(VM_SHUTDOWN_TYPES), { addEmpty: false }),
|
||||
values: arrayToOptions(Object.keys(VM_SHUTDOWN_TYPES), {
|
||||
addEmpty: false,
|
||||
getText: (key) => VM_SHUTDOWN_TYPES[key],
|
||||
getValue: (key) => key,
|
||||
}),
|
||||
validation: string()
|
||||
.trim()
|
||||
.required()
|
||||
.oneOf(Object.values(VM_SHUTDOWN_TYPES))
|
||||
.default(() => Object.values(VM_SHUTDOWN_TYPES)[0]),
|
||||
grid: { sm: 2, md: 2 },
|
||||
.notRequired()
|
||||
.oneOf(Object.keys(VM_SHUTDOWN_TYPES))
|
||||
.default(() => undefined),
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
const WAIT_VMS = {
|
||||
label: T.WaitVmsReport,
|
||||
name: 'ADVANCED.READY_STATUS_GATE',
|
||||
name: 'ready_status_gate',
|
||||
type: INPUT_TYPES.CHECKBOX,
|
||||
validation: boolean().default(() => false),
|
||||
grid: { sd: 4, md: 4 },
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
const AUTO_DELETE = {
|
||||
label: T.ServiceAutoDelete,
|
||||
name: 'ADVANCED.AUTOMATIC_DELETION',
|
||||
name: 'automatic_deletion',
|
||||
type: INPUT_TYPES.CHECKBOX,
|
||||
validation: boolean().default(() => false),
|
||||
grid: { sd: 4, md: 4 },
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
export const ADVANCED_PARAMS_FIELDS = [
|
||||
|
@ -1,152 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { Component, useMemo } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useFieldArray, useForm, FormProvider } from 'react-hook-form'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import {
|
||||
CUSTOM_ATTRIBUTES_FIELDS,
|
||||
CUSTOM_ATTRIBUTES_SCHEMA,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes/schema'
|
||||
import { FormWithSchema, Legend } from '@modules/components/Forms'
|
||||
import { Translate, Tr } from '@modules/components/HOC'
|
||||
import { DeleteCircledOutline, AddCircledOutline } from 'iconoir-react'
|
||||
import { Stack, FormControl, Divider, Button, Box } from '@mui/material'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import SubmitButton from '@modules/components/FormControl/SubmitButton'
|
||||
import { T } from '@ConstantsModule'
|
||||
import { sentenceCase } from '@UtilsModule'
|
||||
|
||||
export const SECTION_ID = 'CUSTOM_ATTRIBUTES'
|
||||
|
||||
/**
|
||||
* @param {object} root0 - Params
|
||||
* @param {string} root0.stepId - Step identifier
|
||||
* @returns {Component} - Custom Attributes sub-step
|
||||
*/
|
||||
const CustomAttributesSection = ({ stepId }) => {
|
||||
const fields = CUSTOM_ATTRIBUTES_FIELDS
|
||||
|
||||
const {
|
||||
fields: customattributes,
|
||||
append,
|
||||
remove,
|
||||
} = useFieldArray({
|
||||
name: useMemo(
|
||||
() => [stepId, SECTION_ID].filter(Boolean).join('.'),
|
||||
[stepId]
|
||||
),
|
||||
})
|
||||
|
||||
const methods = useForm({
|
||||
defaultValues: CUSTOM_ATTRIBUTES_SCHEMA.default(),
|
||||
resolver: yupResolver(CUSTOM_ATTRIBUTES_SCHEMA),
|
||||
})
|
||||
|
||||
const onSubmit = (newcustomAttribute) => {
|
||||
append(newcustomAttribute)
|
||||
methods.reset()
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
component="fieldset"
|
||||
sx={{ width: '100%', gridColumn: '1 / -1' }}
|
||||
>
|
||||
<Legend title={T.UserInputs} />
|
||||
<FormProvider {...methods}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
gap="0.5rem"
|
||||
component="form"
|
||||
onSubmit={methods.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormWithSchema
|
||||
cy={'extra-customAttributes'}
|
||||
fields={fields}
|
||||
rootProps={{ sx: { m: 0 } }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
color="secondary"
|
||||
startIcon={<AddCircledOutline />}
|
||||
data-cy={'extra-customAttributes'}
|
||||
sx={{ mt: '1em' }}
|
||||
>
|
||||
<Translate word={T.Add} />
|
||||
</Button>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
<Divider />
|
||||
<List>
|
||||
{customattributes?.map(
|
||||
({ id, name, defaultvalue, description, mandatory, type }, index) => {
|
||||
const secondaryFields = [
|
||||
description && `${Tr(T.Description)}: ${description}`,
|
||||
defaultvalue && `${Tr(T.DefaultValue)}: ${defaultvalue}`,
|
||||
type && `${Tr(T.Type)}: ${Tr(sentenceCase(type))}`,
|
||||
mandatory &&
|
||||
`${Tr(T.Mandatory)}: ${
|
||||
mandatory ? `${Tr(T.Yes)}` : `${Tr(T.No)}`
|
||||
}`,
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={id}
|
||||
sx={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<ListItem
|
||||
secondaryAction={
|
||||
<SubmitButton
|
||||
onClick={() => remove(index)}
|
||||
icon={<DeleteCircledOutline />}
|
||||
/>
|
||||
}
|
||||
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={name}
|
||||
primaryTypographyProps={{ variant: 'body1' }}
|
||||
secondary={secondaryFields.join(' | ')}
|
||||
/>
|
||||
</ListItem>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</List>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
||||
CustomAttributesSection.propTypes = {
|
||||
stepId: PropTypes.string,
|
||||
}
|
||||
|
||||
export default CustomAttributesSection
|
@ -1,206 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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, number } from 'yup'
|
||||
import {
|
||||
getObjectSchemaFromFields,
|
||||
arrayToOptions,
|
||||
sentenceCase,
|
||||
} from '@UtilsModule'
|
||||
import { INPUT_TYPES, T } from '@ConstantsModule'
|
||||
|
||||
const getTypeProp = (type) => {
|
||||
switch (type) {
|
||||
case CA_TYPES.boolean:
|
||||
return INPUT_TYPES.SWITCH
|
||||
case CA_TYPES.text:
|
||||
case CA_TYPES.text64:
|
||||
case CA_TYPES.number:
|
||||
case CA_TYPES.numberfloat:
|
||||
return INPUT_TYPES.TEXT
|
||||
default:
|
||||
return INPUT_TYPES.TEXT
|
||||
}
|
||||
}
|
||||
|
||||
const getFieldProps = (type) => {
|
||||
switch (type) {
|
||||
case CA_TYPES.text:
|
||||
case CA_TYPES.text64:
|
||||
return { type: 'text' }
|
||||
case CA_TYPES.number:
|
||||
case CA_TYPES.numberfloat:
|
||||
return { type: 'number' }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// Define the CA types
|
||||
const CA_TYPES = {
|
||||
text64: 'text64',
|
||||
password: 'password',
|
||||
number: 'number',
|
||||
numberfloat: 'number-float',
|
||||
range: 'range',
|
||||
rangefloat: 'range-float',
|
||||
list: 'list',
|
||||
listmultiple: 'list-multiple',
|
||||
boolean: 'boolean',
|
||||
text: 'text',
|
||||
}
|
||||
|
||||
const CA_TYPE = {
|
||||
name: 'type',
|
||||
label: T.Type,
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
values: arrayToOptions(Object.values(CA_TYPES), {
|
||||
addEmpty: false,
|
||||
getText: (type) => sentenceCase(type),
|
||||
}),
|
||||
|
||||
defaultValueProp: CA_TYPES.text,
|
||||
validation: string()
|
||||
.trim()
|
||||
.required()
|
||||
.oneOf(Object.values(CA_TYPES))
|
||||
.default(() => CA_TYPES.text),
|
||||
grid: {
|
||||
sm: 1.5,
|
||||
md: 1.5,
|
||||
},
|
||||
}
|
||||
|
||||
const NAME = {
|
||||
name: 'name',
|
||||
label: T.Name,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
validation: string()
|
||||
.trim()
|
||||
.uppercase()
|
||||
.matches(/^[A-Z0-9_]*$/, {
|
||||
message:
|
||||
'Name must only contain uppercase alphanumeric characters and no spaces',
|
||||
excludeEmptyString: true,
|
||||
})
|
||||
.required()
|
||||
.default(() => ''),
|
||||
grid: { sm: 2.5, md: 2.5 },
|
||||
}
|
||||
|
||||
const DESCRIPTION = {
|
||||
name: 'description',
|
||||
label: T.Description,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
validation: string()
|
||||
.trim()
|
||||
.notRequired()
|
||||
.default(() => undefined),
|
||||
grid: { sm: 2.5, md: 2.5 },
|
||||
}
|
||||
|
||||
const DEFAULT_VALUE_TEXT = {
|
||||
name: 'defaultvalue',
|
||||
label: T.DefaultValue,
|
||||
dependOf: CA_TYPE.name,
|
||||
htmlType: (type) =>
|
||||
(type === CA_TYPES.password || type === CA_TYPES.boolean) &&
|
||||
INPUT_TYPES.HIDDEN,
|
||||
type: getTypeProp,
|
||||
fieldProps: getFieldProps,
|
||||
validation: string(),
|
||||
grid: { sm: 2.5, md: 2.5 },
|
||||
}
|
||||
|
||||
const DEFAULT_VALUE_BOOLEAN = {
|
||||
name: 'defaultvalue',
|
||||
label: T.DefaultValue,
|
||||
dependOf: CA_TYPE.name,
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
htmlType: (type) => ![CA_TYPES.boolean].includes(type) && INPUT_TYPES.HIDDEN,
|
||||
optionsOnly: true,
|
||||
values: () => arrayToOptions(['NO', 'YES']),
|
||||
fieldProps: getFieldProps,
|
||||
validation: string(),
|
||||
grid: { sm: 2.5, md: 2.5 },
|
||||
}
|
||||
|
||||
const DEFAULT_VALUE_RANGE_MIN = {
|
||||
name: 'defaultvaluerangemin',
|
||||
label: T.MinRange,
|
||||
dependOf: CA_TYPE.name,
|
||||
|
||||
htmlType: (type) =>
|
||||
![CA_TYPES.range, CA_TYPES.rangefloat].includes(type) && INPUT_TYPES.HIDDEN,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
validation: number(),
|
||||
grid: { sm: 4, md: 4.5 },
|
||||
}
|
||||
|
||||
const DEFAULT_VALUE_RANGE_MAX = {
|
||||
name: 'defaultvaluerangemax',
|
||||
label: T.MaxRange,
|
||||
dependOf: CA_TYPE.name,
|
||||
htmlType: (type) =>
|
||||
![CA_TYPES.range, CA_TYPES.rangefloat].includes(type) && INPUT_TYPES.HIDDEN,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
validation: number(),
|
||||
grid: { sm: 4, md: 4.5 },
|
||||
}
|
||||
|
||||
const DEFAULT_VALUE_LIST = {
|
||||
name: 'defaultvaluelist',
|
||||
label: T.UIOptionsConcept,
|
||||
dependOf: CA_TYPE.name,
|
||||
htmlType: (type) =>
|
||||
![CA_TYPES.listmultiple, CA_TYPES.list].includes(type) &&
|
||||
INPUT_TYPES.HIDDEN,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
fieldProps: {
|
||||
type: 'text',
|
||||
},
|
||||
validation: string(),
|
||||
grid: { sm: 9, md: 9 },
|
||||
}
|
||||
|
||||
const MANDATORY = {
|
||||
name: 'mandatory',
|
||||
label: T.Mandatory,
|
||||
type: INPUT_TYPES.SWITCH,
|
||||
validation: boolean().yesOrNo(),
|
||||
grid: { sm: 2, md: 2 },
|
||||
}
|
||||
|
||||
export const CUSTOM_ATTRIBUTES_FIELDS = [
|
||||
CA_TYPE,
|
||||
NAME,
|
||||
DESCRIPTION,
|
||||
DEFAULT_VALUE_TEXT,
|
||||
DEFAULT_VALUE_BOOLEAN,
|
||||
MANDATORY,
|
||||
DEFAULT_VALUE_RANGE_MIN,
|
||||
DEFAULT_VALUE_RANGE_MAX,
|
||||
DEFAULT_VALUE_LIST,
|
||||
]
|
||||
|
||||
export const CUSTOM_ATTRIBUTES_SCHEMA = getObjectSchemaFromFields(
|
||||
CUSTOM_ATTRIBUTES_FIELDS
|
||||
)
|
@ -14,50 +14,47 @@
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { Component, useMemo } from 'react'
|
||||
import { useFormContext } from 'react-hook-form'
|
||||
import PropTypes from 'prop-types'
|
||||
import { SCHEMA } from './schema'
|
||||
import { Stack, FormControl, Divider } from '@mui/material'
|
||||
import NetworkingSection from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking'
|
||||
import { SCHEMA } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/schema'
|
||||
|
||||
import CustomAttributesSection from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes'
|
||||
import Networking from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking'
|
||||
|
||||
import ScheduleActionsSection from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/scheduledActions'
|
||||
import UserInputs from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/userInputs'
|
||||
|
||||
import { FormWithSchema } from '@modules/components/Forms'
|
||||
import ScheduledActions from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/scheduledActions'
|
||||
|
||||
import AdvancedOptions from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams'
|
||||
|
||||
import { T } from '@ConstantsModule'
|
||||
import { ADVANCED_PARAMS_FIELDS } from './advancedParams/schema'
|
||||
import { BaseTab as Tabs } from '@modules/components/Tabs'
|
||||
|
||||
export const STEP_ID = 'extra'
|
||||
|
||||
const Content = () =>
|
||||
useMemo(
|
||||
() => (
|
||||
<Stack
|
||||
display="grid"
|
||||
gap="1em"
|
||||
sx={{ gridTemplateColumns: { sm: '1fr', md: '1fr 1fr' } }}
|
||||
>
|
||||
<NetworkingSection stepId={STEP_ID} />
|
||||
<CustomAttributesSection stepId={STEP_ID} />
|
||||
<FormControl
|
||||
component="fieldset"
|
||||
sx={{ width: '100%', gridColumn: '1 / -1' }}
|
||||
>
|
||||
<FormWithSchema
|
||||
id={STEP_ID}
|
||||
legend={T.AdvancedParams}
|
||||
fields={ADVANCED_PARAMS_FIELDS}
|
||||
rootProps={{ sx: { m: 0 } }}
|
||||
export const TABS = [Networking, UserInputs, ScheduledActions, AdvancedOptions]
|
||||
|
||||
const Content = () => {
|
||||
const { control } = useFormContext()
|
||||
const tabs = useMemo(
|
||||
() =>
|
||||
TABS.map(({ Content: TabContent, name, getError, ...section } = {}) => ({
|
||||
...section,
|
||||
name,
|
||||
label: name,
|
||||
renderContent: () => (
|
||||
<TabContent
|
||||
{...{
|
||||
control,
|
||||
}}
|
||||
/>
|
||||
<Divider />
|
||||
</FormControl>
|
||||
<ScheduleActionsSection />
|
||||
</Stack>
|
||||
),
|
||||
),
|
||||
})),
|
||||
[STEP_ID]
|
||||
)
|
||||
|
||||
return <Tabs tabs={tabs} />
|
||||
}
|
||||
|
||||
Content.propTypes = {
|
||||
data: PropTypes.any,
|
||||
setFormData: PropTypes.func,
|
||||
|
@ -0,0 +1,65 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 */
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import { Legend } from '@modules/components/Forms'
|
||||
import {
|
||||
Stack,
|
||||
Grid,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
} from '@mui/material'
|
||||
import { T } from '@ConstantsModule'
|
||||
|
||||
import {
|
||||
AR,
|
||||
SG,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections'
|
||||
|
||||
export const SECTION_ID = 'networks_values'
|
||||
|
||||
export const ExtraDropdown = ({ networksValues, selectedNetwork }) => (
|
||||
<Accordion
|
||||
variant="transparent"
|
||||
defaultExpanded={
|
||||
!!Object.values(networksValues?.[selectedNetwork] ?? {})?.flat()?.length
|
||||
}
|
||||
TransitionProps={{ unmountOnExit: false }}
|
||||
sx={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<AccordionSummary sx={{ width: '100%' }}>
|
||||
<Legend disableGutters title={T.Extra} />
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={4}>
|
||||
{[AR, SG].map(({ Section }, idx) => (
|
||||
<Grid key={`section-${idx}`} item md={6}>
|
||||
<Stack direction="column" spacing={2}>
|
||||
<Section selectedNetwork={selectedNetwork} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
|
||||
export default ExtraDropdown
|
@ -15,28 +15,14 @@
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { array, object, mixed } from 'yup'
|
||||
|
||||
import { ADVANCED_PARAMS_SCHEMA } from './AdvancedParameters/schema'
|
||||
import { createElasticityPoliciesSchema } from './ElasticityPolicies/schema'
|
||||
import { createScheduledPoliciesSchema } from './ScheduledPolicies/schema'
|
||||
import { createMinMaxVmsSchema } from './MinMaxVms/schema'
|
||||
import { SCHEMA as ADDRESS_RANGE_SCHEMA } from '@modules/components/Forms/VNetwork/AddRangeForm/schema'
|
||||
|
||||
export const SCHEMA = object()
|
||||
.shape({
|
||||
MINMAXVMS: array().of(createMinMaxVmsSchema()),
|
||||
})
|
||||
.shape({
|
||||
ELASTICITYPOLICIES: array().of(
|
||||
array().of(createElasticityPoliciesSchema())
|
||||
),
|
||||
})
|
||||
.shape({
|
||||
SCHEDULEDPOLICIES: array().of(array().of(createScheduledPoliciesSchema())),
|
||||
})
|
||||
.shape({
|
||||
NETWORKS: array(),
|
||||
NETWORKDEFS: array(),
|
||||
// Set to mixed, casting wont work for dynamically calculated keys
|
||||
// In reality should be [number()]: string()
|
||||
RDP: mixed(),
|
||||
})
|
||||
.concat(ADVANCED_PARAMS_SCHEMA)
|
||||
import {
|
||||
AR,
|
||||
SG,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections'
|
||||
|
||||
export const NETWORKS_EXTRA_SCHEMA = object().shape({
|
||||
[AR.id]: array().of(ADDRESS_RANGE_SCHEMA()),
|
||||
[SG.id]: array().of(mixed()), // Should be updated to a real schema
|
||||
})
|
@ -0,0 +1,77 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 */
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import { useFieldArray } from 'react-hook-form'
|
||||
import { Stack } from '@mui/material'
|
||||
import AddressRangeCard from '@modules/components/Cards/AddressRangeCard'
|
||||
import {
|
||||
AddAddressRangeAction,
|
||||
UpdateAddressRangeAction,
|
||||
DeleteAddressRangeAction,
|
||||
} from '@modules/components/Buttons'
|
||||
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
|
||||
|
||||
import { SECTION_ID as EXTRA_SECTION_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown'
|
||||
|
||||
const SECTION_ID = 'AR'
|
||||
|
||||
const AddressRanges = ({ selectedNetwork }) => {
|
||||
const {
|
||||
fields: addressRanges,
|
||||
remove,
|
||||
append,
|
||||
update,
|
||||
} = useFieldArray({
|
||||
name: `${EXTRA_ID}.${EXTRA_SECTION_ID}.${selectedNetwork}.${SECTION_ID}`,
|
||||
})
|
||||
|
||||
const handleSubmit = (data) => append(data)
|
||||
const handleUpdate = (idx, data) => update(idx, data)
|
||||
const handleRemove = (idx) => remove(idx)
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddAddressRangeAction onSubmit={handleSubmit} />
|
||||
<Stack direction="column" spacing={1}>
|
||||
{addressRanges?.map((ar, idx) => (
|
||||
<AddressRangeCard
|
||||
key={`ar-${idx}`}
|
||||
ar={ar}
|
||||
actions={
|
||||
<>
|
||||
<UpdateAddressRangeAction
|
||||
ar={{ ...ar, AR_ID: idx }}
|
||||
onSubmit={(updatedAr) => handleUpdate(idx, updatedAr)}
|
||||
/>
|
||||
<DeleteAddressRangeAction
|
||||
ar={{ ...ar, AR_ID: idx }}
|
||||
onSubmit={handleRemove}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const AR = {
|
||||
Section: AddressRanges,
|
||||
id: SECTION_ID,
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export * from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections/addressRange'
|
||||
export * from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections/securityGroup'
|
@ -0,0 +1,113 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 */
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useFieldArray } from 'react-hook-form'
|
||||
import { Stack } from '@mui/material'
|
||||
import { SecurityGroupCard } from '@modules/components/Cards'
|
||||
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
|
||||
import { SECTION_ID as EXTRA_SECTION_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown'
|
||||
|
||||
import ButtonToTriggerForm from '@modules/components/Forms/ButtonToTriggerForm'
|
||||
|
||||
import { ChangeForm as AddSgForm } from '@modules/components/Forms/SecurityGroups'
|
||||
|
||||
import { Plus as AddIcon } from 'iconoir-react/dist'
|
||||
|
||||
import { SecurityGroupAPI } from '@FeaturesModule'
|
||||
import { T } from '@ConstantsModule'
|
||||
|
||||
const SECTION_ID = 'SECURITY_GROUPS'
|
||||
|
||||
const SecurityGroups = ({ selectedNetwork }) => {
|
||||
const loadedInitial = useRef(false)
|
||||
const { data: fetchedGroups, isSuccess: fetchedSecGroups } =
|
||||
SecurityGroupAPI.useGetSecGroupsQuery()
|
||||
|
||||
const {
|
||||
fields: secGroups,
|
||||
append,
|
||||
replace,
|
||||
} = useFieldArray({
|
||||
name: `${EXTRA_ID}.${EXTRA_SECTION_ID}.${selectedNetwork}.${SECTION_ID}`,
|
||||
})
|
||||
|
||||
const handleAdd = ({ secgroups }) =>
|
||||
secgroups.forEach(async (group) => {
|
||||
const foundGroup = fetchedGroups?.find(({ ID }) => ID === group)
|
||||
foundGroup && append(foundGroup)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedInitial.current) return
|
||||
|
||||
if (!fetchedSecGroups) return
|
||||
|
||||
if (!secGroups) return
|
||||
|
||||
const validateKeys = ['NAME', 'GNAME', 'UNAME']
|
||||
|
||||
const invalidGroups = secGroups?.filter(
|
||||
(group) => !validateKeys?.some((key) => Object.hasOwn(group, key))
|
||||
)
|
||||
|
||||
const patchedGroups = invalidGroups?.map(({ ID }) =>
|
||||
fetchedGroups?.find((group) => group?.ID === ID)
|
||||
)
|
||||
|
||||
if (patchedGroups?.length) {
|
||||
replace(patchedGroups)
|
||||
}
|
||||
|
||||
loadedInitial.current = true
|
||||
}, [secGroups, fetchedGroups])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonToTriggerForm
|
||||
buttonProps={{
|
||||
color: 'secondary',
|
||||
'data-cy': 'add-sg',
|
||||
startIcon: <AddIcon />,
|
||||
label: T.SecurityGroup,
|
||||
variant: 'outlined',
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
dialogProps: {
|
||||
title: T.SecurityGroup,
|
||||
dataCy: 'modal-add-sg',
|
||||
},
|
||||
form: () => AddSgForm(),
|
||||
onSubmit: handleAdd,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Stack direction="column" spacing={1}>
|
||||
{secGroups.map((sg, idx) => (
|
||||
<SecurityGroupCard key={`sg-${idx}`} securityGroup={sg} />
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const SG = {
|
||||
Section: SecurityGroups,
|
||||
id: SECTION_ID,
|
||||
}
|
@ -14,139 +14,230 @@
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import PropTypes from 'prop-types'
|
||||
import { Component, useMemo } from 'react'
|
||||
import { useFieldArray, useForm, FormProvider } from 'react-hook-form'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useFormContext, useFieldArray } from 'react-hook-form'
|
||||
import { ServerConnection as NetworkIcon, Cancel } from 'iconoir-react'
|
||||
import {
|
||||
NETWORK_INPUT_FIELDS,
|
||||
NETWORK_INPUT_SCHEMA,
|
||||
NETWORK_SELECTION,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/schema'
|
||||
import { FormWithSchema, Legend } from '@modules/components/Forms'
|
||||
import { Translate, Tr } from '@modules/components/HOC'
|
||||
import { DeleteCircledOutline, AddCircledOutline } from 'iconoir-react'
|
||||
import { Stack, FormControl, Divider, Button, Box } from '@mui/material'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import SubmitButton from '@modules/components/FormControl/SubmitButton'
|
||||
import {
|
||||
ExtraDropdown,
|
||||
SECTION_ID as NETWORKS_VALUES_ID,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown'
|
||||
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
|
||||
|
||||
import { FormWithSchema } from '@modules/components/Forms'
|
||||
|
||||
import { Tr } from '@modules/components/HOC'
|
||||
import { Stack, Button, Grid, List, ListItem, IconButton } from '@mui/material'
|
||||
import { T } from '@ConstantsModule'
|
||||
import { sentenceCase } from '@UtilsModule'
|
||||
|
||||
export const SECTION_ID = 'NETWORKING'
|
||||
import {
|
||||
AR,
|
||||
SG,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections'
|
||||
|
||||
/**
|
||||
* @param {object} root0 - Params
|
||||
* @param {string} root0.stepId - Step identifier
|
||||
* @returns {Component} - Networking sub-section
|
||||
*/
|
||||
const NetworkingSection = ({ stepId }) => {
|
||||
const fields = useMemo(() => NETWORK_INPUT_FIELDS)
|
||||
export const TAB_ID = 'networks'
|
||||
|
||||
const Content = () => {
|
||||
const { watch } = useFormContext()
|
||||
|
||||
// Updates in real-time compared to the snapshot from the fieldArray hook
|
||||
const wNetworks = watch(`${EXTRA_ID}.${TAB_ID}`)
|
||||
const wNetworksValues = watch(`${EXTRA_ID}.${NETWORKS_VALUES_ID}`)
|
||||
|
||||
const {
|
||||
fields: networks,
|
||||
append,
|
||||
remove,
|
||||
append: appendNet,
|
||||
remove: rmNet,
|
||||
} = useFieldArray({
|
||||
name: useMemo(
|
||||
() => [stepId, SECTION_ID].filter(Boolean).join('.'),
|
||||
[stepId]
|
||||
),
|
||||
name: `${EXTRA_ID}.${TAB_ID}`,
|
||||
})
|
||||
|
||||
const methods = useForm({
|
||||
defaultValues: NETWORK_INPUT_SCHEMA.default(),
|
||||
resolver: yupResolver(NETWORK_INPUT_SCHEMA),
|
||||
const { append: appendNetv, remove: rmNetv } = useFieldArray({
|
||||
name: `${EXTRA_ID}.${NETWORKS_VALUES_ID}`,
|
||||
})
|
||||
|
||||
const onSubmit = async (newNetwork) => {
|
||||
const isValid = await methods.trigger()
|
||||
if (isValid) {
|
||||
append(newNetwork)
|
||||
methods.reset()
|
||||
const [selectedNetwork, setSelectedNetwork] = useState(0)
|
||||
const [shift, setShift] = useState(0)
|
||||
|
||||
const handleRemove = (event, idx) => {
|
||||
event.stopPropagation()
|
||||
|
||||
// Calculates shift & releases current reference in case it goes oob
|
||||
setSelectedNetwork((prev) => {
|
||||
setShift(prev + (networks?.length === 2 ? -+prev : idx < prev ? -1 : 0))
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
// Remove corresponding entry from networks_values array
|
||||
rmNetv(idx)
|
||||
rmNet(idx)
|
||||
}
|
||||
|
||||
// Very important, define all fields or else RHF uses previous input data
|
||||
const handleAppend = (event) => {
|
||||
event?.stopPropagation?.()
|
||||
setSelectedNetwork(() => {
|
||||
setShift(null)
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
appendNet({
|
||||
name: '',
|
||||
description: '',
|
||||
network: null,
|
||||
size: null,
|
||||
type: null,
|
||||
})
|
||||
|
||||
appendNetv({
|
||||
[AR.id]: [],
|
||||
[SG.id]: [],
|
||||
})
|
||||
}
|
||||
|
||||
// Shifts selected index after networks array has been updated
|
||||
useEffect(() => {
|
||||
if (selectedNetwork === null) {
|
||||
if (shift === null) {
|
||||
setSelectedNetwork(networks?.length - 1)
|
||||
} else {
|
||||
setSelectedNetwork(shift)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return null
|
||||
}
|
||||
}, [networks])
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
component="fieldset"
|
||||
sx={{ width: '100%', gridColumn: '1 / -1' }}
|
||||
>
|
||||
<Legend title={T.Networks} />
|
||||
<FormProvider {...methods}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
gap="0.5rem"
|
||||
component="form"
|
||||
onSubmit={methods.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormWithSchema
|
||||
cy={'extra-networking'}
|
||||
fields={fields}
|
||||
rootProps={{ sx: { m: 0 } }}
|
||||
/>
|
||||
<>
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
columnSpacing={1}
|
||||
rowSpacing={2}
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'stretch',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Grid item md={3} sx={{ borderRight: 1, padding: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
type="submit"
|
||||
color="secondary"
|
||||
startIcon={<AddCircledOutline />}
|
||||
data-cy={'extra-networking'}
|
||||
sx={{ mt: '1em' }}
|
||||
size="large"
|
||||
data-cy={'extra-add-network'}
|
||||
onClick={handleAppend}
|
||||
>
|
||||
<Translate word={T.Add} />
|
||||
{Tr(T.AddNetwork)}
|
||||
</Button>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
<Divider />
|
||||
<List>
|
||||
{networks?.map(
|
||||
({ name, description, netextra, id, network, type }, index) => {
|
||||
const secondaryFields = [
|
||||
description && `${Tr(T.Description)}: ${description}`,
|
||||
type && `${Tr(T.Type)}: ${Tr(sentenceCase(type))}`,
|
||||
network && `${Tr(T.Network)}: ${network}`,
|
||||
netextra && `${Tr(T.Extra)}: ${netextra}`,
|
||||
].filter(Boolean)
|
||||
<List
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{networks?.map((network, idx) => {
|
||||
const networkName = watch(`${EXTRA_ID}.${TAB_ID}.${idx}.name`)
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={id}
|
||||
sx={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
return (
|
||||
<ListItem
|
||||
secondaryAction={
|
||||
<SubmitButton
|
||||
onClick={() => remove(index)}
|
||||
icon={<DeleteCircledOutline />}
|
||||
/>
|
||||
}
|
||||
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
|
||||
key={`item-${idx}-${network.id}`}
|
||||
onClick={() => setSelectedNetwork(idx)}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: '4px',
|
||||
minHeight: '70px',
|
||||
my: 0.5,
|
||||
overflowX: 'hidden',
|
||||
padding: 2,
|
||||
|
||||
bgcolor:
|
||||
idx === selectedNetwork ? 'action.selected' : 'inherit',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={name}
|
||||
primaryTypographyProps={{ variant: 'body1' }}
|
||||
secondary={secondaryFields.join(' | ')}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="delete"
|
||||
onClick={(event) => handleRemove(event, idx)}
|
||||
sx={{ mr: 1.5, size: 'small' }}
|
||||
>
|
||||
<Cancel />
|
||||
</IconButton>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
maxWidth: '100%',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontSize: '1em',
|
||||
}}
|
||||
>
|
||||
{networkName || T.NewNetwork}
|
||||
</div>
|
||||
</ListItem>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</List>
|
||||
</FormControl>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</Grid>
|
||||
<Grid item md={9}>
|
||||
{selectedNetwork != null && wNetworks?.length > 0 && (
|
||||
<>
|
||||
<Stack
|
||||
key={`inputs-${networks?.[selectedNetwork]?.id}`}
|
||||
direction="column"
|
||||
alignItems="flex-start"
|
||||
gap="0.5rem"
|
||||
component="form"
|
||||
width="100%"
|
||||
>
|
||||
<FormWithSchema
|
||||
legend={T.Type}
|
||||
id={`${EXTRA_ID}.${TAB_ID}.${selectedNetwork}`}
|
||||
cy={`${TAB_ID}`}
|
||||
fields={NETWORK_INPUT_FIELDS}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<ExtraDropdown
|
||||
networksValues={wNetworksValues}
|
||||
key={`extra-${networks?.[selectedNetwork]?.id}`}
|
||||
selectedNetwork={selectedNetwork}
|
||||
/>
|
||||
|
||||
<FormWithSchema
|
||||
key={`network-table-${networks?.[selectedNetwork]?.id}`}
|
||||
cy={`${TAB_ID}-${NETWORK_SELECTION?.name}`}
|
||||
id={`${EXTRA_ID}.${TAB_ID}.${selectedNetwork}`}
|
||||
fields={[NETWORK_SELECTION]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
NetworkingSection.propTypes = {
|
||||
Content.propTypes = {
|
||||
stepId: PropTypes.string,
|
||||
}
|
||||
|
||||
export default NetworkingSection
|
||||
const TAB = {
|
||||
id: TAB_ID,
|
||||
name: T.Networks,
|
||||
icon: NetworkIcon,
|
||||
Content,
|
||||
getError: (error) => !!error?.[TAB_ID],
|
||||
}
|
||||
|
||||
export default TAB
|
||||
|
@ -13,30 +13,33 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { string } from 'yup'
|
||||
import { lazy, string, object, number } from 'yup'
|
||||
import { getObjectSchemaFromFields, arrayToOptions } from '@UtilsModule'
|
||||
import { INPUT_TYPES } from '@ConstantsModule'
|
||||
import { VnAPI } from '@FeaturesModule'
|
||||
import { VnsTable, VnTemplatesTable } from '@modules/components/Tables'
|
||||
|
||||
// Define the network types
|
||||
export const NETWORK_TYPES = {
|
||||
create: 'Create',
|
||||
reserve: 'Reserve',
|
||||
existing: 'Existing',
|
||||
template_id: 'Create',
|
||||
reserve_from: 'Reserve',
|
||||
id: 'Existing',
|
||||
}
|
||||
|
||||
// Network Type Field
|
||||
const NETWORK_TYPE = {
|
||||
name: 'type',
|
||||
label: 'Type',
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
type: INPUT_TYPES.TOGGLE,
|
||||
optionsOnly: true,
|
||||
values: arrayToOptions(Object.values(NETWORK_TYPES), { addEmpty: false }),
|
||||
values: arrayToOptions(Object.keys(NETWORK_TYPES), {
|
||||
addEmpty: false,
|
||||
getText: (key) => NETWORK_TYPES?.[key],
|
||||
getValue: (key) => key,
|
||||
}),
|
||||
validation: string()
|
||||
.trim()
|
||||
.oneOf([...Object.keys(NETWORK_TYPES), ...Object.values(NETWORK_TYPES)])
|
||||
.default(() => Object.values(NETWORK_TYPES)[0]),
|
||||
grid: { md: 2 },
|
||||
.required()
|
||||
.default(() => undefined),
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
// Network Name Field
|
||||
@ -45,7 +48,7 @@ const NAME = {
|
||||
label: 'Name',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
validation: string().trim().required(),
|
||||
grid: { md: 2.5 },
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
// Network Description Field
|
||||
@ -57,53 +60,47 @@ const DESCRIPTION = {
|
||||
.trim()
|
||||
.notRequired()
|
||||
.default(() => undefined),
|
||||
grid: { md: 2.5 },
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
const SIZE = {
|
||||
name: 'SIZE',
|
||||
label: 'Size',
|
||||
dependOf: NETWORK_TYPE.name,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
htmlType: (TYPE) => (!TYPE || TYPE !== 'reserve_from') && INPUT_TYPES.HIDDEN,
|
||||
validation: lazy((_, { parent } = {}) => {
|
||||
const isRequired = parent?.type === 'reserve_from'
|
||||
|
||||
return number()?.[isRequired ? 'required' : 'notRequired']?.()
|
||||
}),
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
// Network Selection Field (for 'reserve' or 'existing')
|
||||
const NETWORK_SELECTION = {
|
||||
name: 'network',
|
||||
name: 'value',
|
||||
label: 'Network',
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
optionsOnly: true,
|
||||
values: () => {
|
||||
const { data: vnets = [] } = VnAPI.useGetVNetworksQuery()
|
||||
const networks = vnets
|
||||
.map((vnet) => ({ NAME: vnet?.NAME, ID: vnet?.ID }))
|
||||
.flat()
|
||||
|
||||
return arrayToOptions(networks, {
|
||||
getText: (network = '') => network?.NAME,
|
||||
getValue: (network) => network?.ID,
|
||||
})
|
||||
type: INPUT_TYPES.TABLE,
|
||||
Table: (TYPE) =>
|
||||
TYPE === 'template_id' ? VnTemplatesTable.Table : VnsTable.Table,
|
||||
dependOf: NETWORK_TYPE.name,
|
||||
validation: string().trim().required(),
|
||||
grid: { md: 12 },
|
||||
singleSelect: true,
|
||||
fieldProps: {
|
||||
preserveState: true,
|
||||
},
|
||||
dependOf: NETWORK_TYPE.name,
|
||||
validation: string().trim().notRequired(),
|
||||
grid: { sm: 2, md: 2 },
|
||||
}
|
||||
|
||||
// NetExtra Field
|
||||
const NETEXTRA = {
|
||||
name: 'netextra',
|
||||
label: 'Extra',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
dependOf: NETWORK_TYPE.name,
|
||||
validation: string().trim().when(NETWORK_TYPE.name, {
|
||||
is: 'existing',
|
||||
then: string().strip(),
|
||||
otherwise: string().notRequired(),
|
||||
}),
|
||||
grid: { md: 2.5 },
|
||||
}
|
||||
|
||||
// List of Network Input Fields
|
||||
export const NETWORK_INPUT_FIELDS = [
|
||||
NETWORK_TYPE,
|
||||
NAME,
|
||||
DESCRIPTION,
|
||||
NETWORK_SELECTION,
|
||||
NETEXTRA,
|
||||
]
|
||||
export const NETWORK_INPUT_FIELDS = [NETWORK_TYPE, NAME, DESCRIPTION, SIZE]
|
||||
|
||||
export const NETWORK_INPUT_SCHEMA =
|
||||
getObjectSchemaFromFields(NETWORK_INPUT_FIELDS)
|
||||
export const NETWORK_INPUT_SCHEMA = object().concat(
|
||||
getObjectSchemaFromFields([...NETWORK_INPUT_FIELDS, NETWORK_SELECTION])
|
||||
)
|
||||
|
||||
export { NETWORK_SELECTION }
|
||||
|
@ -13,135 +13,213 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { Box, Stack, Divider } from '@mui/material'
|
||||
import { useFieldArray } from 'react-hook-form'
|
||||
import { array, object } from 'yup'
|
||||
|
||||
import { ScheduleActionCard } from '@modules/components/Cards'
|
||||
import {
|
||||
CreateSchedButton,
|
||||
CharterButton,
|
||||
UpdateSchedButton,
|
||||
DeleteSchedButton,
|
||||
} from '@modules/components/Buttons/ScheduleAction'
|
||||
|
||||
import PropTypes from 'prop-types'
|
||||
import { T } from '@ConstantsModule'
|
||||
import { Legend } from '@modules/components/Forms'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useFormContext, useFieldArray } from 'react-hook-form'
|
||||
import { ServerConnection as NetworkIcon, Cancel } from 'iconoir-react'
|
||||
|
||||
import { mapNameByIndex } from '@modules/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema'
|
||||
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
|
||||
import { Component } from 'react'
|
||||
import { FormWithSchema } from '@modules/components/Forms'
|
||||
|
||||
export const TAB_ID = 'SCHED_ACTION'
|
||||
import { VM_SCHED_FIELDS } from '@modules/components/Forms/Vm/CreateSchedActionForm/schema'
|
||||
|
||||
const mapNameFunction = mapNameByIndex('SCHED_ACTION')
|
||||
import { Tr } from '@modules/components/HOC'
|
||||
import { Stack, Button, Grid, List, ListItem, IconButton } from '@mui/material'
|
||||
import { T } from '@ConstantsModule'
|
||||
|
||||
export const SCHED_ACTION_SCHEMA = object({
|
||||
SCHED_ACTION: array()
|
||||
.ensure()
|
||||
.transform((actions) => actions.map(mapNameByIndex('SCHED_ACTION'))),
|
||||
})
|
||||
export const TAB_ID = 'sched_actions'
|
||||
|
||||
const Content = () => {
|
||||
const [selectedSchedAction, setSelectedSchedAction] = useState(0)
|
||||
const [shift, setShift] = useState(0)
|
||||
|
||||
const { watch } = useFormContext()
|
||||
|
||||
const wSchedActions = watch(`${EXTRA_ID}.${TAB_ID}`)
|
||||
|
||||
/**
|
||||
* @param {object} root0 - Props
|
||||
* @param {object} root0.oneConfig - One config
|
||||
* @param {object} root0.adminGroup - oneadmin group
|
||||
* @returns {Component} - Scheduled actions component
|
||||
*/
|
||||
const ScheduleActionsSection = ({ oneConfig, adminGroup }) => {
|
||||
const {
|
||||
fields: scheduleActions,
|
||||
fields: schedActions,
|
||||
remove,
|
||||
update,
|
||||
append,
|
||||
} = useFieldArray({
|
||||
name: `${EXTRA_ID}.${TAB_ID}`,
|
||||
keyName: 'ID',
|
||||
})
|
||||
|
||||
const handleCreateAction = (action) => {
|
||||
append(mapNameFunction(action, scheduleActions.length))
|
||||
const handleRemove = (event, idx) => {
|
||||
event.stopPropagation()
|
||||
|
||||
// Calculates shift & releases current reference in case it goes oob
|
||||
setSelectedSchedAction((prev) => {
|
||||
setShift(
|
||||
prev + (schedActions?.length === 2 ? -+prev : idx < prev ? -1 : 0)
|
||||
)
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
remove(idx)
|
||||
}
|
||||
|
||||
const handleCreateCharter = (actions) => {
|
||||
const mappedActions = actions?.map((action, idx) =>
|
||||
mapNameFunction(action, scheduleActions.length + idx)
|
||||
)
|
||||
const handleAppend = (event) => {
|
||||
event?.stopPropagation?.()
|
||||
|
||||
append(mappedActions)
|
||||
setSelectedSchedAction(() => {
|
||||
setShift(null)
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
append({
|
||||
ACTION: '',
|
||||
PERIODIC: '',
|
||||
TIME: '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdate = (action, index) => {
|
||||
update(index, mapNameFunction(action, index))
|
||||
}
|
||||
|
||||
const handleRemove = (index) => {
|
||||
remove(index)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (selectedSchedAction === null) {
|
||||
if (shift === null) {
|
||||
setSelectedSchedAction(schedActions?.length - 1)
|
||||
} else {
|
||||
setSelectedSchedAction(shift)
|
||||
}
|
||||
}
|
||||
}, [schedActions])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Legend title={T.ScheduledActions} />
|
||||
<Box sx={{ width: '100%', gridColumn: '1 / -1' }}>
|
||||
<Stack flexDirection="row" gap="1em">
|
||||
<CreateSchedButton
|
||||
relative
|
||||
onSubmit={handleCreateAction}
|
||||
oneConfig={oneConfig}
|
||||
adminGroup={adminGroup}
|
||||
/>
|
||||
<CharterButton relative onSubmit={handleCreateCharter} />
|
||||
</Stack>
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
columnSpacing={1}
|
||||
rowSpacing={2}
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'stretch',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Grid item md={3} sx={{ borderRight: 1, padding: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
type="submit"
|
||||
size="large"
|
||||
data-cy={'extra-add-userinput'}
|
||||
onClick={handleAppend}
|
||||
>
|
||||
{Tr(T.AddScheduleAction)}
|
||||
</Button>
|
||||
<List
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{schedActions?.map((userinput, idx) => {
|
||||
const schedActionType = watch(
|
||||
`${EXTRA_ID}.${TAB_ID}.${idx}.ACTION`
|
||||
)
|
||||
|
||||
<Stack
|
||||
pb="1em"
|
||||
display="grid"
|
||||
gridTemplateColumns="repeat(auto-fit, minmax(300px, 0.5fr))"
|
||||
gap="1em"
|
||||
mt="1em"
|
||||
>
|
||||
{scheduleActions?.map((schedule, index) => {
|
||||
const { ID, NAME } = schedule
|
||||
const fakeValues = { ...schedule, ID: index }
|
||||
return (
|
||||
<ListItem
|
||||
key={`${idx}-${userinput?.id}-${userinput?.name}`}
|
||||
onClick={() => setSelectedSchedAction(idx)}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: '4px',
|
||||
minHeight: '70px',
|
||||
my: 0.5,
|
||||
overflowX: 'hidden',
|
||||
padding: 2,
|
||||
|
||||
return (
|
||||
<ScheduleActionCard
|
||||
key={ID ?? NAME}
|
||||
schedule={fakeValues}
|
||||
actions={
|
||||
<>
|
||||
<UpdateSchedButton
|
||||
relative
|
||||
vm={{}}
|
||||
schedule={fakeValues}
|
||||
onSubmit={(newAction) => handleUpdate(newAction, index)}
|
||||
oneConfig={oneConfig}
|
||||
adminGroup={adminGroup}
|
||||
/>
|
||||
<DeleteSchedButton
|
||||
schedule={fakeValues}
|
||||
onSubmit={() => handleRemove(index)}
|
||||
oneConfig={oneConfig}
|
||||
adminGroup={adminGroup}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
bgcolor:
|
||||
idx === selectedSchedAction
|
||||
? 'action.selected'
|
||||
: 'inherit',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
aria-label="delete"
|
||||
onClick={(event) => handleRemove(event, idx)}
|
||||
sx={{ mr: 1.5, size: 'small' }}
|
||||
>
|
||||
<Cancel />
|
||||
</IconButton>
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="flex-start"
|
||||
gap="0.2rem"
|
||||
width="100%"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
maxWidth: '100%',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontSize: '1em',
|
||||
}}
|
||||
>
|
||||
{`${T.ScheduleAction}#${idx}`}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
maxWidth: '100%',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontSize: '0.85em',
|
||||
opacity: '0.8',
|
||||
}}
|
||||
>
|
||||
{schedActionType || `${T.No} ${T.Type}`}
|
||||
</div>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</Grid>
|
||||
<Grid item md={9}>
|
||||
{selectedSchedAction != null && wSchedActions?.length > 0 && (
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="flex-start"
|
||||
gap="0.5rem"
|
||||
component="form"
|
||||
width="100%"
|
||||
>
|
||||
<FormWithSchema
|
||||
id={`${EXTRA_ID}.${TAB_ID}.${selectedSchedAction}`}
|
||||
key={`inputs-${selectedSchedAction}`}
|
||||
fields={VM_SCHED_FIELDS({ vm: {} })}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
<Divider />
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
ScheduleActionsSection.propTypes = {
|
||||
data: PropTypes.any,
|
||||
setFormData: PropTypes.func,
|
||||
oneConfig: PropTypes.object,
|
||||
adminGroup: PropTypes.bool,
|
||||
Content.propTypes = {
|
||||
stepId: PropTypes.string,
|
||||
}
|
||||
|
||||
export default ScheduleActionsSection
|
||||
const TAB = {
|
||||
id: TAB_ID,
|
||||
name: T.ScheduledActions,
|
||||
icon: NetworkIcon,
|
||||
Content,
|
||||
getError: (error) => !!error?.[TAB_ID],
|
||||
}
|
||||
|
||||
export default TAB
|
||||
|
@ -15,17 +15,30 @@
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { array, object } from 'yup'
|
||||
|
||||
import { NETWORK_INPUT_SCHEMA } from './networking/schema'
|
||||
import { CUSTOM_ATTRIBUTES_SCHEMA } from './customAttributes/schema'
|
||||
import { SCHED_ACTION_SCHEMA } from './scheduledActions'
|
||||
import { ADVANCED_PARAMS_SCHEMA } from './advancedParams/schema'
|
||||
import { NETWORK_INPUT_SCHEMA } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/schema'
|
||||
|
||||
export const SCHEMA = object()
|
||||
.shape({
|
||||
NETWORKING: array().of(NETWORK_INPUT_SCHEMA),
|
||||
import { TAB_ID as NETWORK_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking'
|
||||
|
||||
import { NETWORKS_EXTRA_SCHEMA } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/schema'
|
||||
|
||||
import { SECTION_ID as NETWORK_DROPDOWN_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown'
|
||||
|
||||
import { USER_INPUTS_SCHEMA } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/userInputs/schema'
|
||||
import { TAB_ID as USER_INPUT_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/userInputs'
|
||||
|
||||
import { TAB_ID as SCHED_ACTION_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/scheduledActions'
|
||||
|
||||
import { VM_SCHED_SCHEMA as SCHED_ACTION_SCHEMA } from '@modules/components/Forms/Vm/CreateSchedActionForm/schema'
|
||||
|
||||
import { ADVANCED_PARAMS_SCHEMA } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/schema'
|
||||
import { TAB_ID as ADVANCED_PARAMS_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams'
|
||||
|
||||
export const SCHEMA = object().concat(
|
||||
object().shape({
|
||||
[NETWORK_ID]: array().of(NETWORK_INPUT_SCHEMA),
|
||||
[NETWORK_DROPDOWN_ID]: array().of(NETWORKS_EXTRA_SCHEMA),
|
||||
[USER_INPUT_ID]: array().of(USER_INPUTS_SCHEMA),
|
||||
[SCHED_ACTION_ID]: array().of(SCHED_ACTION_SCHEMA),
|
||||
[ADVANCED_PARAMS_ID]: ADVANCED_PARAMS_SCHEMA,
|
||||
})
|
||||
.shape({
|
||||
CUSTOM_ATTRIBUTES: array().of(CUSTOM_ATTRIBUTES_SCHEMA),
|
||||
})
|
||||
.concat(ADVANCED_PARAMS_SCHEMA)
|
||||
.concat(SCHED_ACTION_SCHEMA)
|
||||
)
|
||||
|
@ -0,0 +1,203 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { useState, useEffect } from 'react'
|
||||
import { useFormContext, useFieldArray } from 'react-hook-form'
|
||||
import { ServerConnection as NetworkIcon, Cancel } from 'iconoir-react'
|
||||
import { USER_INPUTS_FIELDS } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/userInputs/schema'
|
||||
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
|
||||
import { FormWithSchema } from '@modules/components/Forms'
|
||||
|
||||
import { Tr } from '@modules/components/HOC'
|
||||
import { Stack, Button, Grid, List, ListItem, IconButton } from '@mui/material'
|
||||
import { T } from '@ConstantsModule'
|
||||
|
||||
export const TAB_ID = 'user_inputs'
|
||||
|
||||
const Content = () => {
|
||||
const [selectedUInput, setSelectedUInput] = useState(0)
|
||||
const [shift, setShift] = useState(0)
|
||||
|
||||
const { watch } = useFormContext()
|
||||
|
||||
const wUserInputs = watch(`${EXTRA_ID}.${TAB_ID}`)
|
||||
|
||||
const {
|
||||
fields: userinputs,
|
||||
remove,
|
||||
append,
|
||||
} = useFieldArray({
|
||||
name: `${EXTRA_ID}.${TAB_ID}`,
|
||||
})
|
||||
|
||||
const handleRemove = (event, idx) => {
|
||||
event.stopPropagation()
|
||||
|
||||
// Calculates shift & releases current reference in case it goes oob
|
||||
setSelectedUInput((prev) => {
|
||||
setShift(prev + (userinputs?.length === 2 ? -+prev : idx < prev ? -1 : 0))
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
remove(idx)
|
||||
}
|
||||
|
||||
const handleAppend = (event) => {
|
||||
event?.stopPropagation?.()
|
||||
|
||||
setSelectedUInput(() => {
|
||||
setShift(null)
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
append({
|
||||
type: '',
|
||||
mandatory: false,
|
||||
name: '',
|
||||
description: '',
|
||||
options: '',
|
||||
default: '',
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedUInput === null) {
|
||||
if (shift === null) {
|
||||
setSelectedUInput(userinputs?.length - 1)
|
||||
} else {
|
||||
setSelectedUInput(shift)
|
||||
}
|
||||
}
|
||||
}, [userinputs])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
columnSpacing={1}
|
||||
rowSpacing={2}
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'stretch',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Grid item md={3} sx={{ borderRight: 1, padding: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
type="submit"
|
||||
size="large"
|
||||
data-cy={'extra-add-userinput'}
|
||||
onClick={handleAppend}
|
||||
>
|
||||
{Tr(T.AddUserInput)}
|
||||
</Button>
|
||||
<List
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{userinputs?.map((userinput, idx) => {
|
||||
const userinputName = watch(`${EXTRA_ID}.${TAB_ID}.${idx}.name`)
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={`${idx}-${userinput?.id}-${userinput?.name}`}
|
||||
onClick={() => setSelectedUInput(idx)}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: '4px',
|
||||
minHeight: '70px',
|
||||
my: 0.5,
|
||||
overflowX: 'hidden',
|
||||
padding: 2,
|
||||
|
||||
bgcolor:
|
||||
idx === selectedUInput ? 'action.selected' : 'inherit',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{
|
||||
<IconButton
|
||||
aria-label="delete"
|
||||
onClick={(event) => handleRemove(event, idx)}
|
||||
sx={{ mr: 1.5, size: 'small' }}
|
||||
>
|
||||
<Cancel />
|
||||
</IconButton>
|
||||
}
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
maxWidth: '100%',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontSize: '1em',
|
||||
}}
|
||||
>
|
||||
{userinputName || T.NewUserInput}
|
||||
</div>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</Grid>
|
||||
<Grid item md={9}>
|
||||
{selectedUInput != null && wUserInputs?.length > 0 && (
|
||||
<Stack
|
||||
key={`inputs-${userinputs?.[selectedUInput]?.id}`}
|
||||
direction="column"
|
||||
alignItems="flex-start"
|
||||
gap="0.5rem"
|
||||
component="form"
|
||||
width="100%"
|
||||
>
|
||||
<FormWithSchema
|
||||
id={`${EXTRA_ID}.${TAB_ID}.${selectedUInput}`}
|
||||
legend={T.Type}
|
||||
fields={USER_INPUTS_FIELDS}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Content.propTypes = {
|
||||
stepId: PropTypes.string,
|
||||
}
|
||||
|
||||
const TAB = {
|
||||
id: TAB_ID,
|
||||
name: T.UserInputs,
|
||||
icon: NetworkIcon,
|
||||
Content,
|
||||
getError: (error) => !!error?.[TAB_ID],
|
||||
}
|
||||
|
||||
export default TAB
|
@ -0,0 +1,288 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { mixed, string, boolean, number, array, lazy } from 'yup'
|
||||
import {
|
||||
getObjectSchemaFromFields,
|
||||
arrayToOptions,
|
||||
sentenceCase,
|
||||
} from '@UtilsModule'
|
||||
import { INPUT_TYPES, T } from '@ConstantsModule'
|
||||
|
||||
// Define the UI types
|
||||
const UI_TYPES = {
|
||||
password: 'password',
|
||||
number: 'number',
|
||||
numberfloat: 'number-float',
|
||||
range: 'range',
|
||||
rangefloat: 'range-float',
|
||||
list: 'list',
|
||||
listmultiple: 'list-multiple',
|
||||
boolean: 'boolean',
|
||||
text: 'text',
|
||||
text64: 'text64',
|
||||
}
|
||||
|
||||
const SPLITS = 2
|
||||
|
||||
const DISPLAY_OPTIONS = [
|
||||
UI_TYPES.range,
|
||||
UI_TYPES.rangefloat,
|
||||
UI_TYPES.list,
|
||||
UI_TYPES.listmultiple,
|
||||
]
|
||||
|
||||
const getType = (type) => {
|
||||
switch (type) {
|
||||
case UI_TYPES.boolean:
|
||||
return {
|
||||
html: 'text',
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
values: ['YES', 'NO'],
|
||||
optionsOnly: true,
|
||||
validation: string()
|
||||
.trim()
|
||||
.notRequired()
|
||||
.default(() => undefined),
|
||||
}
|
||||
case UI_TYPES.list:
|
||||
case UI_TYPES.listmultiple:
|
||||
return {
|
||||
html: 'text',
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
multiple: true,
|
||||
validation: array()
|
||||
.of(string().trim())
|
||||
.notRequired()
|
||||
.default(() => []),
|
||||
}
|
||||
case UI_TYPES.range:
|
||||
return {
|
||||
html: 'number',
|
||||
label: `${T.Min}/${T.Max}`,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
split: SPLITS,
|
||||
grid: { md: 6 },
|
||||
validation: number()
|
||||
.integer()
|
||||
.isFinite()
|
||||
.notRequired()
|
||||
.default(() => undefined),
|
||||
}
|
||||
case UI_TYPES.rangefloat:
|
||||
return {
|
||||
html: 'number',
|
||||
label: `${T.Min}/${T.Max}`,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
split: 2,
|
||||
grid: { md: 6 },
|
||||
validation: number()
|
||||
.isFloat()
|
||||
.notRequired()
|
||||
.default(() => undefined),
|
||||
}
|
||||
case UI_TYPES.text:
|
||||
return {
|
||||
html: 'text',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
multiline: false,
|
||||
identifier: UI_TYPES.text,
|
||||
validation: string()
|
||||
.trim()
|
||||
.notRequired()
|
||||
.default(() => ''),
|
||||
}
|
||||
case UI_TYPES.text64:
|
||||
return {
|
||||
html: 'text',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
multiline: true,
|
||||
identifier: UI_TYPES.text64,
|
||||
validation: string()
|
||||
.trim()
|
||||
.isBase64()
|
||||
.notRequired()
|
||||
.default(() => ''),
|
||||
}
|
||||
case UI_TYPES.number:
|
||||
return {
|
||||
html: 'number',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
validation: number()
|
||||
.isFinite()
|
||||
.notRequired()
|
||||
.default(() => undefined),
|
||||
}
|
||||
case UI_TYPES.numberfloat:
|
||||
return {
|
||||
html: 'number',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
validation: number()
|
||||
.isFloat()
|
||||
.notRequired()
|
||||
.default(() => undefined),
|
||||
}
|
||||
case UI_TYPES.password:
|
||||
return {
|
||||
html: 'password',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
validation: string()
|
||||
.trim()
|
||||
.notRequired()
|
||||
.default(() => ''),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
html: 'text',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
validation: string()
|
||||
.trim()
|
||||
.notRequired()
|
||||
.default(() => ''),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const UI_TYPE = {
|
||||
name: 'type',
|
||||
label: T.Type,
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
values: arrayToOptions(Object.values(UI_TYPES), {
|
||||
addEmpty: false,
|
||||
getText: (type) => sentenceCase(type),
|
||||
}),
|
||||
grid: { md: 12 },
|
||||
validation: string()
|
||||
.trim()
|
||||
.notRequired()
|
||||
.oneOf(Object.values(UI_TYPES))
|
||||
.default(() => undefined),
|
||||
}
|
||||
|
||||
const MANDATORY = {
|
||||
name: 'mandatory',
|
||||
label: T.Mandatory,
|
||||
type: INPUT_TYPES.CHECKBOX,
|
||||
validation: boolean().default(() => false),
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
const NAME = {
|
||||
name: 'name',
|
||||
label: T.Name,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
validation: string()
|
||||
.trim()
|
||||
.uppercase()
|
||||
.matches(/^[A-Z0-9_]*$/, {
|
||||
message:
|
||||
'Name must only contain uppercase alphanumeric characters and no spaces',
|
||||
excludeEmptyString: true,
|
||||
})
|
||||
.notRequired()
|
||||
.default(() => ''),
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
const DESCRIPTION = {
|
||||
name: 'description',
|
||||
label: T.Description,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
validation: string()
|
||||
.trim()
|
||||
.notRequired()
|
||||
.default(() => undefined),
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
const OPTIONS = {
|
||||
name: 'options',
|
||||
label: (type) => getType(type)?.label ?? T.Options,
|
||||
dependOf: UI_TYPE.name,
|
||||
type: (type) => getType(type)?.type,
|
||||
htmlType: (type) =>
|
||||
DISPLAY_OPTIONS?.includes(type) ? getType(type)?.html : INPUT_TYPES.HIDDEN,
|
||||
values: (type) =>
|
||||
arrayToOptions(getType(type)?.values ?? [], { addEmpty: false }),
|
||||
splits: (type) => getType(type)?.split,
|
||||
identifier: (type) => getType(type)?.identifier,
|
||||
multiple: (type) => getType(type)?.multiple,
|
||||
optionsOnly: (type) => getType(type)?.optionsOnly,
|
||||
multiline: (type) => getType(type)?.multiline,
|
||||
grid: (type) => getType(type)?.grid ?? { md: 12 },
|
||||
watcher: (() => {
|
||||
let previousName = null
|
||||
let previousType = null
|
||||
|
||||
return (type, context) => {
|
||||
const { name, formContext: { setValue } = {} } = context
|
||||
|
||||
if (previousName !== name) {
|
||||
previousType = type
|
||||
previousName = name
|
||||
}
|
||||
|
||||
if (previousType !== type) {
|
||||
setValue?.(name, undefined)
|
||||
previousType = type
|
||||
}
|
||||
}
|
||||
})(),
|
||||
validation: lazy((_, { parent: { type } = {} } = {}) => {
|
||||
const validation = getType(type)?.validation
|
||||
|
||||
return validation
|
||||
}),
|
||||
}
|
||||
|
||||
const DEFAULT_VALUE = {
|
||||
name: 'default',
|
||||
label: T.DefaultValue,
|
||||
dependOf: UI_TYPE.name,
|
||||
type: (type) => getType(type)?.type,
|
||||
htmlType: (type) => getType(type)?.html,
|
||||
values: (type) =>
|
||||
arrayToOptions(getType(type)?.values ?? [], { addEmpty: false }),
|
||||
multiple: (type) => getType(type)?.multiple,
|
||||
optionsOnly: (type) => getType(type)?.optionsOnly,
|
||||
multiline: (type) => getType(type)?.multiline,
|
||||
validation: lazy((_, { parent: { type } = {} } = {}) => {
|
||||
const validation = getType(type)?.validation
|
||||
const isHidden = DISPLAY_OPTIONS?.includes(type)
|
||||
|
||||
if (isHidden) {
|
||||
return mixed()
|
||||
.notRequired()
|
||||
.afterSubmit(() => undefined)
|
||||
}
|
||||
|
||||
return validation
|
||||
}),
|
||||
|
||||
grid: { md: 12 },
|
||||
}
|
||||
export const USER_INPUTS_FIELDS = [
|
||||
UI_TYPE,
|
||||
MANDATORY,
|
||||
NAME,
|
||||
DESCRIPTION,
|
||||
OPTIONS,
|
||||
DEFAULT_VALUE,
|
||||
]
|
||||
|
||||
export const USER_INPUTS_SCHEMA = getObjectSchemaFromFields([
|
||||
...USER_INPUTS_FIELDS,
|
||||
{ ...OPTIONS, name: 'options_1' }, // matches split field name
|
||||
])
|
@ -38,7 +38,7 @@ const Content = ({ isUpdate }) => (
|
||||
* @returns {object} General configuration step
|
||||
*/
|
||||
const General = (data) => {
|
||||
const isUpdate = data?.ID
|
||||
const isUpdate = data?.dataTemplate?.ID
|
||||
|
||||
return {
|
||||
id: STEP_ID,
|
||||
|
@ -19,26 +19,24 @@ import { string } from 'yup'
|
||||
|
||||
/** @type {Field} Name field */
|
||||
const NAME_FIELD = {
|
||||
name: 'NAME',
|
||||
name: 'name',
|
||||
label: T.Name,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
validation: string()
|
||||
.trim()
|
||||
.min(3, 'Template name less than 3 characters')
|
||||
.max(128, 'Template name over 128 characters')
|
||||
.required('Name cannot be empty')
|
||||
.min(3)
|
||||
.required()
|
||||
.default(() => undefined),
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
/** @type {Field} Description field */
|
||||
const DESCRIPTION_FIELD = {
|
||||
name: 'DESCRIPTION',
|
||||
name: 'description',
|
||||
label: T.Description,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
validation: string()
|
||||
.trim()
|
||||
.max(128, 'Description over 128 characters')
|
||||
.test(
|
||||
'is-not-numeric',
|
||||
'Description should not be a numeric value',
|
||||
|
@ -1,112 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { useMemo, useEffect, Component } from 'react'
|
||||
import { useFormContext } from 'react-hook-form'
|
||||
import { ADVANCED_PARAMS_FIELDS } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters/schema'
|
||||
import { FormWithSchema } from '@modules/components/Forms'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
FormControl,
|
||||
Box,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from '@mui/material'
|
||||
import { T } from '@ConstantsModule'
|
||||
import { Tr } from '@modules/components/HOC'
|
||||
|
||||
export const SECTION_ID = 'ADVANCEDPARAMS'
|
||||
|
||||
/**
|
||||
* @param {object} root0 - props
|
||||
* @param {string} root0.stepId - Main step ID
|
||||
* @param {object} root0.roleConfigs - Roles config
|
||||
* @param {Function} root0.onChange - Callback handler
|
||||
* @returns {Component} - component
|
||||
*/
|
||||
const AdvancedParametersSection = ({ stepId, roleConfigs, onChange }) => {
|
||||
const { watch, setValue } = useFormContext()
|
||||
const { palette } = useTheme()
|
||||
const fields = useMemo(() => ADVANCED_PARAMS_FIELDS, [stepId])
|
||||
|
||||
useEffect(() => {
|
||||
setValue(
|
||||
`${stepId}.${SECTION_ID}.SHUTDOWNTYPE`,
|
||||
roleConfigs?.[SECTION_ID]?.[0] ?? ''
|
||||
)
|
||||
}, [roleConfigs])
|
||||
|
||||
const shutdownTypeValue = watch(`${stepId}.${SECTION_ID}.SHUTDOWNTYPE`)
|
||||
|
||||
useEffect(() => {
|
||||
if (shutdownTypeValue) {
|
||||
onChange('update', { [SECTION_ID]: shutdownTypeValue }, false)
|
||||
}
|
||||
}, [shutdownTypeValue])
|
||||
|
||||
if (fields.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
component="fieldset"
|
||||
sx={{ width: '100%', gridColumn: '1 / -1' }}
|
||||
>
|
||||
<Accordion>
|
||||
<AccordionSummary
|
||||
aria-controls="panel-content"
|
||||
id="panel-header"
|
||||
sx={{
|
||||
backgroundColor: palette?.background?.paper,
|
||||
filter: 'brightness(90%)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">{Tr(T.AdvancedParams)}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Box component="form" sx={{ flexGrow: 1, display: 'flex' }}>
|
||||
<FormWithSchema
|
||||
id={stepId}
|
||||
cy={'extra-networking'}
|
||||
fields={fields}
|
||||
rootProps={{ sx: { m: 0 } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
||||
AdvancedParametersSection.propTypes = {
|
||||
selectedRoleIndex: PropTypes.number,
|
||||
stepId: PropTypes.string,
|
||||
roleConfigs: PropTypes.object,
|
||||
onChange: PropTypes.func,
|
||||
}
|
||||
|
||||
export default AdvancedParametersSection
|
@ -1,203 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { yupResolver } from '@hookform/resolvers/yup'
|
||||
import { useMemo, Component } from 'react'
|
||||
import {
|
||||
useForm,
|
||||
useFieldArray,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from 'react-hook-form'
|
||||
import {
|
||||
createElasticityPolicyFields,
|
||||
createElasticityPoliciesSchema,
|
||||
ELASTICITY_TYPES,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies/schema'
|
||||
import { FormWithSchema } from '@modules/components/Forms'
|
||||
import { Translate, Tr } from '@modules/components/HOC'
|
||||
import { DeleteCircledOutline, AddCircledOutline } from 'iconoir-react'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
FormControl,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from '@mui/material'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import SubmitButton from '@modules/components/FormControl/SubmitButton'
|
||||
import { T } from '@ConstantsModule'
|
||||
|
||||
export const SECTION_ID = 'ELASTICITYPOLICIES'
|
||||
|
||||
/**
|
||||
* @param {object} root0 - props
|
||||
* @param {string} root0.stepId - Main step ID
|
||||
* @param {number} root0.selectedRoleIndex - Active role index
|
||||
* @returns {Component} - component
|
||||
*/
|
||||
const ElasticityPoliciesSection = ({ stepId, selectedRoleIndex }) => {
|
||||
const { palette } = useTheme()
|
||||
const fields = createElasticityPolicyFields()
|
||||
const schema = createElasticityPoliciesSchema()
|
||||
const { getValues } = useFormContext()
|
||||
|
||||
const { append, remove } = useFieldArray({
|
||||
name: useMemo(
|
||||
() => `${stepId}.${SECTION_ID}.${selectedRoleIndex}`,
|
||||
[stepId, selectedRoleIndex]
|
||||
),
|
||||
})
|
||||
|
||||
const methods = useForm({
|
||||
defaultValues: schema.default(),
|
||||
resolver: yupResolver(schema),
|
||||
})
|
||||
|
||||
const onSubmit = async (newPolicy) => {
|
||||
const isValid = await methods.trigger(`${stepId}.${SECTION_ID}`)
|
||||
if (isValid) {
|
||||
append(newPolicy)
|
||||
methods.reset()
|
||||
}
|
||||
}
|
||||
|
||||
const currentPolicies =
|
||||
getValues(`${stepId}.${SECTION_ID}.${selectedRoleIndex}`) ?? []
|
||||
|
||||
if (fields.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
component="fieldset"
|
||||
sx={{ width: '100%', gridColumn: '1 / -1' }}
|
||||
>
|
||||
<Accordion>
|
||||
<AccordionSummary
|
||||
aria-controls="panel-content"
|
||||
id="panel-header"
|
||||
data-cy="roleconfig-elasticitypolicies-accordion"
|
||||
sx={{
|
||||
backgroundColor: palette?.background?.paper,
|
||||
filter: 'brightness(90%)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">{Tr(T.ElasticityPolicies)}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<FormProvider {...methods}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={methods.handleSubmit(onSubmit)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<FormWithSchema fields={fields} rootProps={{ sx: { m: 0 } }} />
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
type="submit"
|
||||
color="secondary"
|
||||
startIcon={<AddCircledOutline />}
|
||||
data-cy={'roleconfig-elasticitypolicies'}
|
||||
sx={{ width: '100%', mt: 2 }}
|
||||
>
|
||||
<Translate word={T.Add} />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<List sx={{ mt: 2 }}>
|
||||
{currentPolicies.map(
|
||||
(
|
||||
{
|
||||
TYPE,
|
||||
ADJUST,
|
||||
MIN,
|
||||
COOLDOWN,
|
||||
PERIOD_NUMBER,
|
||||
PERIOD,
|
||||
EXPRESSION,
|
||||
},
|
||||
index
|
||||
) => {
|
||||
const secondaryFields = [
|
||||
EXPRESSION && `${Tr(T.Expression)}: ${EXPRESSION}`,
|
||||
ADJUST && `${Tr(T.Adjust)}: ${ADJUST}`,
|
||||
COOLDOWN && `${Tr(T.Cooldown)}: ${COOLDOWN}`,
|
||||
PERIOD && `${Tr(T.Period)}: ${PERIOD}`,
|
||||
PERIOD_NUMBER && `#: ${PERIOD_NUMBER}`,
|
||||
].filter(Boolean)
|
||||
if (MIN !== undefined && TYPE === 'PERCENTAGE_CHANGE') {
|
||||
secondaryFields.push(`${Tr(T.Min)}: ${MIN}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
>
|
||||
<ListItem
|
||||
secondaryAction={
|
||||
<SubmitButton
|
||||
onClick={() => remove(index)}
|
||||
icon={<DeleteCircledOutline />}
|
||||
/>
|
||||
}
|
||||
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={Tr(ELASTICITY_TYPES?.[TYPE])}
|
||||
primaryTypographyProps={{ variant: 'body1' }}
|
||||
secondary={secondaryFields.join(' | ')}
|
||||
/>
|
||||
</ListItem>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</List>
|
||||
</FormProvider>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
||||
ElasticityPoliciesSection.propTypes = {
|
||||
stepId: PropTypes.string,
|
||||
selectedRoleIndex: PropTypes.number,
|
||||
}
|
||||
|
||||
export default ElasticityPoliciesSection
|
@ -1,146 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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, number } from 'yup'
|
||||
import { getValidationFromFields, arrayToOptions } from '@UtilsModule'
|
||||
import { INPUT_TYPES, T } from '@ConstantsModule'
|
||||
|
||||
// Define the CA types
|
||||
export const ELASTICITY_TYPES = {
|
||||
CHANGE: 'Change',
|
||||
CARDINALITY: 'Cardinality',
|
||||
PERCENTAGE_CHANGE: 'Percentage',
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates fields for elasticity policies schema based on a path prefix.
|
||||
*
|
||||
* @param {string} pathPrefix - Path prefix for field names.
|
||||
* @returns {object[]} - Array of field definitions for elasticity policies.
|
||||
*/
|
||||
export const createElasticityPolicyFields = (pathPrefix) => {
|
||||
const getPath = (fieldName) =>
|
||||
pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName
|
||||
|
||||
return [
|
||||
{
|
||||
name: getPath('TYPE'),
|
||||
label: T.Type,
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
optionsOnly: true,
|
||||
cy: 'roleconfig-elasticitypolicies',
|
||||
values: arrayToOptions(Object.keys(ELASTICITY_TYPES), {
|
||||
addEmpty: false,
|
||||
getText: (key) => ELASTICITY_TYPES?.[key],
|
||||
getValue: (key) => key,
|
||||
}),
|
||||
validation: string()
|
||||
.trim()
|
||||
.required()
|
||||
.oneOf(Object.keys(ELASTICITY_TYPES))
|
||||
.default(() => Object.keys(ELASTICITY_TYPES)[0]),
|
||||
grid: { xs: 12, sm: 6, md: 6 },
|
||||
},
|
||||
{
|
||||
name: getPath('ADJUST'),
|
||||
label: T.Adjust,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-elasticitypolicies',
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
validation: number().required(),
|
||||
grid: { xs: 12, sm: 6, md: 6 },
|
||||
},
|
||||
{
|
||||
name: getPath('MIN'),
|
||||
label: T.Min,
|
||||
dependOf: getPath('TYPE'),
|
||||
htmlType: (type) =>
|
||||
// ONLY DISPLAY ON PERCENTAGE_CHANGE
|
||||
type !== Object.keys(ELASTICITY_TYPES)[2] && INPUT_TYPES.HIDDEN,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-elasticitypolicies',
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
validation: number().when(getPath('TYPE'), {
|
||||
is: (type) => type === Object.keys(ELASTICITY_TYPES)[2],
|
||||
then: number().required(),
|
||||
otherwise: number().notRequired().nullable(),
|
||||
}),
|
||||
grid: { xs: 12, sm: 6, md: 6 },
|
||||
},
|
||||
{
|
||||
name: getPath('EXPRESSION'),
|
||||
dependOf: getPath('TYPE'),
|
||||
label: T.Expression,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-elasticitypolicies',
|
||||
validation: string().trim().required(),
|
||||
grid: (type) => ({
|
||||
xs: 12,
|
||||
...(type !== Object.keys(ELASTICITY_TYPES)[2]
|
||||
? { sm: 12, md: 12 }
|
||||
: { sm: 6, md: 6 }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: getPath('PERIOD_NUMBER'),
|
||||
label: '#',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-elasticitypolicies',
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
validation: number(),
|
||||
grid: { xs: 12, sm: 6, md: 4 },
|
||||
},
|
||||
{
|
||||
name: getPath('PERIOD'),
|
||||
label: 'Period',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-elasticitypolicies',
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
validation: number(),
|
||||
grid: { xs: 12, sm: 6, md: 4 },
|
||||
},
|
||||
{
|
||||
name: getPath('COOLDOWN'),
|
||||
label: 'Cooldown',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-elasticitypolicies',
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
validation: number(),
|
||||
grid: { xs: 12, sm: 12, md: 4 },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Yup schema for elasticity policies based on a given path prefix.
|
||||
*
|
||||
* @param {string} pathPrefix - Path prefix for field names in the schema.
|
||||
* @returns {object} - Yup schema object for elasticity policies.
|
||||
*/
|
||||
export const createElasticityPoliciesSchema = (pathPrefix) => {
|
||||
const fields = createElasticityPolicyFields(pathPrefix)
|
||||
|
||||
return object(getValidationFromFields(fields))
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { Component, useMemo } from 'react'
|
||||
import { Box, FormControl } from '@mui/material'
|
||||
import { createMinMaxVmsFields } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms/schema'
|
||||
import { FormWithSchema } from '@modules/components/Forms'
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form'
|
||||
|
||||
export const SECTION_ID = 'MINMAXVMS'
|
||||
|
||||
/**
|
||||
* @param {object} root0 - props
|
||||
* @param {string} root0.stepId - Main step ID
|
||||
* @param {number} root0.selectedRoleIndex - Active role index
|
||||
* @returns {Component} - component
|
||||
*/
|
||||
const MinMaxVms = ({ stepId, selectedRoleIndex }) => {
|
||||
const { control } = useFormContext()
|
||||
|
||||
const fields = createMinMaxVmsFields(
|
||||
`${stepId}.${SECTION_ID}.${selectedRoleIndex}`
|
||||
)
|
||||
|
||||
useFieldArray({
|
||||
name: useMemo(() => `${stepId}.${SECTION_ID}`, [stepId, selectedRoleIndex]),
|
||||
control: control,
|
||||
})
|
||||
|
||||
if (fields.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<FormControl
|
||||
component="fieldset"
|
||||
sx={{ width: '100%', gridColumn: '1 / -1' }}
|
||||
>
|
||||
<FormWithSchema fields={fields} rootProps={{ sx: { m: 0 } }} />
|
||||
</FormControl>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
MinMaxVms.propTypes = {
|
||||
stepId: PropTypes.string,
|
||||
selectedRoleIndex: PropTypes.number,
|
||||
}
|
||||
|
||||
export default MinMaxVms
|
@ -1,89 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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, number } from 'yup'
|
||||
import { getValidationFromFields } from '@UtilsModule'
|
||||
import { INPUT_TYPES, T } from '@ConstantsModule'
|
||||
|
||||
const MAX_VALUE = 999999
|
||||
|
||||
/**
|
||||
* Creates fields for minmax vms schema based on a path prefix.
|
||||
*
|
||||
* @param {string} pathPrefix - Path prefix for field names.
|
||||
* @returns {object[]} - Array of field definitions for minmax vms.
|
||||
*/
|
||||
export const createMinMaxVmsFields = (pathPrefix) => {
|
||||
const getPath = (fieldName) =>
|
||||
pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName
|
||||
|
||||
return [
|
||||
{
|
||||
name: getPath('min_vms'),
|
||||
label: T.RolesMinVms,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'elasticity',
|
||||
validation: number()
|
||||
.integer('Min VMs must be an integer')
|
||||
.default(() => undefined),
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
grid: { sm: 4, md: 4 },
|
||||
},
|
||||
{
|
||||
name: getPath('max_vms'),
|
||||
label: T.RolesMaxVms,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'elasticity',
|
||||
validation: number()
|
||||
.integer('Max VMs must be an integer')
|
||||
.max(MAX_VALUE, `Max VMs cannot exceed ${MAX_VALUE}`)
|
||||
.default(() => undefined),
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
grid: { sm: 4, md: 4 },
|
||||
},
|
||||
{
|
||||
name: getPath('cooldown'),
|
||||
label: T.Cooldown,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'elasticity',
|
||||
validation: number()
|
||||
.integer('Cooldown must be an integer')
|
||||
.min(0, 'Cooldown cannot be less than 0')
|
||||
.max(MAX_VALUE, `Cooldown exceed ${MAX_VALUE}`)
|
||||
.default(() => undefined),
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
grid: { sm: 4, md: 4 },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Yup schema for minmax vms based on a given path prefix.
|
||||
*
|
||||
* @param {string} pathPrefix - Path prefix for field names in the schema.
|
||||
* @returns {object} - Yup schema object for minmax vms.
|
||||
*/
|
||||
export const createMinMaxVmsSchema = (pathPrefix) => {
|
||||
const fields = createMinMaxVmsFields(pathPrefix)
|
||||
|
||||
return object(getValidationFromFields(fields))
|
||||
}
|
@ -1,197 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { Component, useMemo } from 'react'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import {
|
||||
useFormContext,
|
||||
useForm,
|
||||
useFieldArray,
|
||||
FormProvider,
|
||||
} from 'react-hook-form'
|
||||
import {
|
||||
createScheduledPolicyFields,
|
||||
createScheduledPoliciesSchema,
|
||||
SCHED_TYPES,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies/schema'
|
||||
import { FormWithSchema } from '@modules/components/Forms'
|
||||
import { Translate, Tr } from '@modules/components/HOC'
|
||||
import { DeleteCircledOutline, AddCircledOutline } from 'iconoir-react'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
FormControl,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from '@mui/material'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import SubmitButton from '@modules/components/FormControl/SubmitButton'
|
||||
import { T } from '@ConstantsModule'
|
||||
|
||||
export const SECTION_ID = 'SCHEDULEDPOLICIES'
|
||||
|
||||
/**
|
||||
* @param {object} root0 - props
|
||||
* @param {string} root0.stepId - Main step ID
|
||||
* @param {number} root0.selectedRoleIndex - Active role index
|
||||
* @returns {Component} - component
|
||||
*/
|
||||
const ScheduledPoliciesSection = ({ stepId, selectedRoleIndex }) => {
|
||||
const { palette } = useTheme()
|
||||
const fields = createScheduledPolicyFields()
|
||||
const schema = createScheduledPoliciesSchema()
|
||||
const { getValues } = useFormContext()
|
||||
|
||||
const { append, remove } = useFieldArray({
|
||||
name: useMemo(
|
||||
() => `${stepId}.${SECTION_ID}.${selectedRoleIndex}`,
|
||||
[stepId, selectedRoleIndex]
|
||||
),
|
||||
})
|
||||
|
||||
const methods = useForm({
|
||||
defaultValues: schema.default(),
|
||||
resolver: yupResolver(schema),
|
||||
})
|
||||
|
||||
const onSubmit = async (newPolicy) => {
|
||||
const isValid = await methods.trigger(`${stepId}.${SECTION_ID}`)
|
||||
if (isValid) {
|
||||
append(newPolicy)
|
||||
methods.reset()
|
||||
}
|
||||
}
|
||||
|
||||
const currentPolicies =
|
||||
getValues(`${stepId}.${SECTION_ID}.${selectedRoleIndex}`) ?? []
|
||||
|
||||
if (fields.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
component="fieldset"
|
||||
sx={{ width: '100%', gridColumn: '1 / -1' }}
|
||||
>
|
||||
<Accordion>
|
||||
<AccordionSummary
|
||||
aria-controls="panel-content"
|
||||
id="panel-header"
|
||||
data-cy="roleconfig-scheduledpolicies-accordion"
|
||||
sx={{
|
||||
backgroundColor: palette?.background?.paper,
|
||||
filter: 'brightness(90%)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">{Tr(T.ScheduledPolicies)}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<FormProvider {...methods}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={methods.handleSubmit(onSubmit)}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
<FormWithSchema fields={fields} rootProps={{ sx: { m: 0 } }} />
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
type="submit"
|
||||
color="secondary"
|
||||
startIcon={<AddCircledOutline />}
|
||||
data-cy={'roleconfig-scheduledpolicies'}
|
||||
sx={{ width: '100%', mt: 2 }}
|
||||
>
|
||||
<Translate word={T.Add} />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<List>
|
||||
{currentPolicies.map(
|
||||
(
|
||||
{ TIMEFORMAT, SCHEDTYPE, ADJUST, MIN, TIMEEXPRESSION },
|
||||
index
|
||||
) => {
|
||||
const timeFormatTrans = Tr(TIMEFORMAT)
|
||||
|
||||
const secondaryFields = [
|
||||
TIMEEXPRESSION &&
|
||||
`${Tr(T.TimeExpression)}: ${TIMEEXPRESSION}`,
|
||||
ADJUST && `${Tr(T.Adjust)}: ${ADJUST}`,
|
||||
timeFormatTrans &&
|
||||
`${Tr(T.TimeFormat)}: ${timeFormatTrans}`,
|
||||
].filter(Boolean)
|
||||
|
||||
if (MIN !== undefined) {
|
||||
secondaryFields?.push(`${Tr(T.Min)}: ${MIN}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
>
|
||||
<ListItem
|
||||
secondaryAction={
|
||||
<SubmitButton
|
||||
onClick={() => remove(index)}
|
||||
icon={<DeleteCircledOutline />}
|
||||
/>
|
||||
}
|
||||
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={Tr(SCHED_TYPES?.[SCHEDTYPE])}
|
||||
primaryTypographyProps={{ variant: 'body1' }}
|
||||
secondary={secondaryFields.join(' | ')}
|
||||
/>
|
||||
</ListItem>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</List>
|
||||
</FormProvider>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
||||
ScheduledPoliciesSection.propTypes = {
|
||||
stepId: PropTypes.string,
|
||||
selectedRoleIndex: PropTypes.number,
|
||||
}
|
||||
|
||||
export default ScheduledPoliciesSection
|
@ -1,136 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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, number } from 'yup'
|
||||
import { getValidationFromFields, arrayToOptions } from '@UtilsModule'
|
||||
import { INPUT_TYPES, T } from '@ConstantsModule'
|
||||
|
||||
const TIME_TYPES = {
|
||||
none: '',
|
||||
recurrence: 'Recurrence',
|
||||
starttime: 'Start time',
|
||||
}
|
||||
|
||||
export const SCHED_TYPES = {
|
||||
CHANGE: 'Change',
|
||||
CARDINALITY: 'Cardinality',
|
||||
PERCENTAGE_CHANGE: 'Percentage',
|
||||
}
|
||||
/* eslint-disable no-useless-escape */
|
||||
const timeExpressionRegex =
|
||||
/^(\d{4}-\d{2}-\d{2}(?: [0-2]\d:[0-5]\d:[0-5]\d|\d{4}-\d{2}-\d{2}T[0-2]\d:[0-5]\d:[0-5]\dZ)?)$/
|
||||
|
||||
const cronExpressionRegex = /^([\d*\/,-]+ ){4}[\d*\/,-]+$/
|
||||
|
||||
/* eslint-enable no-useless-escape */
|
||||
|
||||
/**
|
||||
* Creates fields for scheduled policies schema based on a path prefix.
|
||||
*
|
||||
* @param {string} pathPrefix - Path prefix for field names.
|
||||
* @returns {object[]} - Array of field definitions for scheduled policies.
|
||||
*/
|
||||
export const createScheduledPolicyFields = (pathPrefix) => {
|
||||
const getPath = (fieldName) =>
|
||||
pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName
|
||||
|
||||
return [
|
||||
{
|
||||
name: getPath('SCHEDTYPE'),
|
||||
label: T.Type,
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
optionsOnly: true,
|
||||
cy: 'roleconfig-scheduledpolicies',
|
||||
values: arrayToOptions(Object.keys(SCHED_TYPES), {
|
||||
addEmpty: false,
|
||||
getText: (key) => SCHED_TYPES?.[key],
|
||||
getValue: (key) => key,
|
||||
}),
|
||||
validation: string()
|
||||
.trim()
|
||||
.oneOf(Object.keys(SCHED_TYPES))
|
||||
.default(() => Object.keys(SCHED_TYPES)[0]),
|
||||
grid: { xs: 12, sm: 6, md: 3.3 },
|
||||
},
|
||||
{
|
||||
name: getPath('ADJUST'),
|
||||
label: T.Adjust,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-scheduledpolicies',
|
||||
validation: string()
|
||||
.trim()
|
||||
.default(() => ''),
|
||||
grid: { xs: 12, sm: 6, md: 3.1 },
|
||||
},
|
||||
{
|
||||
name: getPath('MIN'),
|
||||
label: T.Min,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-scheduledpolicies',
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
validation: number().notRequired(),
|
||||
grid: { xs: 12, sm: 6, md: 2.1 },
|
||||
},
|
||||
{
|
||||
name: getPath('TIMEFORMAT'),
|
||||
label: T.TimeFormat,
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
optionsOnly: true,
|
||||
cy: 'roleconfig-scheduledpolicies',
|
||||
values: arrayToOptions(Object.values(TIME_TYPES), { addEmpty: false }),
|
||||
validation: string()
|
||||
.trim()
|
||||
.required()
|
||||
.oneOf(Object.values(TIME_TYPES))
|
||||
.default(() => Object.values(TIME_TYPES)[0]),
|
||||
grid: { xs: 12, sm: 6, md: 3.5 },
|
||||
},
|
||||
{
|
||||
name: getPath('TIMEEXPRESSION'),
|
||||
label: T.TimeExpression,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-scheduledpolicies',
|
||||
validation: string()
|
||||
.trim()
|
||||
.when(getPath('TIMEFORMAT'), {
|
||||
is: 'Start time',
|
||||
then: string().matches(
|
||||
timeExpressionRegex,
|
||||
'Time Expression must be in the format YYYY-MM-DD hh:mm:ss or YYYY-MM-DDThh:mm:ssZ'
|
||||
),
|
||||
otherwise: string().matches(
|
||||
cronExpressionRegex,
|
||||
'Time Expression must be a valid CRON expression'
|
||||
),
|
||||
})
|
||||
.required(),
|
||||
grid: { xs: 12, sm: 12, md: 12 },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Yup schema for scheduled policies based on a given path prefix.
|
||||
*
|
||||
* @param {string} pathPrefix - Path prefix for field names in the schema.
|
||||
* @returns {object} - Yup schema object for scheduled policies.
|
||||
*/
|
||||
export const createScheduledPoliciesSchema = (pathPrefix) => {
|
||||
const fields = createScheduledPolicyFields(pathPrefix)
|
||||
|
||||
return object(getValidationFromFields(fields))
|
||||
}
|
@ -1,308 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { useState, useRef, Component } from 'react'
|
||||
import _ from 'lodash'
|
||||
import { useFormContext, useForm, FormProvider } from 'react-hook-form'
|
||||
import { Box, Button, Grid } from '@mui/material'
|
||||
import { SCHEMA } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/schema'
|
||||
import RoleColumn from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesColumn'
|
||||
import RoleSummary from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/roleSummary'
|
||||
import RoleNetwork from './roleNetwork'
|
||||
import { STEP_ID as ROLE_DEFINITION_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles'
|
||||
import ElasticityPoliciesSection from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies'
|
||||
import ScheduledPoliciesSection from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies'
|
||||
import AdvancedParametersSection from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters'
|
||||
import VmTemplatesPanel from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/vmTemplatesPanel'
|
||||
import MinMaxSection from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms'
|
||||
import { Translate } from '@modules/components/HOC'
|
||||
import FormWithSchema from '@modules/components/Forms/FormWithSchema'
|
||||
import { Legend } from '@modules/components/Forms'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import { INPUT_TYPES, T } from '@ConstantsModule'
|
||||
import { AddCircledOutline } from 'iconoir-react'
|
||||
import { Field, getObjectSchemaFromFields } from '@UtilsModule'
|
||||
import { string, number } from 'yup'
|
||||
|
||||
export const STEP_ID = 'roleconfig'
|
||||
|
||||
/** @type {Field} STANDALONE Name field */
|
||||
const STANDALONE_NAME_FIELD = {
|
||||
name: `${STEP_ID}.name`,
|
||||
label: T.Name,
|
||||
cy: 'role',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
validation: string()
|
||||
.trim()
|
||||
.min(3, 'Role name cannot be less than 3 characters')
|
||||
.max(128, 'Role name cannot be over 128 characters')
|
||||
.required('Role name cannot be empty')
|
||||
.default(() => undefined),
|
||||
grid: { md: 8 },
|
||||
}
|
||||
|
||||
/** @type {Field} STANDALONE Cardinality field */
|
||||
const STANDALONE_CARDINALITY_FIELD = {
|
||||
name: `${STEP_ID}.cardinality`,
|
||||
label: T.NumberOfVms,
|
||||
cy: 'role',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
validation: number()
|
||||
.positive('Number of VMs must be positive')
|
||||
.default(() => 1),
|
||||
grid: { md: 4 },
|
||||
}
|
||||
|
||||
const STANDALONE_SCHEMA = getObjectSchemaFromFields([
|
||||
STANDALONE_NAME_FIELD,
|
||||
STANDALONE_CARDINALITY_FIELD,
|
||||
])
|
||||
|
||||
/**
|
||||
* @param {object} root0 - Props
|
||||
* @param {boolean} root0.standaloneModal - Run as standalone modal
|
||||
* @param {Function} root0.standaloneModalCallback - API callback function
|
||||
* @param {object} root0.fetchedVmTemplates - Fetched VM templates
|
||||
* @returns {Component} - Role configuration component
|
||||
*/
|
||||
export const Content = ({
|
||||
standaloneModal = false,
|
||||
standaloneModalCallback = () => {},
|
||||
fetchedVmTemplates = {},
|
||||
}) => {
|
||||
const { vmTemplates, error } = fetchedVmTemplates
|
||||
const [standaloneRole, setStandaloneRole] = useState([
|
||||
{ SELECTED_VM_TEMPLATE_ID: [] },
|
||||
])
|
||||
|
||||
const HANDLE_VM_SELECT_STANDALONE_ROLE = (updatedRole) => {
|
||||
setStandaloneRole(updatedRole)
|
||||
}
|
||||
|
||||
const formMethods = standaloneModal
|
||||
? useForm({
|
||||
defaultValues: SCHEMA.concat(STANDALONE_SCHEMA).default(),
|
||||
resolver: yupResolver(STANDALONE_SCHEMA),
|
||||
mode: 'onChange',
|
||||
})
|
||||
: useFormContext()
|
||||
const { getValues, setValue } = formMethods
|
||||
|
||||
const handleAddRoleClick = async () => {
|
||||
const role = getValues(STEP_ID)
|
||||
|
||||
const formatRole = {
|
||||
name: role?.name,
|
||||
cardinality: role?.cardinality,
|
||||
vm_template: standaloneRole?.SELECTED_VM_TEMPLATE_ID?.[0],
|
||||
...(role?.ADVANCEDPARAMS?.SHUTDOWNTYPE && {
|
||||
shutdown_type: role.ADVANCEDPARAMS.SHUTDOWNTYPE,
|
||||
}),
|
||||
min_vms: +role?.MINMAXVMS?.[0]?.min_vms,
|
||||
max_vms: +role?.MINMAXVMS?.[0]?.max_vms,
|
||||
cooldown: role?.MINMAXVMS?.[0]?.cooldown,
|
||||
...(role?.ELASTICITYPOLICIES && {
|
||||
elasticity_policies: role?.ELASTICITYPOLICIES?.[0],
|
||||
}),
|
||||
|
||||
...(role?.SCHEDULEDPOLICIES && {
|
||||
scheduled_policies: role?.SCHEDULEDPOLICIES?.[0],
|
||||
}),
|
||||
}
|
||||
standaloneModalCallback({ role: formatRole })
|
||||
}
|
||||
|
||||
const definedConfigs = getValues(`${STEP_ID}.ROLES`)
|
||||
const roleConfigs = useRef(definedConfigs ?? [])
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
const syncFormState = () => {
|
||||
setValue(`${STEP_ID}.ROLES`, roleConfigs.current)
|
||||
}
|
||||
|
||||
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0)
|
||||
const roles = getValues(ROLE_DEFINITION_ID)
|
||||
|
||||
const handleConfigChange = (operationType, config, shouldReset = false) => {
|
||||
const configKey = Object.keys(config)[0]
|
||||
const configValue = Object.values(config)[0]
|
||||
|
||||
_.defaultsDeep(roleConfigs.current, {
|
||||
[selectedRoleIndex]: { [configKey]: [] },
|
||||
})
|
||||
|
||||
switch (operationType) {
|
||||
case 'add':
|
||||
_.get(roleConfigs.current, [selectedRoleIndex, configKey]).push(
|
||||
configValue
|
||||
)
|
||||
break
|
||||
case 'remove':
|
||||
_.remove(
|
||||
_.get(roleConfigs.current, [selectedRoleIndex, configKey]),
|
||||
(_v, index) => index === configValue
|
||||
)
|
||||
break
|
||||
case 'update':
|
||||
_.set(
|
||||
roleConfigs.current,
|
||||
[selectedRoleIndex, configKey, 0],
|
||||
configValue
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
syncFormState()
|
||||
}
|
||||
|
||||
const ComponentContent = (
|
||||
<Grid mt={2} container>
|
||||
{!standaloneModal && (
|
||||
<Grid item xs={2.2}>
|
||||
<RoleColumn
|
||||
roles={roles}
|
||||
selectedRoleIndex={selectedRoleIndex}
|
||||
setSelectedRoleIndex={setSelectedRoleIndex}
|
||||
disableModify={true}
|
||||
onChange={handleConfigChange}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={standaloneModal ? 12 : 7}>
|
||||
<Box
|
||||
margin={1}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{!standaloneModal && (
|
||||
<RoleNetwork
|
||||
stepId={STEP_ID}
|
||||
selectedRoleIndex={selectedRoleIndex}
|
||||
totalRoles={roles?.length}
|
||||
/>
|
||||
)}
|
||||
<Box mt={2}>
|
||||
<Legend title={T.RoleElasticity} />
|
||||
{standaloneModal && (
|
||||
<Box>
|
||||
<FormWithSchema
|
||||
fields={[STANDALONE_NAME_FIELD, STANDALONE_CARDINALITY_FIELD]}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{standaloneModal && (
|
||||
<Box>
|
||||
<VmTemplatesPanel
|
||||
roles={[standaloneRole]}
|
||||
selectedRoleIndex={selectedRoleIndex}
|
||||
onChange={HANDLE_VM_SELECT_STANDALONE_ROLE}
|
||||
vmTemplates={vmTemplates}
|
||||
error={error}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<MinMaxSection
|
||||
stepId={STEP_ID}
|
||||
selectedRoleIndex={selectedRoleIndex}
|
||||
/>
|
||||
<ElasticityPoliciesSection
|
||||
stepId={STEP_ID}
|
||||
selectedRoleIndex={selectedRoleIndex}
|
||||
/>
|
||||
<ScheduledPoliciesSection
|
||||
stepId={STEP_ID}
|
||||
selectedRoleIndex={selectedRoleIndex}
|
||||
/>
|
||||
</Box>
|
||||
<Box mt={2}>
|
||||
<Legend title={T.Extra} />
|
||||
<AdvancedParametersSection
|
||||
stepId={STEP_ID}
|
||||
roleConfigs={roleConfigs.current?.[selectedRoleIndex]}
|
||||
onChange={handleConfigChange}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{standaloneModal && (
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
type="submit"
|
||||
color="secondary"
|
||||
startIcon={<AddCircledOutline />}
|
||||
data-cy={'roleconfig-addrole'}
|
||||
onClick={handleAddRoleClick}
|
||||
sx={{ width: '100%', mt: 2 }}
|
||||
>
|
||||
<Translate word={T.AddRole} />
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{!standaloneModal && (
|
||||
<Grid item xs={2.8}>
|
||||
<RoleSummary
|
||||
role={roles?.[selectedRoleIndex] ?? []}
|
||||
selectedRoleIndex={selectedRoleIndex}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
)
|
||||
|
||||
return standaloneModal ? (
|
||||
<FormProvider {...formMethods}>{ComponentContent}</FormProvider>
|
||||
) : (
|
||||
ComponentContent
|
||||
)
|
||||
}
|
||||
|
||||
Content.propTypes = {
|
||||
standaloneModal: PropTypes.bool,
|
||||
standaloneModalCallback: PropTypes.func,
|
||||
fetchedVmTemplates: PropTypes.object,
|
||||
}
|
||||
|
||||
/**
|
||||
* Role definition configuration.
|
||||
*
|
||||
* @returns {object} Roles definition configuration step
|
||||
*/
|
||||
const RoleConfig = () => ({
|
||||
id: STEP_ID,
|
||||
label: 'Role Configuration',
|
||||
resolver: SCHEMA,
|
||||
optionsValidate: { abortEarly: false },
|
||||
content: Content,
|
||||
})
|
||||
RoleConfig.propTypes = {
|
||||
data: PropTypes.array,
|
||||
setFormData: PropTypes.func,
|
||||
}
|
||||
|
||||
export default RoleConfig
|
@ -1,352 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { useTheme, Box, Checkbox, TextField, Autocomplete } from '@mui/material'
|
||||
import { css } from '@emotion/css'
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'
|
||||
import { useEffect, useState, useRef, useMemo, Component } from 'react'
|
||||
import { DataGrid } from '@mui/x-data-grid'
|
||||
import { T } from '@ConstantsModule'
|
||||
import { Tr } from '@modules/components/HOC'
|
||||
import { Legend } from '@modules/components/Forms'
|
||||
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
|
||||
import _ from 'lodash'
|
||||
|
||||
const useStyles = () => ({
|
||||
root: css({
|
||||
'& .MuiDataGrid-columnHeader:focus, & .MuiDataGrid-cell:focus': {
|
||||
outline: 'none !important',
|
||||
},
|
||||
'& .MuiDataGrid-columnHeader:focus-within, & .MuiDataGrid-cell:focus-within':
|
||||
{
|
||||
outline: 'none !important',
|
||||
},
|
||||
'& .MuiDataGrid-overlay': {
|
||||
top: '50% !important',
|
||||
left: '50% !important',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 'auto !important',
|
||||
height: 'auto !important',
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const SECTION_ID = 'NETWORKS'
|
||||
|
||||
/**
|
||||
* @param {object} root0 - Props
|
||||
* @param {string} root0.stepId - Step ID
|
||||
* @param {number} root0.selectedRoleIndex - Active role index
|
||||
* @returns {Component} - Component
|
||||
*/
|
||||
const RoleNetwork = ({ stepId, selectedRoleIndex }) => {
|
||||
const theme = useTheme()
|
||||
// Using a local state to keep track of the loading of initial row data
|
||||
// will overwrite modifications if stepId changes
|
||||
const loadInitialRowData = useRef({})
|
||||
const [networks, setNetworks] = useState([])
|
||||
const [fieldArrayLocation, setFieldArrayLocation] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setFieldArrayLocation(`${stepId}.${SECTION_ID}.${selectedRoleIndex}`)
|
||||
}, [selectedRoleIndex, SECTION_ID, stepId])
|
||||
|
||||
const classes = useMemo(() => useStyles(theme), [theme])
|
||||
const { control, getValues, setValue } = useFormContext()
|
||||
|
||||
const { fields, update } = useFieldArray({
|
||||
name: fieldArrayLocation,
|
||||
})
|
||||
|
||||
const watchedRdpConfig = useWatch({
|
||||
control,
|
||||
name: `${stepId}.RDP`,
|
||||
defaultValue: {},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const networkDefinitions = getValues(EXTRA_ID)?.NETWORKING ?? []
|
||||
const networkMap = networkDefinitions.map((network) => ({
|
||||
name: network?.name,
|
||||
description: network?.description,
|
||||
id: network?.network,
|
||||
extra: network?.netextra,
|
||||
type: network?.type,
|
||||
}))
|
||||
|
||||
setNetworks(networkMap)
|
||||
}, [getValues])
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
networks.map((config, index) => ({
|
||||
...config,
|
||||
id: index,
|
||||
idx: index, // RHF overwrites the ID prop with a UUID
|
||||
rowSelected: false,
|
||||
aliasSelected: false,
|
||||
aliasIdx: -1,
|
||||
network: config.name,
|
||||
})),
|
||||
[networks]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const existingRows = getValues(fieldArrayLocation)
|
||||
|
||||
const mergedRows = rows?.map((row) => {
|
||||
const existingRow = existingRows?.find((er) => er?.name === row?.name)
|
||||
|
||||
return existingRow ? _.merge({}, row, existingRow) : row
|
||||
})
|
||||
|
||||
setValue(fieldArrayLocation, mergedRows)
|
||||
|
||||
if (!loadInitialRowData.current?.[selectedRoleIndex] && rows?.length) {
|
||||
const reversedNetworkDefs = getValues(stepId)?.NETWORKDEFS ?? []
|
||||
|
||||
if (reversedNetworkDefs?.[selectedRoleIndex]) {
|
||||
reversedNetworkDefs?.[selectedRoleIndex]?.forEach((def) => {
|
||||
const rowName = def.NETWORK_ID.slice(1).toLowerCase()
|
||||
const rowToSelect = rows.find(
|
||||
(row) => row?.name?.toLowerCase() === rowName
|
||||
)
|
||||
|
||||
if (rowToSelect) {
|
||||
handleSelectRow(rowToSelect, true)
|
||||
|
||||
if (def.PARENT) {
|
||||
handleSelectAlias(rowToSelect)
|
||||
const parentNetwork = reversedNetworkDefs[
|
||||
selectedRoleIndex
|
||||
]?.find((network) => network?.NAME === def.PARENT)
|
||||
|
||||
if (parentNetwork) {
|
||||
const parentNetworkName =
|
||||
parentNetwork.NETWORK_ID.slice(1).toLowerCase()
|
||||
const parentRow = rows.find(
|
||||
(row) => row?.name?.toLowerCase() === parentNetworkName
|
||||
)
|
||||
|
||||
handleSetAlias(rowToSelect, parentRow?.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
loadInitialRowData.current[selectedRoleIndex] = true
|
||||
}
|
||||
}, [fieldArrayLocation])
|
||||
|
||||
const handleSetRdp = (row) => {
|
||||
const existing = getValues(`${stepId}.RDP`) || {}
|
||||
const updatedRdp = {
|
||||
...existing,
|
||||
[selectedRoleIndex]:
|
||||
typeof row === 'object' && row !== null ? row?.name : '',
|
||||
}
|
||||
setValue(`${stepId}.RDP`, updatedRdp)
|
||||
}
|
||||
|
||||
const handleSelectRow = (row, forceSelect = false) => {
|
||||
const fieldArray = getValues(fieldArrayLocation)
|
||||
const fieldArrayIndex = fieldArray?.findIndex((f) => f?.idx === row?.idx)
|
||||
const rowToggle = forceSelect
|
||||
? true
|
||||
: !fieldArray?.[fieldArrayIndex]?.rowSelected
|
||||
|
||||
if (
|
||||
// if rowSelected === true, its being deselected
|
||||
row?.rowSelected &&
|
||||
getValues(`${stepId}.RDP`)?.[selectedRoleIndex] === row?.name
|
||||
) {
|
||||
handleSetRdp(null) // Deselect
|
||||
}
|
||||
|
||||
const updatedFieldArray = fieldArray?.map((f, index) => {
|
||||
if (index === fieldArrayIndex) {
|
||||
return { ...f, rowSelected: rowToggle, aliasSelected: false }
|
||||
}
|
||||
|
||||
return f
|
||||
})
|
||||
|
||||
setValue(fieldArrayLocation, updatedFieldArray)
|
||||
}
|
||||
|
||||
const handleSelectAlias = (row) => {
|
||||
const fieldArray = getValues(fieldArrayLocation)
|
||||
const fieldArrayIndex = fieldArray?.findIndex((f) => f?.idx === row?.idx)
|
||||
const aliasToggle = !fieldArray?.[fieldArrayIndex]?.aliasSelected
|
||||
const aliasIdx = !fieldArray?.[fieldArrayIndex]?.aliasIdx
|
||||
update(fieldArrayIndex, {
|
||||
...fieldArray?.[fieldArrayIndex],
|
||||
aliasSelected: aliasToggle,
|
||||
aliasIdx: !aliasToggle ? -1 : aliasIdx,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSetAlias = (row, aliasName) => {
|
||||
const fieldArray = getValues(fieldArrayLocation)
|
||||
const aliasIndex = fieldArray?.findIndex((f) => f?.network === aliasName)
|
||||
const fieldArrayIndex = fieldArray?.findIndex((f) => f?.idx === row?.idx)
|
||||
update(fieldArrayIndex, {
|
||||
...fieldArray?.[fieldArrayIndex],
|
||||
aliasIdx: aliasIndex,
|
||||
})
|
||||
}
|
||||
|
||||
// Transalte before useMemo because Tr could not be inside useMemo
|
||||
const columnTranslations = {
|
||||
select: Tr(T.Select),
|
||||
network: Tr(T.Network),
|
||||
nicAlias: Tr(T.NICAlias),
|
||||
alias: Tr(T.Alias),
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
field: 'select',
|
||||
disableColumnMenu: true,
|
||||
sortable: false,
|
||||
headerName: columnTranslations.select,
|
||||
width: 100,
|
||||
renderCell: (params) => (
|
||||
<Checkbox
|
||||
checked={params?.row?.rowSelected || false}
|
||||
onChange={() => handleSelectRow(params?.row)}
|
||||
inputProps={{
|
||||
'data-cy': `role-config-network-${params?.row?.idx}`,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'network',
|
||||
disableColumnMenu: true,
|
||||
flex: 1,
|
||||
headerName: columnTranslations.network,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
field: 'aliasToggle',
|
||||
disableColumnMenu: true,
|
||||
sortable: false,
|
||||
headerName: columnTranslations.nicAlias,
|
||||
width: 110,
|
||||
renderCell: (params) =>
|
||||
params?.row?.rowSelected && (
|
||||
<Checkbox
|
||||
checked={params?.row?.aliasSelected || false}
|
||||
onChange={() => handleSelectAlias(params?.row)}
|
||||
inputProps={{
|
||||
'data-cy': `role-config-network-alias-${params?.row?.idx}`,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'alias',
|
||||
disableColumnMenu: true,
|
||||
flex: 1,
|
||||
headerName: columnTranslations.alias,
|
||||
width: 200,
|
||||
renderCell: (params) =>
|
||||
params?.row?.aliasSelected && (
|
||||
<Autocomplete
|
||||
id={`role-config-network-alias-name-${params?.row?.id}`}
|
||||
options={networks
|
||||
?.filter((net, index) => {
|
||||
const fieldArray = getValues(fieldArrayLocation)?.[index]
|
||||
|
||||
return (
|
||||
net?.name !== params?.row?.network &&
|
||||
fieldArray?.rowSelected &&
|
||||
fieldArray?.aliasIdx === -1
|
||||
)
|
||||
})
|
||||
|
||||
?.map((net) => net?.name)}
|
||||
renderOption={(props, option) => (
|
||||
<li
|
||||
{...props}
|
||||
data-cy={`role-config-network-aliasname-option-${option}`}
|
||||
>
|
||||
{option}
|
||||
</li>
|
||||
)}
|
||||
renderInput={(props) => <TextField {...props} label="NIC" />}
|
||||
onChange={(_event, value) => handleSetAlias(params?.row, value)}
|
||||
value={
|
||||
getValues(fieldArrayLocation)?.[params?.row?.aliasIdx]?.name ??
|
||||
null
|
||||
}
|
||||
data-cy={`role-config-network-aliasname-${params?.row?.idx}`}
|
||||
sx={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[networks, fieldArrayLocation]
|
||||
)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Legend title={T.RoleNetworks} />
|
||||
<DataGrid
|
||||
className={classes.root}
|
||||
rows={fields}
|
||||
columns={columns}
|
||||
localeText={{
|
||||
noRowsLabel: 'No networks have been defined',
|
||||
MuiTablePagination: {
|
||||
labelRowsPerPage: Tr(T.RowsPerPage),
|
||||
},
|
||||
}}
|
||||
autoHeight
|
||||
rowsPerPageOptions={[5, 10, 25, 50, 100]}
|
||||
disableSelectionOnClick
|
||||
/>
|
||||
|
||||
{networks?.length > 0 && (
|
||||
<Box sx={{ mb: 2, mt: 4 }}>
|
||||
<Autocomplete
|
||||
options={(getValues(fieldArrayLocation) || [])?.filter(
|
||||
(row) => row?.rowSelected
|
||||
)}
|
||||
value={watchedRdpConfig?.[selectedRoleIndex] ?? ''}
|
||||
getOptionLabel={(option) => option?.name || option || ''}
|
||||
onChange={(_event, value) => handleSetRdp(value)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} name="RDP" placeholder={Tr(T.Rdp)} />
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
RoleNetwork.propTypes = {
|
||||
networks: PropTypes.array,
|
||||
roleConfigs: PropTypes.object,
|
||||
stepId: PropTypes.string,
|
||||
selectedRoleIndex: PropTypes.number,
|
||||
}
|
||||
|
||||
export default RoleNetwork
|
@ -0,0 +1,18 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
export * from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/network'
|
||||
|
||||
export * from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/policies'
|
@ -0,0 +1,251 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 */
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import { useFormContext, useFieldArray } from 'react-hook-form'
|
||||
import { useEffect } from 'react'
|
||||
import { DataGrid } from '@mui/x-data-grid'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Autocomplete,
|
||||
TextField,
|
||||
Checkbox,
|
||||
} from '@mui/material'
|
||||
|
||||
import { Legend } from '@modules/components/Forms'
|
||||
|
||||
import { STEP_ID as ROLES_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles'
|
||||
|
||||
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
|
||||
|
||||
import { TAB_ID as NETWORKS_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking'
|
||||
|
||||
const SECTION_ID = 'NIC'
|
||||
|
||||
export const NetworksDropdown = ({ roles, selectedRole }) => {
|
||||
const { getValues } = useFormContext()
|
||||
const isVr = roles?.[selectedRole]?.type === 'vr'
|
||||
|
||||
const {
|
||||
fields: NICs,
|
||||
replace,
|
||||
update,
|
||||
} = useFieldArray({
|
||||
name: `${ROLES_ID}.${selectedRole}.template_contents.${SECTION_ID}`,
|
||||
})
|
||||
|
||||
const networks = getValues(`${EXTRA_ID}.${NETWORKS_ID}`).map((network) => ({
|
||||
...network,
|
||||
id: `$${network?.name}`,
|
||||
}))
|
||||
|
||||
const handleSelect = (selectedRows) => {
|
||||
const existingSelections = NICs || []
|
||||
replace(
|
||||
selectedRows?.map((row, idx) => {
|
||||
const { NIC_ALIAS, ...nic } = existingSelections?.find(
|
||||
(existing) => existing?.NETWORK_ID === row
|
||||
) || { NETWORK_ID: row }
|
||||
|
||||
if (NIC_ALIAS && !selectedRows?.includes(NIC_ALIAS)) {
|
||||
return { nic, NAME: `NIC_${idx}` }
|
||||
}
|
||||
|
||||
return { ...nic, ...(NIC_ALIAS || {}), NAME: `NIC_${idx}` }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const handleAlias = (rowId, newAlias) => {
|
||||
const nicIndex = NICs?.findIndex((nic) => nic?.NETWORK_ID === rowId)
|
||||
|
||||
if (nicIndex === -1) return
|
||||
|
||||
const updatedNIC = { ...NICs[nicIndex] }
|
||||
|
||||
if (newAlias == null) {
|
||||
delete updatedNIC.NIC_ALIAS
|
||||
} else {
|
||||
updatedNIC.NIC_ALIAS = newAlias
|
||||
}
|
||||
|
||||
update(nicIndex, updatedNIC)
|
||||
}
|
||||
|
||||
const handleFloatingIp = (rowId, toggle, type) => {
|
||||
const nicIndex = NICs?.findIndex((nic) => nic?.NETWORK_ID === rowId)
|
||||
|
||||
if (nicIndex === -1) return
|
||||
|
||||
const updatedNIC = { ...NICs[nicIndex] }
|
||||
|
||||
if (!toggle) {
|
||||
delete updatedNIC[type]
|
||||
} else {
|
||||
updatedNIC[type] = 'yes'
|
||||
}
|
||||
|
||||
update(nicIndex, updatedNIC)
|
||||
}
|
||||
|
||||
// Clears floating IP fields
|
||||
useEffect(() => {
|
||||
if (!isVr) {
|
||||
replace(
|
||||
NICs?.map(({ FLOATING_IP, FLOATING_ONLY, ...nic }) => ({ ...nic }))
|
||||
)
|
||||
}
|
||||
}, [isVr])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
field: 'name',
|
||||
headerName: 'Network',
|
||||
flex: isVr ? 8 / 24 : 12 / 24,
|
||||
},
|
||||
|
||||
...(isVr
|
||||
? [
|
||||
{
|
||||
field: 'floating_ip',
|
||||
headerName: 'Floating IP',
|
||||
flex: 4 / 24,
|
||||
renderCell: (params) => {
|
||||
if (!isVr) return null
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
onChange={(_, value) =>
|
||||
handleFloatingIp(params.row.id, value, 'FLOATING_IP')
|
||||
}
|
||||
checked={
|
||||
NICs?.find((nic) => nic?.NETWORK_ID === params?.row?.id)
|
||||
?.FLOATING_IP === 'yes'
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'floating_only',
|
||||
headerName: 'Floating Only',
|
||||
flex: 4 / 24,
|
||||
renderCell: (params) => {
|
||||
if (!isVr) return null
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
onChange={(_, value) =>
|
||||
handleFloatingIp(params.row.id, value, 'FLOATING_ONLY')
|
||||
}
|
||||
checked={
|
||||
NICs?.find((nic) => nic?.NETWORK_ID === params?.row?.id)
|
||||
?.FLOATING_ONLY === 'yes'
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
field: 'NIC_ALIAS',
|
||||
headerName: 'As NIC Alias',
|
||||
flex: isVr ? 8 / 24 : 12 / 24,
|
||||
renderCell: (params) => {
|
||||
const isSelected = NICs?.find(
|
||||
(NIC) => NIC?.NETWORK_ID === params?.row.id
|
||||
)
|
||||
|
||||
const availableAliases = NICs.filter(
|
||||
(NIC) =>
|
||||
NIC.NETWORK_ID !== params.row.id &&
|
||||
!NICs?.some((nic) => nic?.NIC_ALIAS?.NETWORK_ID === params.row.id)
|
||||
)
|
||||
|
||||
if (!isSelected || !availableAliases?.length) return null
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
options={availableAliases}
|
||||
getOptionLabel={(option) => option?.NETWORK_ID?.replace('$', '')}
|
||||
value={
|
||||
NICs.find((NIC) => NIC.NETWORK_ID === params.row.id)?.NIC_ALIAS ||
|
||||
null
|
||||
}
|
||||
onChange={(_, newValue) => handleAlias(params.row.id, newValue)}
|
||||
renderInput={(args) => (
|
||||
<TextField
|
||||
{...args}
|
||||
label="Select alias"
|
||||
variant="standard"
|
||||
size="small"
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
'&:hover:before': {
|
||||
borderBottom: 'none !important',
|
||||
},
|
||||
},
|
||||
'& .MuiInput-underline:before': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
'& .MuiInput-underline:after': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
fullWidth
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
key={`networks`}
|
||||
variant="transparent"
|
||||
defaultExpanded={networks?.length}
|
||||
TransitionProps={{ unmountOnExit: false }}
|
||||
sx={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<AccordionSummary sx={{ width: '100%' }}>
|
||||
<Legend disableGutters title={'Networks'} />
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails>
|
||||
<DataGrid
|
||||
key={isVr}
|
||||
checkboxSelection
|
||||
disableSelectionOnClick
|
||||
onSelectionModelChange={handleSelect}
|
||||
selectionModel={NICs?.map((NIC) => NIC?.NETWORK_ID)}
|
||||
rows={networks}
|
||||
columns={columns}
|
||||
disableColumnMenu
|
||||
autoHeight
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 */
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import { STEP_ID as ROLES_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles'
|
||||
|
||||
import { Legend, FormWithSchema } from '@modules/components/Forms'
|
||||
import {
|
||||
Stack,
|
||||
Grid,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
} from '@mui/material'
|
||||
import { T } from '@ConstantsModule'
|
||||
|
||||
import { MIN_MAX_FIELDS } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/minMax'
|
||||
|
||||
import {
|
||||
ELASTICITY,
|
||||
SCHEDULED,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections'
|
||||
|
||||
export const PoliciesDropdown = ({ roles, selectedRole }) => (
|
||||
<Accordion
|
||||
key={`policies-${roles?.[selectedRole]?.id}`}
|
||||
variant="transparent"
|
||||
defaultExpanded={false}
|
||||
TransitionProps={{ unmountOnExit: false }}
|
||||
sx={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<AccordionSummary sx={{ width: '100%' }}>
|
||||
<Legend disableGutters title={T.RoleElasticity} />
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={4}>
|
||||
<Grid item md={12}>
|
||||
<FormWithSchema
|
||||
id={`${ROLES_ID}.${selectedRole}`}
|
||||
cy={`${ROLES_ID}`}
|
||||
fields={MIN_MAX_FIELDS}
|
||||
/>
|
||||
</Grid>
|
||||
{[ELASTICITY, SCHEDULED].map(({ Section }, idx) => (
|
||||
<Grid key={`policies-section-${idx}`} item md={12}>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Section roles={roles} selectedRole={selectedRole} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
|
||||
export default PoliciesDropdown
|
@ -0,0 +1,241 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 */
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form'
|
||||
import {
|
||||
Stack,
|
||||
Grid,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Button,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
} from '@mui/material'
|
||||
import { STEP_ID as ROLES_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles'
|
||||
|
||||
import { Tr } from '@modules/components/HOC'
|
||||
|
||||
import { Cancel } from 'iconoir-react'
|
||||
|
||||
import { Legend, FormWithSchema } from '@modules/components/Forms'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { T } from '@ConstantsModule'
|
||||
|
||||
import {
|
||||
ELASTICITY_POLICY_FIELDS,
|
||||
ELASTICITY_TYPES,
|
||||
SECTION_ID,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/elasticity/schema'
|
||||
|
||||
const ElasticityPolicies = ({ roles, selectedRole }) => {
|
||||
const { watch } = useFormContext()
|
||||
|
||||
const wPolicies = watch(`${ROLES_ID}.${selectedRole}.${SECTION_ID}`)
|
||||
|
||||
const [selectedPolicy, setSelectedPolicy] = useState(0)
|
||||
const [shift, setShift] = useState(0)
|
||||
|
||||
const {
|
||||
fields: policies,
|
||||
append,
|
||||
remove,
|
||||
} = useFieldArray({
|
||||
name: `${ROLES_ID}.${selectedRole}.${SECTION_ID}`,
|
||||
})
|
||||
|
||||
const handleRemove = (event, idx) => {
|
||||
event.stopPropagation()
|
||||
|
||||
// Calculates shift & releases current reference in case it goes oob
|
||||
setSelectedPolicy((prev) => {
|
||||
setShift(prev + (policies?.length === 2 ? -+prev : idx < prev ? -1 : 0))
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
remove(idx)
|
||||
}
|
||||
|
||||
const handleAppend = (event) => {
|
||||
event?.stopPropagation?.()
|
||||
|
||||
setSelectedPolicy(() => {
|
||||
setShift(null)
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
append({
|
||||
adjust: '',
|
||||
cooldown: '',
|
||||
expression: '',
|
||||
min: '',
|
||||
period: '',
|
||||
period_number: '',
|
||||
type: '',
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPolicy === null) {
|
||||
if (shift === null) {
|
||||
setSelectedPolicy(policies?.length - 1)
|
||||
} else {
|
||||
setSelectedPolicy(shift)
|
||||
}
|
||||
}
|
||||
}, [policies])
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
key={`policies-${roles?.[selectedRole]?.id}`}
|
||||
variant="transparent"
|
||||
defaultExpanded
|
||||
TransitionProps={{ unmountOnExit: false }}
|
||||
sx={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<AccordionSummary sx={{ width: '100%' }}>
|
||||
<Legend disableGutters title={T.ElasticityPolicies} />
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={4}>
|
||||
<Grid key={`elasticity-section`} item md={12}>
|
||||
{selectedPolicy != null && (
|
||||
<>
|
||||
<Stack
|
||||
key={`epolicy-${policies?.[selectedPolicy]?.id}`}
|
||||
direction="column"
|
||||
spacing={1}
|
||||
>
|
||||
{wPolicies?.length > 0 && (
|
||||
<FormWithSchema
|
||||
id={`${ROLES_ID}.${selectedRole}.${SECTION_ID}.${selectedPolicy}`}
|
||||
cy={`${ROLES_ID}`}
|
||||
fields={ELASTICITY_POLICY_FIELDS}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
type="submit"
|
||||
size="large"
|
||||
data-cy={'roles-add-elastic-policy'}
|
||||
onClick={handleAppend}
|
||||
>
|
||||
{T.AddPolicy}
|
||||
</Button>
|
||||
<List>
|
||||
{policies?.map((policy, idx) => {
|
||||
const {
|
||||
expression,
|
||||
adjust,
|
||||
cooldown,
|
||||
period,
|
||||
// eslint-disable-next-line camelcase
|
||||
period_number,
|
||||
min,
|
||||
type,
|
||||
} = wPolicies?.[idx] ?? policy
|
||||
|
||||
const secondaryFields = [
|
||||
type &&
|
||||
`${Tr(T.Type)}: ${Tr(ELASTICITY_TYPES?.[type])}`,
|
||||
|
||||
adjust && `${Tr(T.Adjust)}: ${adjust}`,
|
||||
min && `${Tr(T.Min)}: ${min}`,
|
||||
cooldown && `${Tr(T.Cooldown)}: ${cooldown}`,
|
||||
period && `${Tr(T.Period)}: ${period}`,
|
||||
// eslint-disable-next-line camelcase
|
||||
period_number && `#: ${period_number}`,
|
||||
expression && `${Tr(T.Expression)}: ${expression}`,
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={`epolicy-${idx}-${policy.id}`}
|
||||
onClick={() => setSelectedPolicy(idx)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignitems: 'center',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: '4px',
|
||||
minHeight: '92px',
|
||||
my: 0.5,
|
||||
overflowX: 'hidden',
|
||||
padding: 2,
|
||||
|
||||
bgcolor:
|
||||
idx === selectedPolicy
|
||||
? 'action.selected'
|
||||
: 'inherit',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
sx={{
|
||||
flex: '1 1 auto',
|
||||
maxWidth: '100%',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontSize: '1em',
|
||||
}}
|
||||
primary={`Policy #${idx}`}
|
||||
primaryTypographyProps={{ variant: 'body1' }}
|
||||
secondary={secondaryFields.join(' | ')}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
aria-label="delete"
|
||||
onClick={(event) => handleRemove(event, idx)}
|
||||
sx={{
|
||||
mr: 1.5,
|
||||
size: 'small',
|
||||
}}
|
||||
>
|
||||
<Cancel />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
export const ELASTICITY = {
|
||||
Section: ElasticityPolicies,
|
||||
id: SECTION_ID,
|
||||
}
|
@ -0,0 +1,143 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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, number } from 'yup'
|
||||
import { getObjectSchemaFromFields, arrayToOptions } from '@UtilsModule'
|
||||
import { INPUT_TYPES, T } from '@ConstantsModule'
|
||||
|
||||
export const SECTION_ID = 'elasticity_policies'
|
||||
|
||||
// Define the CA types
|
||||
export const ELASTICITY_TYPES = {
|
||||
CHANGE: 'Change',
|
||||
CARDINALITY: 'Cardinality',
|
||||
PERCENTAGE_CHANGE: 'Percentage',
|
||||
}
|
||||
|
||||
const type = {
|
||||
name: 'type',
|
||||
label: T.Type,
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
optionsOnly: true,
|
||||
cy: 'roleconfig-elasticitypolicies',
|
||||
values: arrayToOptions(Object.keys(ELASTICITY_TYPES), {
|
||||
addEmpty: false,
|
||||
getText: (key) => ELASTICITY_TYPES?.[key],
|
||||
getValue: (key) => key,
|
||||
}),
|
||||
validation: string()
|
||||
.trim()
|
||||
.required()
|
||||
.oneOf(Object.keys(ELASTICITY_TYPES))
|
||||
.default(() => undefined),
|
||||
grid: { md: 3 },
|
||||
}
|
||||
|
||||
const adjust = {
|
||||
name: 'adjust',
|
||||
label: T.Adjust,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-elasticitypolicies',
|
||||
dependOf: 'type',
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
validation: number().required(),
|
||||
|
||||
grid: (policyType) => ({
|
||||
md: policyType !== Object.keys(ELASTICITY_TYPES)[2] ? 2.75 : 2,
|
||||
}),
|
||||
}
|
||||
|
||||
const min = {
|
||||
name: 'min',
|
||||
label: T.Min,
|
||||
dependOf: 'type',
|
||||
htmlType: (policyType) =>
|
||||
policyType !== Object.keys(ELASTICITY_TYPES)[2] && INPUT_TYPES.HIDDEN,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-elasticitypolicies',
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
validation: number()
|
||||
.nullable()
|
||||
.notRequired()
|
||||
.default(() => undefined),
|
||||
grid: { md: 1.5 },
|
||||
}
|
||||
|
||||
const expression = {
|
||||
name: 'expression',
|
||||
dependOf: 'type',
|
||||
label: T.Expression,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-elasticitypolicies',
|
||||
validation: string().trim().required(),
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
const periodNumber = {
|
||||
name: 'period_number',
|
||||
label: '#',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-elasticitypolicies',
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
validation: number(),
|
||||
grid: { md: 1.5 },
|
||||
}
|
||||
|
||||
const period = {
|
||||
name: 'period',
|
||||
label: 'Period',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-elasticitypolicies',
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
validation: number(),
|
||||
grid: { md: 2 },
|
||||
}
|
||||
|
||||
const cooldown = {
|
||||
name: 'cooldown',
|
||||
label: 'Cooldown',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-elasticitypolicies',
|
||||
dependOf: 'type',
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
validation: number(),
|
||||
grid: (policyType) => ({
|
||||
md: policyType !== Object.keys(ELASTICITY_TYPES)[2] ? 2.75 : 2,
|
||||
}),
|
||||
}
|
||||
|
||||
export const ELASTICITY_POLICY_FIELDS = [
|
||||
type,
|
||||
adjust,
|
||||
min,
|
||||
periodNumber,
|
||||
period,
|
||||
cooldown,
|
||||
expression,
|
||||
]
|
||||
|
||||
export const ELASTICITY_POLICY_SCHEMA = getObjectSchemaFromFields(
|
||||
ELASTICITY_POLICY_FIELDS
|
||||
)
|
@ -0,0 +1,21 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export * from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/elasticity'
|
||||
|
||||
export * from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/scheduled'
|
||||
|
||||
export * from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/minMax'
|
@ -0,0 +1,69 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { number } from 'yup'
|
||||
import { getObjectSchemaFromFields } from '@UtilsModule'
|
||||
import { INPUT_TYPES, T } from '@ConstantsModule'
|
||||
|
||||
const min = {
|
||||
name: 'min_vms',
|
||||
label: T.RolesMinVms,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'elasticity',
|
||||
validation: number()
|
||||
.min(0)
|
||||
.notRequired()
|
||||
.default(() => undefined),
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
grid: { md: 4 },
|
||||
}
|
||||
|
||||
const max = {
|
||||
name: 'max_vms',
|
||||
label: T.RolesMaxVms,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
dependOf: 'cardinality',
|
||||
|
||||
cy: 'elasticity',
|
||||
validation: number()
|
||||
.min(0)
|
||||
.notRequired()
|
||||
.default(() => undefined),
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
grid: { md: 4 },
|
||||
}
|
||||
|
||||
const cooldown = {
|
||||
name: 'cooldown',
|
||||
label: T.Cooldown,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'elasticity',
|
||||
validation: number()
|
||||
.min(0)
|
||||
.notRequired()
|
||||
.default(() => undefined),
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
grid: { md: 4 },
|
||||
}
|
||||
|
||||
export const MIN_MAX_FIELDS = [min, max, cooldown]
|
||||
|
||||
export const MIN_MAX_SCHEMA = getObjectSchemaFromFields(MIN_MAX_FIELDS)
|
@ -0,0 +1,231 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 */
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form'
|
||||
import {
|
||||
Stack,
|
||||
Grid,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Button,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
} from '@mui/material'
|
||||
import { STEP_ID as ROLES_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles'
|
||||
|
||||
import { Tr } from '@modules/components/HOC'
|
||||
|
||||
import { Cancel } from 'iconoir-react'
|
||||
|
||||
import { Legend, FormWithSchema } from '@modules/components/Forms'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { T } from '@ConstantsModule'
|
||||
|
||||
import {
|
||||
SCHEDULED_POLICY_FIELDS,
|
||||
SCHED_TYPES,
|
||||
SECTION_ID,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/scheduled/schema'
|
||||
|
||||
const ScheduledPolicies = ({ roles, selectedRole }) => {
|
||||
const { watch } = useFormContext()
|
||||
|
||||
const wPolicies = watch(`${ROLES_ID}.${selectedRole}.${SECTION_ID}`)
|
||||
|
||||
const [selectedPolicy, setSelectedPolicy] = useState(-1)
|
||||
const [shift, setShift] = useState(0)
|
||||
|
||||
const {
|
||||
fields: policies,
|
||||
append,
|
||||
remove,
|
||||
} = useFieldArray({
|
||||
name: `${ROLES_ID}.${selectedRole}.${SECTION_ID}`,
|
||||
})
|
||||
|
||||
const handleRemove = (event, idx) => {
|
||||
event.stopPropagation()
|
||||
|
||||
// Calculates shift & releases current reference in case it goes oob
|
||||
setSelectedPolicy((prev) => {
|
||||
setShift(prev + (policies?.length === 2 ? -+prev : idx < prev ? -1 : 0))
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
remove(idx)
|
||||
}
|
||||
|
||||
const handleAppend = (event) => {
|
||||
event?.stopPropagation?.()
|
||||
|
||||
setSelectedPolicy(() => {
|
||||
setShift(null)
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
append({
|
||||
adjust: '',
|
||||
cooldown: '',
|
||||
expression: '',
|
||||
min: '',
|
||||
period: '',
|
||||
period_number: '',
|
||||
type: '',
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPolicy === null) {
|
||||
if (shift === null) {
|
||||
setSelectedPolicy(policies?.length - 1)
|
||||
} else {
|
||||
setSelectedPolicy(shift)
|
||||
}
|
||||
}
|
||||
}, [policies])
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
key={`policies-${roles?.[selectedRole]?.id}`}
|
||||
variant="transparent"
|
||||
defaultExpanded
|
||||
TransitionProps={{ unmountOnExit: false }}
|
||||
sx={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<AccordionSummary sx={{ width: '100%' }}>
|
||||
<Legend disableGutters title={T.ScheduledPolicies} />
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={4}>
|
||||
<Grid key={`scheduled-section`} item md={12}>
|
||||
{selectedPolicy != null && (
|
||||
<>
|
||||
<Stack
|
||||
key={`epolicy-${policies?.[selectedPolicy]?.id}`}
|
||||
direction="column"
|
||||
spacing={1}
|
||||
>
|
||||
{wPolicies?.length > 0 && (
|
||||
<FormWithSchema
|
||||
id={`${ROLES_ID}.${selectedRole}.${SECTION_ID}.${selectedPolicy}`}
|
||||
cy={`${ROLES_ID}`}
|
||||
fields={SCHEDULED_POLICY_FIELDS}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
type="submit"
|
||||
size="large"
|
||||
data-cy={'roles-add-scheduled-policy'}
|
||||
onClick={handleAppend}
|
||||
>
|
||||
{T.AddPolicy}
|
||||
</Button>
|
||||
<List>
|
||||
{policies?.map((policy, idx) => {
|
||||
const { type, adjust, min, format, expression } =
|
||||
wPolicies?.[idx] ?? policy
|
||||
|
||||
const timeFormatTrans = Tr(format)
|
||||
|
||||
const secondaryFields = [
|
||||
type && `${Tr(T.Type)}: ${Tr(SCHED_TYPES?.[type])}`,
|
||||
adjust && `${Tr(T.Adjust)}: ${adjust}`,
|
||||
min && `${Tr(T.Min)}: ${min}`,
|
||||
timeFormatTrans &&
|
||||
`${Tr(T.TimeFormat)}: ${timeFormatTrans}`,
|
||||
expression && `${Tr(T.TimeExpression)}: ${expression}`,
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={`epolicy-${idx}-${policy.id}`}
|
||||
onClick={() => setSelectedPolicy(idx)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignitems: 'center',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: '4px',
|
||||
minHeight: '92px',
|
||||
my: 0.5,
|
||||
overflowX: 'hidden',
|
||||
padding: 2,
|
||||
|
||||
bgcolor:
|
||||
idx === selectedPolicy
|
||||
? 'action.selected'
|
||||
: 'inherit',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
sx={{
|
||||
flex: '1 1 auto',
|
||||
maxWidth: '100%',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontSize: '1em',
|
||||
}}
|
||||
primary={`Policy #${idx}`}
|
||||
primaryTypographyProps={{ variant: 'body1' }}
|
||||
secondary={secondaryFields.join(' | ')}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
aria-label="delete"
|
||||
onClick={(event) => handleRemove(event, idx)}
|
||||
sx={{
|
||||
mr: 1.5,
|
||||
size: 'small',
|
||||
}}
|
||||
>
|
||||
<Cancel />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
export const SCHEDULED = {
|
||||
Section: ScheduledPolicies,
|
||||
id: SECTION_ID,
|
||||
}
|
@ -0,0 +1,135 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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, number } from 'yup'
|
||||
import { getObjectSchemaFromFields, arrayToOptions } from '@UtilsModule'
|
||||
import { INPUT_TYPES, T } from '@ConstantsModule'
|
||||
|
||||
export const SECTION_ID = 'scheduled_policies'
|
||||
|
||||
const TIME_TYPES = {
|
||||
recurrence: 'Recurrence',
|
||||
starttime: 'Start time',
|
||||
}
|
||||
|
||||
export const SCHED_TYPES = {
|
||||
CHANGE: 'Change',
|
||||
CARDINALITY: 'Cardinality',
|
||||
PERCENTAGE_CHANGE: 'Percentage',
|
||||
}
|
||||
|
||||
/* eslint-disable no-useless-escape */
|
||||
const timeExpressionRegex =
|
||||
/^(\d{4}-\d{2}-\d{2}(?: [0-2]\d:[0-5]\d:[0-5]\d|\d{4}-\d{2}-\d{2}T[0-2]\d:[0-5]\d:[0-5]\dZ)?)$/
|
||||
|
||||
const cronExpressionRegex = /^([\d*\/,-]+ ){4}[\d*\/,-]+$/
|
||||
|
||||
/* eslint-enable no-useless-escape */
|
||||
|
||||
const type = {
|
||||
name: 'type',
|
||||
label: T.Type,
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
optionsOnly: true,
|
||||
cy: 'roleconfig-scheduledpolicies',
|
||||
values: arrayToOptions(Object.keys(SCHED_TYPES), {
|
||||
addEmpty: false,
|
||||
getText: (key) => SCHED_TYPES?.[key],
|
||||
getValue: (key) => key,
|
||||
}),
|
||||
validation: string()
|
||||
.trim()
|
||||
.oneOf(Object.keys(SCHED_TYPES))
|
||||
.default(() => Object.keys(SCHED_TYPES)[0]),
|
||||
grid: { md: 3.3 },
|
||||
}
|
||||
|
||||
const adjust = {
|
||||
name: 'adjust',
|
||||
label: T.Adjust,
|
||||
dependOf: 'type',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-scheduledpolicies',
|
||||
validation: string()
|
||||
.trim()
|
||||
.default(() => ''),
|
||||
grid: (policyType) => ({
|
||||
md: policyType !== Object.keys(SCHED_TYPES)[2] ? 4.15 : 3.1,
|
||||
}),
|
||||
}
|
||||
|
||||
const min = {
|
||||
name: 'min',
|
||||
label: T.Min,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
dependOf: 'type',
|
||||
htmlType: (policyType) =>
|
||||
policyType !== Object.keys(SCHED_TYPES)[2] && INPUT_TYPES.HIDDEN,
|
||||
cy: 'roleconfig-scheduledpolicies',
|
||||
fieldProps: {
|
||||
type: 'number',
|
||||
},
|
||||
validation: number()
|
||||
.notRequired()
|
||||
.default(() => undefined),
|
||||
grid: { md: 2.1 },
|
||||
}
|
||||
|
||||
const format = {
|
||||
name: 'format',
|
||||
label: T.TimeFormat,
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
dependOf: 'type',
|
||||
optionsOnly: true,
|
||||
cy: 'roleconfig-scheduledpolicies',
|
||||
values: arrayToOptions(Object.values(TIME_TYPES), { addEmpty: false }),
|
||||
validation: string()
|
||||
.trim()
|
||||
.required()
|
||||
.oneOf(Object.values(TIME_TYPES))
|
||||
.default(() => undefined),
|
||||
grid: (policyType) => ({
|
||||
md: policyType !== Object.keys(SCHED_TYPES)[2] ? 4.55 : 3.5,
|
||||
}),
|
||||
}
|
||||
|
||||
const expression = {
|
||||
name: 'expression',
|
||||
label: T.TimeExpression,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'roleconfig-scheduledpolicies',
|
||||
validation: string()
|
||||
.trim()
|
||||
.when('TIMEFORMAT', {
|
||||
is: 'Start time',
|
||||
then: string().matches(
|
||||
timeExpressionRegex,
|
||||
'Time Expression must be in the format YYYY-MM-DD hh:mm:ss or YYYY-MM-DDThh:mm:ssZ'
|
||||
),
|
||||
otherwise: string().matches(
|
||||
cronExpressionRegex,
|
||||
'Time Expression must be a valid CRON expression'
|
||||
),
|
||||
})
|
||||
.required(),
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
export const SCHEDULED_POLICY_FIELDS = [type, adjust, min, format, expression]
|
||||
|
||||
export const SCHEDULED_POLICY_SCHEMA = getObjectSchemaFromFields(
|
||||
SCHEDULED_POLICY_FIELDS
|
||||
)
|
@ -14,119 +14,245 @@
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import PropTypes from 'prop-types'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useFormContext, useWatch } from 'react-hook-form'
|
||||
import { Box, Grid } from '@mui/material'
|
||||
import { SCHEMA } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schema'
|
||||
import RoleVmVmPanel from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesPanel'
|
||||
import RoleColumn from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesColumn'
|
||||
import VmTemplatesPanel from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/vmTemplatesPanel'
|
||||
import RoleSummary from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/roleSummary'
|
||||
import { Component, useState, useEffect } from 'react'
|
||||
import { useFormContext, useFieldArray } from 'react-hook-form'
|
||||
import { Cancel } from 'iconoir-react'
|
||||
import {
|
||||
FIELDS,
|
||||
SCHEMA,
|
||||
TEMPLATE_ID_FIELD,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schemas'
|
||||
|
||||
import {
|
||||
PoliciesDropdown,
|
||||
NetworksDropdown,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns'
|
||||
|
||||
import { FormWithSchema } from '@modules/components/Forms'
|
||||
|
||||
import { Tr } from '@modules/components/HOC'
|
||||
import {
|
||||
Skeleton,
|
||||
Stack,
|
||||
Button,
|
||||
Grid,
|
||||
List,
|
||||
ListItem,
|
||||
IconButton,
|
||||
} from '@mui/material'
|
||||
import { T } from '@ConstantsModule'
|
||||
|
||||
export const STEP_ID = 'roledefinition'
|
||||
export const STEP_ID = 'roles'
|
||||
|
||||
const Content = () => {
|
||||
const { getValues, setValue, reset } = useFormContext()
|
||||
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0)
|
||||
const standaloneExcludedFields = ['parents']
|
||||
|
||||
const defaultRole = [
|
||||
{ NAME: '', SELECTED_VM_TEMPLATE_ID: [], CARDINALITY: 0 },
|
||||
]
|
||||
/**
|
||||
* @param {object} root0 - Props
|
||||
* @param {boolean} root0.standaloneModal - Render as AddRoleDialog
|
||||
*@returns {Component} - Role definitions step
|
||||
*/
|
||||
const Content = ({ standaloneModal = false }) => {
|
||||
const [selectedRole, setSelectedRole] = useState(0)
|
||||
const [shift, setShift] = useState(0)
|
||||
|
||||
const watchedRoles = useWatch({
|
||||
name: STEP_ID,
|
||||
defaultValue: defaultRole,
|
||||
const { watch } = useFormContext()
|
||||
|
||||
const wRoles = watch(`${STEP_ID}`)
|
||||
|
||||
const isVr = wRoles?.[selectedRole]?.type === 'vr'
|
||||
|
||||
const {
|
||||
fields: roles,
|
||||
remove,
|
||||
append,
|
||||
} = useFieldArray({
|
||||
name: `${STEP_ID}`,
|
||||
})
|
||||
const definedRoles = getValues(STEP_ID)
|
||||
|
||||
useEffect(() => {
|
||||
if (definedRoles) {
|
||||
reset({ [STEP_ID]: definedRoles ?? defaultRole })
|
||||
}
|
||||
}, [])
|
||||
const handleRemove = (event, idx) => {
|
||||
event.stopPropagation()
|
||||
|
||||
const [roles, setRoles] = useState(getValues(STEP_ID))
|
||||
// Calculates shift & releases current reference in case it goes oob
|
||||
setSelectedRole((prev) => {
|
||||
setShift(prev + (roles?.length === 2 ? -+prev : idx < prev ? -1 : 0))
|
||||
|
||||
useEffect(() => {
|
||||
setRoles(watchedRoles)
|
||||
}, [definedRoles, watchedRoles])
|
||||
return null
|
||||
})
|
||||
|
||||
const handleChangeRoles = (updatedRoles) => {
|
||||
setValue(STEP_ID, updatedRoles)
|
||||
remove(idx)
|
||||
}
|
||||
|
||||
const handleRoleChange = useCallback(
|
||||
(updatedRole) => {
|
||||
const updatedRoles = [...roles]
|
||||
const handleAppend = (event) => {
|
||||
event?.stopPropagation?.()
|
||||
|
||||
if (selectedRoleIndex >= 0 && selectedRoleIndex < roles.length) {
|
||||
updatedRoles[selectedRoleIndex] = updatedRole
|
||||
setSelectedRole(() => {
|
||||
setShift(null)
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
append({
|
||||
name: '',
|
||||
cardinality: 1,
|
||||
parents: [],
|
||||
template_id: '',
|
||||
type: '',
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRole === null) {
|
||||
if (shift === null) {
|
||||
setSelectedRole(roles?.length - 1)
|
||||
} else {
|
||||
updatedRoles.push(updatedRole)
|
||||
setSelectedRole(shift)
|
||||
}
|
||||
}
|
||||
}, [roles])
|
||||
|
||||
handleChangeRoles(updatedRoles)
|
||||
},
|
||||
[roles, selectedRoleIndex]
|
||||
)
|
||||
if (!wRoles?.length) {
|
||||
handleAppend()
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Skeleton variant="rectangular" width="25%" height={300} />
|
||||
<Skeleton variant="rectangular" width="75%" height={300} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid mt={2} container>
|
||||
<Grid item xs={2.2}>
|
||||
<RoleColumn
|
||||
roles={roles}
|
||||
onChange={handleChangeRoles}
|
||||
selectedRoleIndex={selectedRoleIndex}
|
||||
setSelectedRoleIndex={setSelectedRoleIndex}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={7}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1em',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<RoleVmVmPanel
|
||||
roles={roles}
|
||||
onChange={handleRoleChange}
|
||||
selectedRoleIndex={selectedRoleIndex}
|
||||
/>
|
||||
<VmTemplatesPanel
|
||||
roles={roles}
|
||||
selectedRoleIndex={selectedRoleIndex}
|
||||
onChange={handleRoleChange}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={2.8}>
|
||||
<RoleSummary
|
||||
role={roles?.[selectedRoleIndex] ?? []}
|
||||
selectedRoleIndex={selectedRoleIndex}
|
||||
/>
|
||||
<Grid
|
||||
mt={1}
|
||||
container
|
||||
direction="row"
|
||||
columnSpacing={1}
|
||||
rowSpacing={2}
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'stretch',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{!standaloneModal && (
|
||||
<Grid item md={3} sx={{ borderRight: 1, padding: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
type="submit"
|
||||
size="large"
|
||||
data-cy={'extra-add-role'}
|
||||
onClick={handleAppend}
|
||||
>
|
||||
{Tr(T.AddRole)}
|
||||
</Button>
|
||||
<List
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{roles?.map((role, idx) => {
|
||||
const roleName = watch(`${STEP_ID}.${idx}.name`)
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={`${idx}-${role?.id}-${role?.name}`}
|
||||
onClick={() => setSelectedRole(idx)}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: '4px',
|
||||
minHeight: '70px',
|
||||
my: 0.5,
|
||||
overflowX: 'hidden',
|
||||
padding: 2,
|
||||
|
||||
bgcolor:
|
||||
idx === selectedRole ? 'action.selected' : 'inherit',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{roles?.length > 1 && idx !== selectedRole && (
|
||||
<IconButton
|
||||
aria-label="delete"
|
||||
onClick={(event) => handleRemove(event, idx)}
|
||||
sx={{ mr: 1.5, size: 'small' }}
|
||||
>
|
||||
<Cancel />
|
||||
</IconButton>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
maxWidth: '100%',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontSize: '1em',
|
||||
}}
|
||||
>
|
||||
{roleName || T.NewRole}
|
||||
</div>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item md={standaloneModal ? 12 : 9}>
|
||||
{selectedRole != null && (
|
||||
<Stack
|
||||
key={`roles-${roles?.[selectedRole]?.id}`}
|
||||
direction="column"
|
||||
alignItems="flex-start"
|
||||
gap="0.5rem"
|
||||
width="100%"
|
||||
sx={{ padding: standaloneModal ? 1 : 0 }}
|
||||
>
|
||||
<FormWithSchema
|
||||
id={`${STEP_ID}.${selectedRole}`}
|
||||
legend={T.Type}
|
||||
fields={FIELDS?.filter(
|
||||
({ name }) =>
|
||||
!standaloneModal || !standaloneExcludedFields?.includes(name)
|
||||
)}
|
||||
/>
|
||||
|
||||
{!standaloneModal && (
|
||||
<NetworksDropdown roles={wRoles} selectedRole={selectedRole} />
|
||||
)}
|
||||
{!isVr && (
|
||||
<PoliciesDropdown roles={roles} selectedRole={selectedRole} />
|
||||
)}
|
||||
<FormWithSchema
|
||||
id={`${STEP_ID}.${selectedRole}`}
|
||||
fields={[TEMPLATE_ID_FIELD]}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Role definition configuration.
|
||||
*
|
||||
* @returns {object} Roles definition configuration step
|
||||
*/
|
||||
const RoleDefinition = () => ({
|
||||
id: STEP_ID,
|
||||
label: T.RoleDefinition,
|
||||
resolver: SCHEMA,
|
||||
optionsValidate: { abortEarly: false },
|
||||
content: Content,
|
||||
})
|
||||
RoleDefinition.propTypes = {
|
||||
data: PropTypes.array,
|
||||
setFormData: PropTypes.func,
|
||||
Content.propTypes = {
|
||||
stepId: PropTypes.string,
|
||||
standaloneModal: PropTypes.bool,
|
||||
}
|
||||
|
||||
export default RoleDefinition
|
||||
/**
|
||||
*@returns {Component} - Roles definition step
|
||||
*/
|
||||
const Step = () => ({
|
||||
id: STEP_ID,
|
||||
content: (props) => Content(props),
|
||||
label: T.Roles,
|
||||
resolver: SCHEMA,
|
||||
optionsValidate: { abortEarly: false },
|
||||
})
|
||||
|
||||
export default Step
|
||||
|
@ -1,188 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { Card, CardContent, Typography, Divider } from '@mui/material'
|
||||
import PropTypes from 'prop-types'
|
||||
import { T } from '@ConstantsModule'
|
||||
import { Tr } from '@modules/components/HOC'
|
||||
import { Component } from 'react'
|
||||
/**
|
||||
* RoleSummary displays detailed information about a VM role, including its configuration and affinity settings.
|
||||
*
|
||||
* @param {object} props - The props that control the RoleSummary component.
|
||||
* @param {object} props.role - The role object containing the role's configuration.
|
||||
* @param {number} props.selectedRoleIndex - The index of the selected role.
|
||||
* @returns {Component} - Role summary component.
|
||||
*/
|
||||
const RoleSummary = ({ role, selectedRoleIndex }) => {
|
||||
const translations = {
|
||||
template: Tr(T.VMTemplate) + ' ' + Tr(T.ID),
|
||||
selectTemplate: Tr(T.SelectVmTemplate),
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
elevation={2}
|
||||
sx={{
|
||||
height: '100%',
|
||||
maxHeight: '630px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1em',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="div" gutterBottom>
|
||||
#{selectedRoleIndex + 1 ?? 0} {Tr(T.RoleConfiguration)}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={role?.NAME ? 'text.primary' : 'text.disabled'}
|
||||
gutterBottom
|
||||
>
|
||||
{Tr(T.Name)}: {role?.NAME || Tr(T.RoleEnterName)}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={
|
||||
role?.CARDINALITY === undefined ||
|
||||
role?.CARDINALITY === 'None' ||
|
||||
+role?.CARDINALITY < 1
|
||||
? 'text.disabled'
|
||||
: 'text.primary'
|
||||
}
|
||||
gutterBottom
|
||||
>
|
||||
{Tr(T.NumberOfVms)}: {role?.CARDINALITY}
|
||||
</Typography>
|
||||
|
||||
{role?.SELECTED_VM_TEMPLATE_ID ? (
|
||||
<>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={
|
||||
role?.SELECTED_VM_TEMPLATE_ID === undefined ||
|
||||
role?.SELECTED_VM_TEMPLATE_ID === 'None' ||
|
||||
role?.SELECTED_VM_TEMPLATE_ID?.length < 1
|
||||
? 'text.disabled'
|
||||
: 'text.primary'
|
||||
}
|
||||
gutterBottom
|
||||
>
|
||||
{translations.template}: {role?.SELECTED_VM_TEMPLATE_ID}
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled" gutterBottom>
|
||||
{translations.selectTemplate}
|
||||
</Typography>
|
||||
)}
|
||||
<Divider />
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={role?.NETWORKS ? 'text.primary' : 'text.disabled'}
|
||||
gutterBottom
|
||||
>
|
||||
{Tr(T.Networks)}: {role?.NETWORKS || ' ' + Tr(T.RoleSelectNetwork)}
|
||||
</Typography>
|
||||
|
||||
<Typography color={'text.primary'} sx={{ fontSize: 16 }} gutterBottom>
|
||||
{Tr(T.RoleElasticity)}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={role?.MINVMS ? 'text.primary' : 'text.disabled'}
|
||||
gutterBottom
|
||||
>
|
||||
{Tr(T.RolesMinVms)}:{role?.MINVMS || ' ' + Tr(T.RoleMinElasticity)}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={role?.MAXVMS ? 'text.primary' : 'text.disabled'}
|
||||
gutterBottom
|
||||
>
|
||||
{Tr(T.RolesMaxVms)}:{role?.MAXVMS || ' ' + Tr(T.RoleMaxElasticity)}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={role?.MAXVMS ? 'text.primary' : 'text.disabled'}
|
||||
gutterBottom
|
||||
>
|
||||
{Tr(T.Cooldown)}:{role?.COOLDOWN || ' ' + Tr(T.RoleDurationScale)}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
color={role?.ELASTICITYPOLICIES ? 'text.primary' : 'text.disabled'}
|
||||
sx={{ fontSize: 14 }}
|
||||
gutterBottom
|
||||
>
|
||||
{Tr(T.ElasticityPolicies)}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={
|
||||
role?.ELASTICITYPOLICIES?.TYPE ? 'text.primary' : 'text.disabled'
|
||||
}
|
||||
gutterBottom
|
||||
>
|
||||
{Tr(T.Type)}:
|
||||
{role?.ELASTICITYPOLICIES?.TYPE || ' ' + Tr(T.RoleAdjustmentType)}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={
|
||||
role?.ELASTICITYPOLICIES?.ADJUST ? 'text.primary' : 'text.disabled'
|
||||
}
|
||||
gutterBottom
|
||||
>
|
||||
{Tr(T.Adjust)}:
|
||||
{role?.ELASTICITYPOLICIES?.ADJUST ||
|
||||
' ' + Tr(T.RoleAdjustmentTypePositiveNegative)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
RoleSummary.propTypes = {
|
||||
role: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
NAME: PropTypes.string,
|
||||
POLICY: PropTypes.oneOf(['AFFINED', 'ANTI_AFFINED', 'None', undefined]),
|
||||
HOST_AFFINED: PropTypes.arrayOf(PropTypes.number),
|
||||
HOST_ANTI_AFFINED: PropTypes.arrayOf(PropTypes.number),
|
||||
}),
|
||||
PropTypes.array,
|
||||
PropTypes.object,
|
||||
]),
|
||||
selectedRoleIndex: PropTypes.number,
|
||||
onRemoveAffinity: PropTypes.func,
|
||||
}
|
||||
|
||||
export default RoleSummary
|
@ -1,170 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { useCallback, Component } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Box, Button, List, ListItem, IconButton } from '@mui/material'
|
||||
import { Cancel } from 'iconoir-react'
|
||||
import { Tr } from '@modules/components/HOC'
|
||||
import { T } from '@ConstantsModule'
|
||||
|
||||
/**
|
||||
* RoleColumn component for displaying and managing roles.
|
||||
*
|
||||
* @param {object} props - The properties passed to the component.
|
||||
* @param {Array} props.roles - The list of roles.
|
||||
* @param {Function} props.onChange - Callback function when roles are changed.
|
||||
* @param {number|null} props.selectedRoleIndex - The index of the currently selected role.
|
||||
* @param {Function} props.setSelectedRoleIndex - Function to set the selected role index.
|
||||
* @param {boolean} props.disableModify - Disables the modification of roles.
|
||||
* @returns {Component} - Role columns component
|
||||
*/
|
||||
const RoleColumn = ({
|
||||
roles,
|
||||
onChange,
|
||||
selectedRoleIndex,
|
||||
setSelectedRoleIndex,
|
||||
disableModify = false,
|
||||
}) => {
|
||||
const newRole = { NAME: '', SELECTED_VM_TEMPLATE_ID: [], CARDINALITY: 0 }
|
||||
|
||||
const handleAddRole = useCallback(() => {
|
||||
const updatedRoles = [...roles, newRole]
|
||||
onChange(updatedRoles)
|
||||
setSelectedRoleIndex(roles?.length)
|
||||
}, [roles, onChange, selectedRoleIndex])
|
||||
|
||||
const handleRemoveRole = useCallback(
|
||||
(indexToRemove) => {
|
||||
const updatedRoles = [
|
||||
...roles.slice(0, indexToRemove),
|
||||
...roles.slice(indexToRemove + 1),
|
||||
]
|
||||
|
||||
onChange(updatedRoles)
|
||||
if (selectedRoleIndex === indexToRemove) {
|
||||
setSelectedRoleIndex(null)
|
||||
}
|
||||
},
|
||||
[roles, selectedRoleIndex]
|
||||
)
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
pt: 2,
|
||||
height: '100%',
|
||||
maxHeight: '630px',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
borderRight: 1,
|
||||
pr: 2,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{!disableModify && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleAddRole}
|
||||
size="large"
|
||||
data-cy="add-role"
|
||||
>
|
||||
{Tr(T.AddRole)}
|
||||
</Button>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: '90%',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<List>
|
||||
{Array.isArray(roles) &&
|
||||
roles.length > 0 &&
|
||||
roles.map((role, index) => (
|
||||
<ListItem
|
||||
button
|
||||
selected={index === selectedRoleIndex}
|
||||
onClick={() => setSelectedRoleIndex(index)}
|
||||
key={index}
|
||||
sx={{
|
||||
my: 0.5,
|
||||
minHeight: '43.5px',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: '4px',
|
||||
overflowX: 'hidden',
|
||||
bgcolor:
|
||||
index === selectedRoleIndex
|
||||
? 'action.selected'
|
||||
: 'inherit',
|
||||
'&.Mui-selected, &.Mui-selected:hover': {
|
||||
bgcolor: 'action.selected',
|
||||
},
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
data-cy={`role-column-${index}`}
|
||||
>
|
||||
{!disableModify && (
|
||||
<IconButton
|
||||
aria-label="delete"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleRemoveRole(index)
|
||||
}}
|
||||
data-cy={`delete-role-${index}`}
|
||||
sx={{ mr: 1.5 }}
|
||||
>
|
||||
<Cancel />
|
||||
</IconButton>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{role?.NAME || 'New Role'}
|
||||
</div>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
RoleColumn.propTypes = {
|
||||
roles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
selectedRoleIndex: PropTypes.number,
|
||||
setSelectedRoleIndex: PropTypes.func.isRequired,
|
||||
disableModify: PropTypes.bool,
|
||||
}
|
||||
|
||||
RoleColumn.defaultProps = {
|
||||
roles: [],
|
||||
}
|
||||
|
||||
export default RoleColumn
|
@ -1,161 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { Component, useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
Checkbox,
|
||||
Autocomplete,
|
||||
} from '@mui/material'
|
||||
import PropTypes from 'prop-types'
|
||||
import { T } from '@ConstantsModule'
|
||||
import { Tr } from '@modules/components/HOC'
|
||||
|
||||
/**
|
||||
* Role Panel component for managing roles.
|
||||
*
|
||||
* @param {object} props - Component properties.
|
||||
* @param {Array} props.roles - List of roles.
|
||||
* @param {Function} props.onChange - Callback for when roles change.
|
||||
* @param {number} props.selectedRoleIndex - Currently selected role index.
|
||||
* @returns {Component} The rendered component.
|
||||
*/
|
||||
const RoleVmVmPanel = ({ roles, onChange, selectedRoleIndex }) => {
|
||||
const [inputBuffers, setInputBuffers] = useState({})
|
||||
|
||||
const handleInputChange = (name, value) => {
|
||||
const updatedRole = { ...roles[selectedRoleIndex], [name]: value }
|
||||
onChange(updatedRole)
|
||||
}
|
||||
|
||||
const handleTextFieldChange = (event) => {
|
||||
const { name, value } = event.target
|
||||
setInputBuffers((prev) => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const handleBlur = (event, number = false) => {
|
||||
const { name } = event.target
|
||||
|
||||
if (inputBuffers[name] !== undefined) {
|
||||
const value = inputBuffers[name]
|
||||
handleInputChange(name, number ? parseInt(value, 10) || 0 : value || '')
|
||||
}
|
||||
|
||||
setInputBuffers((prev) => ({ ...prev, [name]: null }))
|
||||
}
|
||||
|
||||
const handleAutocompleteChange = (_, value) => {
|
||||
const parentNames = value.map((option) => option.NAME)
|
||||
handleInputChange('PARENTS', parentNames)
|
||||
}
|
||||
|
||||
const isDisabled = !roles?.[selectedRoleIndex] || roles?.length <= 0
|
||||
const selectedRole = roles?.[selectedRoleIndex] || {}
|
||||
|
||||
const selectedParentRoles = roles?.filter((role) =>
|
||||
selectedRole?.PARENTS?.includes(role?.NAME)
|
||||
)
|
||||
|
||||
const getValue = (fieldName) => {
|
||||
if (
|
||||
inputBuffers[fieldName] !== undefined &&
|
||||
inputBuffers[fieldName] !== null
|
||||
) {
|
||||
return inputBuffers[fieldName]
|
||||
}
|
||||
|
||||
return selectedRole?.[fieldName] || ''
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6">{Tr(T.RoleDetails)}</Typography>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
label={Tr(T.RoleName)}
|
||||
name="NAME"
|
||||
value={getValue('NAME')}
|
||||
onChange={handleTextFieldChange}
|
||||
onBlur={handleBlur}
|
||||
disabled={isDisabled}
|
||||
inputProps={{ 'data-cy': `role-name-${selectedRoleIndex}` }}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
type="number"
|
||||
label={Tr(T.NumberOfVms)}
|
||||
name="CARDINALITY"
|
||||
value={getValue('CARDINALITY')}
|
||||
onChange={handleTextFieldChange}
|
||||
onBlur={(event) => handleBlur(event, true)}
|
||||
disabled={isDisabled}
|
||||
InputProps={{
|
||||
inputProps: {
|
||||
min: 0,
|
||||
'data-cy': `role-cardinality-${selectedRoleIndex}`,
|
||||
},
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{roles?.length >= 2 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={roles?.filter((_, idx) => idx !== selectedRoleIndex)}
|
||||
disableCloseOnSelect
|
||||
getOptionLabel={(option) => option?.NAME}
|
||||
value={selectedParentRoles}
|
||||
onChange={handleAutocompleteChange}
|
||||
renderOption={(props, option, { selected }) => (
|
||||
<li {...props}>
|
||||
<Checkbox style={{ marginRight: 8 }} checked={selected} />
|
||||
{option?.NAME}
|
||||
</li>
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
name="PARENTS"
|
||||
placeholder={Tr(T.ParentRoles)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
RoleVmVmPanel.propTypes = {
|
||||
roles: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
NAME: PropTypes.string,
|
||||
CARDINALITY: PropTypes.number,
|
||||
})
|
||||
),
|
||||
onChange: PropTypes.func.isRequired,
|
||||
selectedRoleIndex: PropTypes.number,
|
||||
}
|
||||
|
||||
export default RoleVmVmPanel
|
@ -1,106 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { INPUT_TYPES, T } from '@ConstantsModule'
|
||||
import { object, string, array, number } from 'yup'
|
||||
import { Field } from '@UtilsModule'
|
||||
|
||||
/** @type {Field} Name field for role */
|
||||
const ROLE_NAME_FIELD = {
|
||||
name: 'name',
|
||||
label: T.Name,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
validation: string()
|
||||
.trim()
|
||||
.required('Role name cannot be empty')
|
||||
.default(() => ''),
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
const CARDINALITY_FIELD = {
|
||||
name: 'cardinality',
|
||||
label: T.NumberOfVms,
|
||||
|
||||
validation: number()
|
||||
.test(
|
||||
'Is positive?',
|
||||
'Number of VMs cannot be negative!',
|
||||
(value) => value >= 0
|
||||
)
|
||||
.default(() => 0),
|
||||
}
|
||||
|
||||
const PARENTS_FIELD = {
|
||||
name: 'parents',
|
||||
label: T.ParentRoles,
|
||||
validation: array()
|
||||
.notRequired()
|
||||
.default(() => []),
|
||||
}
|
||||
|
||||
const SELECTED_VM_TEMPLATE_ID_FIELD = {
|
||||
name: 'selected_vm_template_id',
|
||||
validation: array()
|
||||
.required('VM Template ID is required')
|
||||
.min(1, 'At least one VM Template ID is required')
|
||||
.default(() => []),
|
||||
}
|
||||
|
||||
/** @type {object} Role schema */
|
||||
const ROLE_SCHEMA = object().shape({
|
||||
NAME: ROLE_NAME_FIELD.validation,
|
||||
CARDINALITY: CARDINALITY_FIELD.validation,
|
||||
PARENTS: PARENTS_FIELD.validation,
|
||||
SELECTED_VM_TEMPLATE_ID: SELECTED_VM_TEMPLATE_ID_FIELD.validation,
|
||||
})
|
||||
|
||||
/** @type {object} Roles schema for the step */
|
||||
export const SCHEMA = array()
|
||||
.of(ROLE_SCHEMA)
|
||||
.test(
|
||||
'is-non-empty',
|
||||
'Define at least one role!',
|
||||
(value) => value !== undefined && value.length > 0
|
||||
)
|
||||
.test(
|
||||
'has-valid-role-names',
|
||||
'Some roles have invalid names, max 128 characters',
|
||||
(roles) =>
|
||||
roles.every(
|
||||
(role) =>
|
||||
role.NAME &&
|
||||
role.NAME.trim().length > 0 &&
|
||||
role.NAME.trim().length <= 128
|
||||
)
|
||||
)
|
||||
.test('non-negative', 'Number of VMs must be non-negative', (roles) =>
|
||||
roles.every((role) => role?.CARDINALITY >= 0)
|
||||
)
|
||||
.test(
|
||||
'valid-characters',
|
||||
'Role names can only contain letters and numbers',
|
||||
(roles) =>
|
||||
roles.every((role) => role.NAME && /^[a-zA-Z0-9_]+$/.test(role.NAME))
|
||||
)
|
||||
.test(
|
||||
'has-unique-name',
|
||||
'All roles must have unique names',
|
||||
(roles) => new Set(roles.map((role) => role.NAME)).size === roles.length
|
||||
)
|
||||
|
||||
/**
|
||||
* @returns {Field[]} Fields
|
||||
*/
|
||||
export const FIELDS = [ROLE_NAME_FIELD, ROLE_SCHEMA]
|
@ -13,51 +13,35 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { object, string } from 'yup'
|
||||
import { getValidationFromFields, arrayToOptions } from '@UtilsModule'
|
||||
import { string } from 'yup'
|
||||
import { getObjectSchemaFromFields, arrayToOptions } from '@UtilsModule'
|
||||
import { INPUT_TYPES, T } from '@ConstantsModule'
|
||||
import { SECTION_ID as ADVANCED_SECTION_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters'
|
||||
|
||||
const SHUTDOWN_TYPES = {
|
||||
none: '',
|
||||
terminate: 'Terminate',
|
||||
terminateHard: 'Terminate hard',
|
||||
}
|
||||
|
||||
const SHUTDOWN_ENUMS_ONEFLOW = {
|
||||
[SHUTDOWN_TYPES.terminate]: 'shutdown',
|
||||
[SHUTDOWN_TYPES.terminateHard]: 'shutdown-hard',
|
||||
}
|
||||
|
||||
const RDP_FIELD = {
|
||||
name: 'rdp',
|
||||
label: T.Rdp,
|
||||
validation: string()
|
||||
.trim()
|
||||
.notRequired()
|
||||
.default(() => undefined),
|
||||
'terminate-hard': 'Terminate hard',
|
||||
}
|
||||
|
||||
const SHUTDOWN_TYPE = {
|
||||
name: `${ADVANCED_SECTION_ID}.SHUTDOWNTYPE`,
|
||||
name: 'shutdown',
|
||||
label: T.VMShutdownAction,
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
optionsOnly: true,
|
||||
values: arrayToOptions(Object.keys(SHUTDOWN_TYPES), {
|
||||
addEmpty: false,
|
||||
getText: (key) => SHUTDOWN_TYPES[key],
|
||||
getValue: (key) => SHUTDOWN_ENUMS_ONEFLOW[key],
|
||||
getValue: (key) => key,
|
||||
}),
|
||||
validation: string()
|
||||
.trim()
|
||||
.notRequired()
|
||||
.oneOf(Object.values(SHUTDOWN_TYPES))
|
||||
.default(() => Object.values(SHUTDOWN_TYPES)[0]),
|
||||
grid: { xs: 12, sm: 12, md: 12 },
|
||||
.default(() => undefined)
|
||||
.afterSubmit((value) => (value === '' ? undefined : value)),
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
export const ADVANCED_PARAMS_FIELDS = [SHUTDOWN_TYPE]
|
||||
|
||||
export const ADVANCED_PARAMS_SCHEMA = object(
|
||||
getValidationFromFields([...ADVANCED_PARAMS_FIELDS, RDP_FIELD])
|
||||
export const ADVANCED_PARAMS_SCHEMA = getObjectSchemaFromFields(
|
||||
ADVANCED_PARAMS_FIELDS
|
||||
)
|
@ -0,0 +1,85 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { array, object, string } from 'yup'
|
||||
|
||||
import {
|
||||
ROLE_DEFINITION_FIELDS,
|
||||
TEMPLATE_ID_FIELD,
|
||||
ROLE_DEFINITION_SCHEMA,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schemas/roleDefinition'
|
||||
|
||||
import {
|
||||
ADVANCED_PARAMS_SCHEMA,
|
||||
ADVANCED_PARAMS_FIELDS,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schemas/advancedParameters'
|
||||
|
||||
import {
|
||||
ELASTICITY_POLICY_SCHEMA,
|
||||
SECTION_ID as EPOLICY_ID,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/elasticity/schema'
|
||||
|
||||
import {
|
||||
SCHEDULED_POLICY_SCHEMA,
|
||||
SECTION_ID as SPOLICY_ID,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/scheduled/schema'
|
||||
|
||||
import { MIN_MAX_SCHEMA } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/minMax'
|
||||
|
||||
const NIC_SCHEMA = object()
|
||||
.shape({
|
||||
NIC: array().of(
|
||||
object().shape({
|
||||
NETWORK_ID: string(),
|
||||
NAME: string(),
|
||||
FLOATING_IP: string(),
|
||||
FLOATING_ONLY: string(),
|
||||
NIC_ALIAS: object()
|
||||
.shape({
|
||||
NETWORK_ID: string(),
|
||||
NAME: string(),
|
||||
})
|
||||
.default(() => undefined),
|
||||
})
|
||||
),
|
||||
})
|
||||
.default(() => undefined)
|
||||
|
||||
export const SCHEMA = array().of(
|
||||
object()
|
||||
.concat(ROLE_DEFINITION_SCHEMA)
|
||||
.concat(
|
||||
object().shape({
|
||||
template_contents: object().concat(NIC_SCHEMA),
|
||||
})
|
||||
)
|
||||
.concat(ADVANCED_PARAMS_SCHEMA)
|
||||
.concat(MIN_MAX_SCHEMA)
|
||||
.concat(
|
||||
object()
|
||||
.shape({
|
||||
[EPOLICY_ID]: array().of(ELASTICITY_POLICY_SCHEMA),
|
||||
})
|
||||
.concat(
|
||||
object().shape({
|
||||
[SPOLICY_ID]: array().of(SCHEDULED_POLICY_SCHEMA),
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
export const FIELDS = [...ROLE_DEFINITION_FIELDS, ...ADVANCED_PARAMS_FIELDS]
|
||||
|
||||
export { TEMPLATE_ID_FIELD }
|
@ -0,0 +1,130 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { INPUT_TYPES, T } from '@ConstantsModule'
|
||||
import { string, array, number } from 'yup'
|
||||
import { Field, getObjectSchemaFromFields, arrayToOptions } from '@UtilsModule'
|
||||
import { VmTemplatesTable } from '@modules/components/Tables'
|
||||
|
||||
// export const TAB_ID = 'definition'
|
||||
|
||||
/** @type {Field} Name field for role */
|
||||
const NAME = {
|
||||
name: 'name',
|
||||
label: T.Name,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
dependOf: 'name',
|
||||
validation: string()
|
||||
.trim()
|
||||
.required()
|
||||
.default(() => ''),
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
const CARDINALITY = {
|
||||
name: 'cardinality',
|
||||
type: INPUT_TYPES.TEXT,
|
||||
htmlType: 'number',
|
||||
label: T.NumberOfVms,
|
||||
validation: number()
|
||||
.min(1)
|
||||
.default(() => 1),
|
||||
grid: { md: 6 },
|
||||
}
|
||||
|
||||
const PARENTS = {
|
||||
name: 'parents',
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
multiple: true,
|
||||
label: T.ParentRoles,
|
||||
optionsOnly: true,
|
||||
tooltip: T.StraightStrategyConcept,
|
||||
dependOf: [NAME.name, `$roles`],
|
||||
values: (deps = []) => {
|
||||
const [ownName, roles] = deps
|
||||
|
||||
const children = roles
|
||||
?.filter((role) => role?.parents?.includes(ownName))
|
||||
.map(({ name }) => name)
|
||||
|
||||
const values =
|
||||
roles
|
||||
?.map((role) => role?.name)
|
||||
?.filter((role) => ownName !== role)
|
||||
?.filter((role) => !children.includes(role))
|
||||
?.filter(Boolean) ?? []
|
||||
|
||||
return arrayToOptions(values, { addEmpty: false })
|
||||
},
|
||||
validation: array()
|
||||
.of(string().trim())
|
||||
.notRequired()
|
||||
.default(() => undefined)
|
||||
.afterSubmit((value) =>
|
||||
Array.isArray(value) && value?.length > 0 ? value : undefined
|
||||
),
|
||||
clearInvalid: true, // Clears invalid values
|
||||
grid: { md: 6 },
|
||||
}
|
||||
|
||||
/* eslint-disable jsdoc/require-jsdoc */
|
||||
export const TEMPLATE_ID_FIELD = {
|
||||
name: 'template_id',
|
||||
label: 'Template ID',
|
||||
type: INPUT_TYPES.TABLE,
|
||||
Table: () => VmTemplatesTable.Table,
|
||||
validation: number()
|
||||
.min(0)
|
||||
.required()
|
||||
.default(() => undefined),
|
||||
grid: { md: 12 },
|
||||
singleSelect: true,
|
||||
fieldProps: {
|
||||
onRowsChange: (row = [], context = {}) => {
|
||||
const { name: path, formContext: { setValue } = {} } = context
|
||||
|
||||
if (!row?.length || !context) return
|
||||
|
||||
// Always selecting first row due to singleSelect
|
||||
const { values: { vrouter } = {} } = row?.[0] ?? {}
|
||||
|
||||
const basePath = path.split('.')
|
||||
// Pops off the "template_id" segment
|
||||
basePath.pop()
|
||||
basePath.push(TYPE.name)
|
||||
|
||||
setValue(basePath.join('.'), vrouter ? 'vr' : 'vm')
|
||||
},
|
||||
preserveState: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Does not need to be rendered
|
||||
const TYPE = {
|
||||
name: 'type',
|
||||
validation: string()
|
||||
.trim()
|
||||
.required()
|
||||
.oneOf(['vm', 'vr'])
|
||||
.default(() => 'vm'),
|
||||
htmlType: INPUT_TYPES.HIDDEN,
|
||||
}
|
||||
|
||||
export const ROLE_DEFINITION_FIELDS = [NAME, CARDINALITY, PARENTS, TYPE]
|
||||
|
||||
export const ROLE_DEFINITION_SCHEMA = getObjectSchemaFromFields([
|
||||
...ROLE_DEFINITION_FIELDS,
|
||||
TEMPLATE_ID_FIELD,
|
||||
])
|
@ -1,177 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { useState, useEffect, Component } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Paper,
|
||||
useTheme,
|
||||
} from '@mui/material'
|
||||
import { VmTemplateAPI, useGeneralApi } from '@FeaturesModule'
|
||||
import { DateTime } from 'luxon'
|
||||
import { Tr } from '@modules/components/HOC'
|
||||
import { T } from '@ConstantsModule'
|
||||
|
||||
const convertTimestampToDate = (timestamp) =>
|
||||
DateTime.fromSeconds(parseInt(timestamp)).toFormat('dd/MM/yyyy HH:mm:ss')
|
||||
|
||||
/**
|
||||
* VmTemplatesPanel component.
|
||||
*
|
||||
* @param {object} props - The props that are passed to this component.
|
||||
* @param {Array} props.roles - The roles available for selection.
|
||||
* @param {number} props.selectedRoleIndex - The index of the currently selected role.
|
||||
* @param {Function} props.onChange - Callback to be called when affinity settings are changed.
|
||||
* @param {Array} props.vmTemplates - VM Templates array
|
||||
* @param {object} props.error - Error object
|
||||
* @returns {Component} The VmTemplatesPanel component.
|
||||
*/
|
||||
const VmTemplatesPanel = ({
|
||||
roles,
|
||||
selectedRoleIndex,
|
||||
onChange,
|
||||
vmTemplates,
|
||||
error,
|
||||
}) => {
|
||||
const theme = useTheme()
|
||||
const { enqueueError } = useGeneralApi()
|
||||
const templateID = roles?.[selectedRoleIndex]?.SELECTED_VM_TEMPLATE_ID ?? []
|
||||
const templates =
|
||||
vmTemplates || (VmTemplateAPI.useGetTemplatesQuery()?.data ?? [])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
enqueueError(T.ErrorVmTemplateFetching, error?.message ?? error)
|
||||
}
|
||||
}, [error, enqueueError])
|
||||
|
||||
const [page, setPage] = useState(0)
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10)
|
||||
|
||||
const handleChangePage = (_event, newPage) => {
|
||||
setPage(newPage)
|
||||
}
|
||||
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10))
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const isDisabled = !roles?.[selectedRoleIndex] || roles?.length <= 0
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
my: 2,
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
maxHeight: '40%',
|
||||
pointerEvents: isDisabled ? 'none' : 'auto',
|
||||
opacity: isDisabled ? '50%' : '100%',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{Tr(T.VMTemplates)}
|
||||
</Typography>
|
||||
<Paper sx={{ overflow: 'auto', marginBottom: 2 }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox"></TableCell>
|
||||
<TableCell>{Tr(T.ID)}</TableCell>
|
||||
<TableCell>{Tr(T.Name)}</TableCell>
|
||||
<TableCell>{Tr(T.Owner)}</TableCell>
|
||||
<TableCell>{Tr(T.Group)}</TableCell>
|
||||
<TableCell>{Tr(T.RegistrationTime)}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{templates
|
||||
?.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||
?.map((vmTemplate) => (
|
||||
<TableRow
|
||||
key={vmTemplate.ID}
|
||||
hover
|
||||
sx={{
|
||||
'&:hover': {
|
||||
filter: 'brightness(85%)',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...roles[selectedRoleIndex],
|
||||
SELECTED_VM_TEMPLATE_ID: [vmTemplate.ID],
|
||||
})
|
||||
}
|
||||
name="SELECTED_VM_TEMPLATE_ID"
|
||||
role="checkbox"
|
||||
aria-checked={templateID.includes(vmTemplate.ID)}
|
||||
style={{
|
||||
backgroundColor: templateID?.includes(vmTemplate.ID)
|
||||
? theme?.palette?.action?.selected
|
||||
: theme?.palette.action?.disabledBackground,
|
||||
}}
|
||||
data-cy={`role-vmtemplate-${vmTemplate.ID}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox checked={templateID.includes(vmTemplate.ID)} />
|
||||
</TableCell>
|
||||
<TableCell>{vmTemplate.ID}</TableCell>
|
||||
<TableCell>{vmTemplate.NAME}</TableCell>
|
||||
<TableCell>{vmTemplate.UNAME}</TableCell>
|
||||
<TableCell>{vmTemplate.GNAME}</TableCell>
|
||||
<TableCell>
|
||||
{convertTimestampToDate(vmTemplate.REGTIME)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
<TablePagination
|
||||
rowsPerPageOptions={[10, 25, 50]}
|
||||
component="div"
|
||||
count={templates?.length ?? 0}
|
||||
rowsPerPage={rowsPerPage}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
labelRowsPerPage={Tr(T.RowsPerPage)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
VmTemplatesPanel.propTypes = {
|
||||
roles: PropTypes.array,
|
||||
selectedRoleIndex: PropTypes.number,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
vmTemplates: PropTypes.array,
|
||||
error: PropTypes.object,
|
||||
templateID: PropTypes.array,
|
||||
}
|
||||
|
||||
export default VmTemplatesPanel
|
@ -16,283 +16,188 @@
|
||||
import General, {
|
||||
STEP_ID as GENERAL_ID,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/General'
|
||||
|
||||
import Extra, {
|
||||
STEP_ID as EXTRA_ID,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
|
||||
import RoleDefinition, {
|
||||
STEP_ID as ROLE_DEFINITION_ID,
|
||||
|
||||
import { TAB_ID as ADVANCED_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams'
|
||||
|
||||
import { TAB_ID as NETWORK_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking'
|
||||
|
||||
import { SECTION_ID as NETWORKS_VALUES_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown'
|
||||
|
||||
import Roles, {
|
||||
STEP_ID as ROLE_ID,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles'
|
||||
|
||||
import RoleConfig, {
|
||||
STEP_ID as ROLE_CONFIG_ID,
|
||||
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig'
|
||||
import { TAB_ID as USER_INPUT_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/userInputs'
|
||||
|
||||
import { TAB_ID as SCHED_ACTION_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/scheduledActions'
|
||||
|
||||
import {
|
||||
parseNetworkString,
|
||||
parseCustomInputString,
|
||||
parseVmTemplateContents,
|
||||
convertKeysToCase,
|
||||
toNetworkString,
|
||||
fromNetworkString,
|
||||
toNetworksValueString,
|
||||
toUserInputString,
|
||||
fromUserInputString,
|
||||
fromNetworksValueString,
|
||||
createSteps,
|
||||
deepClean,
|
||||
} from '@UtilsModule'
|
||||
|
||||
const Steps = createSteps([General, Extra, RoleDefinition, RoleConfig], {
|
||||
const Steps = createSteps([General, Extra, Roles], {
|
||||
transformInitialValue: (ServiceTemplate, schema) => {
|
||||
const definedNetworks = Object.entries(
|
||||
ServiceTemplate?.TEMPLATE?.BODY?.networks || {}
|
||||
)
|
||||
?.map(([name, networkString]) =>
|
||||
parseNetworkString(`${name}|${networkString}`, true)
|
||||
)
|
||||
.filter(Boolean)
|
||||
const { NAME: name, DESCRIPTION: description } = ServiceTemplate
|
||||
|
||||
const customAttributes = Object.entries(
|
||||
ServiceTemplate?.TEMPLATE?.BODY?.custom_attrs || {}
|
||||
)
|
||||
?.map(([name, customInputString]) =>
|
||||
parseCustomInputString(`${name}|${customInputString}`, true)
|
||||
)
|
||||
.filter(Boolean)
|
||||
const template = ServiceTemplate?.TEMPLATE?.BODY ?? {}
|
||||
|
||||
const reversedVmTc = ServiceTemplate?.TEMPLATE?.BODY?.roles?.map((role) =>
|
||||
parseVmTemplateContents(role?.vm_template_contents, true)
|
||||
)
|
||||
/* eslint-disable camelcase */
|
||||
const {
|
||||
networks = {},
|
||||
user_inputs = {},
|
||||
networks_values = [],
|
||||
[SCHED_ACTION_ID]: sched_actions = [], // FireEdge only prop
|
||||
roles,
|
||||
} = template
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
const generalData = {
|
||||
NAME: ServiceTemplate?.TEMPLATE?.BODY?.name,
|
||||
DESCRIPTION: ServiceTemplate?.TEMPLATE?.BODY.description,
|
||||
}
|
||||
const networkParse = Object.entries(networks)?.reduce(
|
||||
(acc, network, idx) => {
|
||||
const res = []
|
||||
const parsedNetwork = fromNetworkString(network)
|
||||
|
||||
const definedRoles = ServiceTemplate?.TEMPLATE?.BODY?.roles
|
||||
?.filter((role) => role != null)
|
||||
?.map((role) => ({
|
||||
NAME: role?.name,
|
||||
CARDINALITY: role?.cardinality,
|
||||
SELECTED_VM_TEMPLATE_ID: [role?.vm_template.toString()],
|
||||
...(role?.parents ? { PARENTS: role?.parents } : {}),
|
||||
}))
|
||||
const matchingNetworksValue = networks_values?.find(
|
||||
(nv) => Object.keys(nv)?.pop() === parsedNetwork?.name
|
||||
)
|
||||
|
||||
const roleDefinitionData = definedRoles?.map((role) => ({
|
||||
...role,
|
||||
}))
|
||||
if (matchingNetworksValue) {
|
||||
// Size goes to parsedNetworks...
|
||||
const { SIZE, ...parsedNetworksValue } = fromNetworksValueString(
|
||||
Object.values(matchingNetworksValue)
|
||||
)
|
||||
|
||||
const networkDefs = reversedVmTc?.map((rtc) => rtc.networks)
|
||||
// Order matters
|
||||
res.push([{ ...parsedNetwork, SIZE }])
|
||||
res.push([parsedNetworksValue])
|
||||
} else {
|
||||
res.push([parsedNetwork])
|
||||
}
|
||||
|
||||
const roleConfigData = {
|
||||
ELASTICITYPOLICIES: convertKeysToCase(
|
||||
ServiceTemplate?.TEMPLATE?.BODY?.roles
|
||||
?.filter((role) => role != null)
|
||||
?.reduce((acc, role, index) => {
|
||||
if (role?.elasticity_policies) {
|
||||
acc[index] = role.elasticity_policies.reduce(
|
||||
(policyAcc, policy) => {
|
||||
policyAcc.push({
|
||||
...policy,
|
||||
COOLDOWN: +policy.cooldown,
|
||||
...(policy?.min && { MIN: +policy.min }),
|
||||
PERIOD: +policy.period,
|
||||
PERIOD_NUMBER: +policy.period_number,
|
||||
})
|
||||
|
||||
return policyAcc
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, []),
|
||||
false
|
||||
),
|
||||
SCHEDULEDPOLICIES: convertKeysToCase(
|
||||
ServiceTemplate?.TEMPLATE?.BODY?.roles
|
||||
?.filter((role) => role != null)
|
||||
?.reduce((acc, role, index) => {
|
||||
if (role?.scheduled_policies) {
|
||||
acc[index] = role.scheduled_policies.reduce(
|
||||
(policyAcc, policy) => {
|
||||
policyAcc.push({
|
||||
...(+policy?.min && { MIN: policy?.min }),
|
||||
SCHEDTYPE: policy?.type,
|
||||
TIMEFORMAT: policy?.recurrence
|
||||
? 'Recurrence'
|
||||
: 'Start time',
|
||||
TIMEEXPRESSION: policy?.recurrence || policy?.start_time,
|
||||
})
|
||||
|
||||
return policyAcc
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, []),
|
||||
false
|
||||
),
|
||||
vm_template_contents: reversedVmTc?.map(
|
||||
(content) => content?.remainingContent
|
||||
),
|
||||
MINMAXVMS: ServiceTemplate?.TEMPLATE?.BODY?.roles
|
||||
?.filter((role) => role != null)
|
||||
?.map((role) => ({
|
||||
min_vms: role.min_vms,
|
||||
max_vms: role.max_vms,
|
||||
cooldown: role.cooldown,
|
||||
}))
|
||||
?.filter((role) =>
|
||||
Object.values(role).some((val) => val !== undefined)
|
||||
),
|
||||
|
||||
NETWORKDEFS: networkDefs,
|
||||
RDP: networkDefs?.reduce((acc, nics, idx) => {
|
||||
const rdpRow =
|
||||
nics?.filter((nic) => nic?.RDP)?.[0]?.NETWORK_ID?.slice(1) ?? ''
|
||||
acc[idx] = rdpRow
|
||||
acc[idx] = res
|
||||
|
||||
return acc
|
||||
}, {}),
|
||||
}
|
||||
|
||||
const knownTemplate = schema.cast(
|
||||
{
|
||||
[EXTRA_ID]: {
|
||||
NETWORKING: definedNetworks,
|
||||
CUSTOM_ATTRIBUTES: customAttributes,
|
||||
// Sched actions are same for all roles, so just grab the first one
|
||||
SCHED_ACTION: reversedVmTc?.[0]?.schedActions,
|
||||
},
|
||||
[GENERAL_ID]: { ...generalData },
|
||||
[ROLE_DEFINITION_ID]: roleDefinitionData,
|
||||
[ROLE_CONFIG_ID]: { ...roleConfigData },
|
||||
},
|
||||
{ stripUnknown: true }
|
||||
[]
|
||||
)
|
||||
|
||||
return knownTemplate
|
||||
const [parsedNetworks, parsedNetworksValues] = [
|
||||
networkParse.map(([pn]) => pn).flat(),
|
||||
networkParse.map(([, pnv]) => pnv).flat(),
|
||||
]
|
||||
|
||||
return schema.cast(
|
||||
{
|
||||
[GENERAL_ID]: { name, description },
|
||||
[EXTRA_ID]: {
|
||||
[NETWORK_ID]: parsedNetworks,
|
||||
[NETWORKS_VALUES_ID]: parsedNetworksValues,
|
||||
[USER_INPUT_ID]: Object.entries(user_inputs).map(fromUserInputString),
|
||||
[SCHED_ACTION_ID]: sched_actions,
|
||||
[ADVANCED_ID]: { ...template }, // strips unknown keys so this is fine
|
||||
},
|
||||
roles,
|
||||
},
|
||||
|
||||
{ stripUnknown: true }
|
||||
)
|
||||
},
|
||||
|
||||
transformBeforeSubmit: (formData) => {
|
||||
const {
|
||||
[GENERAL_ID]: generalData,
|
||||
[ROLE_DEFINITION_ID]: roleDefinitionData,
|
||||
[EXTRA_ID]: extraData,
|
||||
[ROLE_CONFIG_ID]: roleConfigData,
|
||||
[ROLE_ID]: roleData,
|
||||
} = formData
|
||||
|
||||
const getVmTemplateContents = (index) => {
|
||||
const contents = parseVmTemplateContents({
|
||||
networks:
|
||||
roleConfigData?.NETWORKS?.[index] ||
|
||||
roleConfigData?.NETWORKDEFS?.[index],
|
||||
rdpConfig: roleConfigData?.RDP?.[index],
|
||||
remainingContent: roleConfigData?.vm_template_contents?.[index],
|
||||
schedActions: extraData?.SCHED_ACTION,
|
||||
})
|
||||
const {
|
||||
[ADVANCED_ID]: extraParams = {},
|
||||
[NETWORK_ID]: networks,
|
||||
[NETWORKS_VALUES_ID]: networksValues,
|
||||
[USER_INPUT_ID]: userInputs,
|
||||
[SCHED_ACTION_ID]: schedActions,
|
||||
} = extraData
|
||||
|
||||
return contents || ''
|
||||
}
|
||||
const formatRole = roleData?.map((role) => {
|
||||
const { NIC = [] } = role?.template_contents || {}
|
||||
|
||||
const getScheduledPolicies = (index) => {
|
||||
const policies = roleConfigData?.SCHEDULEDPOLICIES?.[index]?.map(
|
||||
(policy) => {
|
||||
const { SCHEDTYPE, ADJUST, TIMEFORMAT, TIMEEXPRESSION, ...rest } =
|
||||
policy
|
||||
return {
|
||||
...role,
|
||||
template_contents: {
|
||||
...role.template_contents,
|
||||
NIC: NIC?.filter(
|
||||
// Filter out stale NIC's
|
||||
({ NETWORK_ID: NIC_ID } = {}) =>
|
||||
networks?.some(
|
||||
({ name: NETWORK_NAME }) => `$${NETWORK_NAME}` === NIC_ID
|
||||
)
|
||||
)
|
||||
?.map(
|
||||
// Filter out stale aliases
|
||||
({
|
||||
NIC_ALIAS: { NETWORK_ID: ALIAS_ID, ...alias } = {},
|
||||
...nic
|
||||
} = {}) => {
|
||||
const validAlias = networks?.some(
|
||||
({ name: NETWORK_NAME }) => `$${NETWORK_NAME}` === ALIAS_ID
|
||||
)
|
||||
|
||||
return {
|
||||
...rest,
|
||||
TYPE: SCHEDTYPE,
|
||||
ADJUST: Number(ADJUST),
|
||||
[TIMEFORMAT?.split(' ')?.join('_')?.toLowerCase()]: TIMEEXPRESSION,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return policies?.length ? policies : undefined
|
||||
}
|
||||
|
||||
const getElasticityPolicies = (index) => {
|
||||
const elasticityPolicies = roleConfigData?.ELASTICITYPOLICIES?.[index]
|
||||
if (!elasticityPolicies || elasticityPolicies.length === 0)
|
||||
return undefined
|
||||
|
||||
return elasticityPolicies.map(({ ADJUST, ...rest }) => ({
|
||||
...rest,
|
||||
...(ADJUST && { adjust: Number(ADJUST) }),
|
||||
}))
|
||||
}
|
||||
|
||||
const getNetworks = () => {
|
||||
if (!extraData?.NETWORKING?.length) return undefined
|
||||
|
||||
return extraData.NETWORKING.reduce((acc, network) => {
|
||||
if (network?.name) {
|
||||
acc[network.name] = parseNetworkString(network)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const getCustomAttributes = () => {
|
||||
if (!extraData?.CUSTOM_ATTRIBUTES?.length) return undefined
|
||||
|
||||
return extraData.CUSTOM_ATTRIBUTES.reduce((acc, cinput) => {
|
||||
if (cinput?.name) {
|
||||
acc[cinput.name] = parseCustomInputString(cinput)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const getRoleParents = (index) => {
|
||||
if (
|
||||
!roleDefinitionData?.[index]?.PARENTS ||
|
||||
!Array.isArray(roleDefinitionData?.[index]?.PARENTS) ||
|
||||
roleDefinitionData?.[index]?.PARENTS?.length <= 0
|
||||
)
|
||||
return undefined
|
||||
|
||||
return roleDefinitionData?.[index]?.PARENTS
|
||||
}
|
||||
|
||||
try {
|
||||
const formatTemplate = {
|
||||
...generalData,
|
||||
...extraData?.ADVANCED,
|
||||
roles: roleDefinitionData?.map((roleDef, index) => {
|
||||
const newRoleDef = {
|
||||
...roleDef,
|
||||
...roleConfigData?.MINMAXVMS?.[index],
|
||||
VM_TEMPLATE: Number(roleDef?.SELECTED_VM_TEMPLATE_ID?.[0]),
|
||||
vm_template_contents: getVmTemplateContents(index),
|
||||
parents: getRoleParents(index),
|
||||
scheduled_policies: getScheduledPolicies(index),
|
||||
elasticity_policies: getElasticityPolicies(index),
|
||||
}
|
||||
|
||||
delete newRoleDef.SELECTED_VM_TEMPLATE_ID
|
||||
delete newRoleDef.MINMAXVMS
|
||||
|
||||
return newRoleDef
|
||||
}),
|
||||
networks: getNetworks(),
|
||||
custom_attrs: getCustomAttributes(),
|
||||
if (validAlias) {
|
||||
return {
|
||||
...nic,
|
||||
NIC_ALIAS: {
|
||||
...alias,
|
||||
NETWORK_ID: ALIAS_ID,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
...nic,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
// Explicitly remove any id's left from fieldArray
|
||||
?.map(
|
||||
({ id, NIC_ALIAS: { id: aliasId, ...alias } = {}, ...nic }) => ({
|
||||
...nic,
|
||||
...(alias ? { NIC_ALIAS: alias } : {}),
|
||||
})
|
||||
),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const cleanedTemplate = {
|
||||
...convertKeysToCase(formatTemplate, true, 1),
|
||||
...(formatTemplate?.roles || formatTemplate?.ROLES
|
||||
? {
|
||||
roles: convertKeysToCase(
|
||||
formatTemplate?.roles || formatTemplate?.ROLES
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
const formatTemplate = {
|
||||
...generalData,
|
||||
...extraParams,
|
||||
roles: formatRole,
|
||||
networks: Object.fromEntries(networks?.map(toNetworkString)) ?? [],
|
||||
networks_values: networks
|
||||
?.map((network, idx) =>
|
||||
toNetworksValueString(network, networksValues[idx])
|
||||
)
|
||||
?.filter(Boolean),
|
||||
|
||||
return cleanedTemplate
|
||||
} catch (error) {}
|
||||
user_inputs: userInputs
|
||||
? Object.fromEntries(userInputs?.map(toUserInputString))
|
||||
: [],
|
||||
[SCHED_ACTION_ID]: schedActions, // FireEdge only prop
|
||||
}
|
||||
|
||||
const cleanedTemplate = deepClean(formatTemplate)
|
||||
|
||||
return cleanedTemplate
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -20,40 +20,30 @@ import { SCHEMA, NAME_FIELD, INSTANCE_FIELD } from './schema'
|
||||
|
||||
export const STEP_ID = 'general'
|
||||
|
||||
const Content = ({ isUpdate }) => (
|
||||
const Content = () => (
|
||||
<FormWithSchema
|
||||
id={STEP_ID}
|
||||
cy={`${STEP_ID}`}
|
||||
fields={[
|
||||
{ ...NAME_FIELD, fieldProps: { disabled: !!isUpdate } },
|
||||
INSTANCE_FIELD,
|
||||
]}
|
||||
fields={[NAME_FIELD, INSTANCE_FIELD]}
|
||||
/>
|
||||
)
|
||||
|
||||
/**
|
||||
* General Service Template configuration.
|
||||
*
|
||||
* @param {object} data - Service Template data
|
||||
* @returns {object} General configuration step
|
||||
*/
|
||||
const General = (data) => {
|
||||
const isUpdate = data?.ID
|
||||
|
||||
return {
|
||||
id: STEP_ID,
|
||||
label: T.General,
|
||||
resolver: SCHEMA,
|
||||
optionsValidate: { abortEarly: false },
|
||||
content: () => Content({ isUpdate }),
|
||||
}
|
||||
}
|
||||
const General = () => ({
|
||||
id: STEP_ID,
|
||||
label: T.General,
|
||||
resolver: SCHEMA,
|
||||
optionsValidate: { abortEarly: false },
|
||||
content: () => Content(),
|
||||
})
|
||||
|
||||
General.propTypes = {
|
||||
data: PropTypes.object,
|
||||
setFormData: PropTypes.func,
|
||||
}
|
||||
|
||||
Content.propTypes = { isUpdate: PropTypes.bool }
|
||||
|
||||
export default General
|
||||
|
@ -24,9 +24,8 @@ const NAME_FIELD = {
|
||||
type: INPUT_TYPES.TEXT,
|
||||
validation: string()
|
||||
.trim()
|
||||
.min(3, 'Service name less than 3 characters')
|
||||
.max(128, 'Service name over 128 characters')
|
||||
.required('Name cannot be empty')
|
||||
.min(3)
|
||||
.required()
|
||||
.default(() => undefined),
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
@ -1,144 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 FormWithSchema from '@modules/components/Forms/FormWithSchema'
|
||||
import { NETWORK_TYPES } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/schema'
|
||||
import { T } from '@ConstantsModule'
|
||||
import { Box, Grid, TextField } from '@mui/material'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { createNetworkFields, createNetworkSchema } from './schema'
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form'
|
||||
|
||||
export const STEP_ID = 'network'
|
||||
export const FIELD_ARRAY = 'NETWORKS'
|
||||
|
||||
const Content = (props) => {
|
||||
const { control, setValue } = useFormContext()
|
||||
|
||||
const templatePath = props?.dataTemplate?.TEMPLATE?.BODY?.networks
|
||||
const networkInfo = Object.entries(templatePath || {}).reduce(
|
||||
(acc, [key, value]) => {
|
||||
const extraPart = value.split('::')?.[1]
|
||||
acc[key] = extraPart
|
||||
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
const [tableIndex, setTableIndex] = useState(0)
|
||||
|
||||
const [tableType, setTableType] = useState(
|
||||
Object.keys(NETWORK_TYPES)?.[0] ?? ''
|
||||
)
|
||||
|
||||
const fields = useMemo(
|
||||
() =>
|
||||
createNetworkFields(`${STEP_ID}.${FIELD_ARRAY}.${tableIndex}`, tableType),
|
||||
[tableIndex, tableType, STEP_ID]
|
||||
)
|
||||
|
||||
useFieldArray({
|
||||
name: useMemo(() => `${STEP_ID}.${FIELD_ARRAY}`, [STEP_ID, tableIndex]),
|
||||
control: control,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setValue(`${STEP_ID}.${FIELD_ARRAY}.${tableIndex}.tableType`, tableType)
|
||||
}, [tableType, tableIndex, STEP_ID])
|
||||
|
||||
if (fields?.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
select
|
||||
label="Network ID"
|
||||
onChange={(e) => {
|
||||
setTableIndex(e.target.selectedIndex)
|
||||
}}
|
||||
fullWidth
|
||||
InputProps={{
|
||||
inputProps: { 'data-cy': `select-${STEP_ID}-id` },
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
{Object.keys(networkInfo).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{key}
|
||||
</option>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
select
|
||||
label="Network Type"
|
||||
value={tableType}
|
||||
onChange={(e) => setTableType(e.target.value)}
|
||||
fullWidth
|
||||
InputProps={{
|
||||
inputProps: { 'data-cy': `select-${STEP_ID}-type` },
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
{Object.entries(NETWORK_TYPES).map(([key, value]) => (
|
||||
<option key={key} value={key}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<FormWithSchema cy={`${STEP_ID}`} fields={fields} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Content.propTypes = {
|
||||
dataTemplate: PropTypes.object,
|
||||
isUpdate: PropTypes.bool,
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} template - Service Template
|
||||
* @returns {object} - Step
|
||||
*/
|
||||
const Network = (template) => ({
|
||||
id: STEP_ID,
|
||||
label: T.Network,
|
||||
resolver: createNetworkSchema(),
|
||||
optionsValidate: { abortEarly: false },
|
||||
defaultDisabled: {
|
||||
condition: () => {
|
||||
const disableStep = !template?.dataTemplate?.TEMPLATE?.BODY?.networks
|
||||
|
||||
return disableStep
|
||||
},
|
||||
},
|
||||
content: () => Content(template),
|
||||
})
|
||||
Network.propTypes = {
|
||||
data: PropTypes.object,
|
||||
setFormData: PropTypes.func,
|
||||
}
|
||||
|
||||
export default Network
|
@ -1,72 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2025, 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 { INPUT_TYPES, T } from '@ConstantsModule'
|
||||
import { getValidationFromFields } from '@UtilsModule'
|
||||
import { mixed, string, object, array } from 'yup'
|
||||
import { VnTemplatesTable, VnsTable } from '@modules/components/Tables'
|
||||
|
||||
/**
|
||||
* @param {string} pathPrefix - Field array path prefix
|
||||
* @param {string} tableType - Table type
|
||||
* @returns {Array} - List of fields
|
||||
*/
|
||||
export const createNetworkFields = (pathPrefix, tableType) => {
|
||||
const getPath = (fieldName) =>
|
||||
pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName
|
||||
|
||||
return [
|
||||
{
|
||||
name: getPath('extra'),
|
||||
label: T.Extra,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
cy: 'network',
|
||||
validation: string()
|
||||
.notRequired()
|
||||
.default(() => null),
|
||||
grid: { xs: 12, sm: 12, md: 12 },
|
||||
},
|
||||
{
|
||||
name: getPath('netid'),
|
||||
type: INPUT_TYPES.TABLE,
|
||||
cy: 'network',
|
||||
Table: () =>
|
||||
['existing', 'reserve'].includes(tableType)
|
||||
? VnsTable.Table
|
||||
: VnTemplatesTable.Table,
|
||||
singleSelect: true,
|
||||
fieldProps: {
|
||||
preserveState: true,
|
||||
},
|
||||
validation: mixed()
|
||||
.required('Network ID missing or malformed!')
|
||||
.default(() => null),
|
||||
grid: { xs: 12, sm: 12, md: 12 },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pathPrefix - Path
|
||||
* @param {string} tableType - Type of table to display
|
||||
* @returns {object} - Yup schema
|
||||
*/
|
||||
export const createNetworkSchema = (pathPrefix, tableType) => {
|
||||
const fields = createNetworkFields(pathPrefix, tableType)
|
||||
|
||||
return object().shape({
|
||||
NETWORKS: array().of(object(getValidationFromFields(fields))),
|
||||
})
|
||||
}
|
@ -36,11 +36,6 @@ export const STEP_ID = 'user_inputs'
|
||||
const Content = ({ userInputsLayout, showMandatoryOnly }) =>
|
||||
generateTabs(userInputsLayout, STEP_ID, FIELDS, showMandatoryOnly)
|
||||
|
||||
Content.propTypes = {
|
||||
props: PropTypes.any,
|
||||
userInputsLayout: PropTypes.object,
|
||||
}
|
||||
|
||||
/**
|
||||
* User inputs step.
|
||||
*
|
||||
@ -57,4 +52,9 @@ const UserInputsStep = (userInputs, userInputsLayout) => ({
|
||||
content: (props) => Content({ ...props, userInputsLayout }),
|
||||
})
|
||||
|
||||
Content.propTypes = {
|
||||
props: PropTypes.any,
|
||||
userInputsLayout: PropTypes.object,
|
||||
}
|
||||
|
||||
export default UserInputsStep
|
||||
|
@ -25,15 +25,11 @@ import UserInputsRole, {
|
||||
STEP_ID as USERINPUTSROLE_ID,
|
||||
} from '@modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/UserInputsRole'
|
||||
|
||||
import Network, {
|
||||
STEP_ID as NETWORK_ID,
|
||||
} from '@modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/Network'
|
||||
|
||||
import Charter, {
|
||||
STEP_ID as CHARTER_ID,
|
||||
} from '@modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/Charters'
|
||||
|
||||
import { createSteps, parseVmTemplateContents } from '@UtilsModule'
|
||||
import { createSteps } from '@UtilsModule'
|
||||
import { groupServiceUserInputs } from '@modules/components/Forms/UserInputs'
|
||||
|
||||
const Steps = createSteps(
|
||||
@ -56,106 +52,44 @@ const Steps = createSteps(
|
||||
userInputsData.roles.userInputs,
|
||||
userInputsData.roles.userInputsLayout
|
||||
)),
|
||||
Network,
|
||||
Charter,
|
||||
].filter(Boolean)
|
||||
},
|
||||
{
|
||||
transformInitialValue: (ServiceTemplate, schema) => {
|
||||
const templatePath = ServiceTemplate?.TEMPLATE?.BODY
|
||||
const roles = templatePath?.roles ?? []
|
||||
|
||||
const networks = Object.entries(templatePath?.networks ?? {}).map(
|
||||
([key, value]) => {
|
||||
const extra = value.split(':').pop()
|
||||
|
||||
return {
|
||||
netid: null,
|
||||
extra: extra,
|
||||
name: key,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Get schedule actions from vm template contents
|
||||
const schedActions = parseVmTemplateContents(
|
||||
ServiceTemplate?.TEMPLATE?.BODY?.roles[0]?.vm_template_contents,
|
||||
true
|
||||
)?.schedActions
|
||||
const { NAME } = ServiceTemplate
|
||||
const {
|
||||
TEMPLATE: { BODY: { sched_actions: schedActions = [] } = {} } = {},
|
||||
} = ServiceTemplate
|
||||
|
||||
const knownTemplate = schema.cast({
|
||||
[GENERAL_ID]: {},
|
||||
[GENERAL_ID]: { NAME },
|
||||
[USERINPUTS_ID]: {},
|
||||
[USERINPUTSROLE_ID]: {},
|
||||
[NETWORK_ID]: { NETWORKS: networks },
|
||||
[CHARTER_ID]: { SCHED_ACTION: schedActions },
|
||||
})
|
||||
|
||||
const newRoles = roles.map((role) => {
|
||||
// Parse vm template content
|
||||
const roleTemplateContent = parseVmTemplateContents(
|
||||
role.vm_template_contents,
|
||||
true
|
||||
)
|
||||
|
||||
// Delete schedule actions
|
||||
delete roleTemplateContent.schedActions
|
||||
|
||||
// Parse content without sched actions
|
||||
const roleTemplateWithoutSchedActions = parseVmTemplateContents(
|
||||
roleTemplateContent,
|
||||
false
|
||||
)
|
||||
role.vm_template_contents = roleTemplateWithoutSchedActions
|
||||
|
||||
// Return content
|
||||
return role
|
||||
})
|
||||
|
||||
return { ...knownTemplate, roles: newRoles }
|
||||
return { ...knownTemplate }
|
||||
},
|
||||
|
||||
transformBeforeSubmit: (formData) => {
|
||||
const {
|
||||
[GENERAL_ID]: generalData,
|
||||
[USERINPUTS_ID]: userInputsData,
|
||||
[USERINPUTSROLE_ID]: userInputsRoleData,
|
||||
[NETWORK_ID]: networkData,
|
||||
[CHARTER_ID]: charterData,
|
||||
} = formData
|
||||
|
||||
const formatTemplate = {
|
||||
custom_attrs_values: Object.fromEntries(
|
||||
Object.entries({
|
||||
...userInputsData,
|
||||
...userInputsRoleData,
|
||||
}).map(([key, value]) => [key.toUpperCase(), String(value)])
|
||||
),
|
||||
networks_values: networkData?.NETWORKS?.map((network) => ({
|
||||
[network?.name]: {
|
||||
[['existing', 'reserve'].includes(network?.tableType)
|
||||
? 'id'
|
||||
: 'template_id']: network?.netid,
|
||||
},
|
||||
})),
|
||||
roles: formData?.roles?.map((role) => {
|
||||
delete role.vm_template_id_content
|
||||
const userInputsValues = Object.fromEntries(
|
||||
Object.entries({
|
||||
...userInputsData,
|
||||
}).map(([key, value]) => [key.toUpperCase(), String(value)])
|
||||
)
|
||||
|
||||
return {
|
||||
...role,
|
||||
vm_template_contents: parseVmTemplateContents(
|
||||
{
|
||||
vmTemplateContents: role?.vm_template_contents,
|
||||
customAttrsValues: { ...userInputsData, ...userInputsRoleData },
|
||||
schedActions: charterData.SCHED_ACTION,
|
||||
},
|
||||
false,
|
||||
true
|
||||
),
|
||||
}
|
||||
}),
|
||||
const formatTemplate = {
|
||||
user_inputs_values: userInputsValues, // Applied across all roles
|
||||
name: generalData?.NAME,
|
||||
instances: generalData?.INSTANCES,
|
||||
...charterData,
|
||||
}
|
||||
|
||||
return formatTemplate
|
||||
|
@ -417,15 +417,11 @@ const generateTabs = (userInputsLayout, STEP_ID, FIELDS, showMandatoryOnly) => {
|
||||
key={`user-inputs`}
|
||||
cy={`user-inputs`}
|
||||
id={STEP_ID}
|
||||
fields={
|
||||
showMandatoryOnly
|
||||
? FIELDS(
|
||||
userInputsLayout[0].groups[0].userInputs.filter(
|
||||
(userInput) => userInput.mandatory
|
||||
)
|
||||
)
|
||||
: FIELDS(userInputsLayout[0].groups[0].userInputs)
|
||||
}
|
||||
fields={FIELDS(
|
||||
userInputsLayout[0].groups[0].userInputs.filter(
|
||||
(userInput) => !showMandatoryOnly || userInput.mandatory
|
||||
)
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -477,13 +473,18 @@ const generateTabs = (userInputsLayout, STEP_ID, FIELDS, showMandatoryOnly) => {
|
||||
* @param {Array} userInputs - List of user inputs.
|
||||
* @returns {Array} - List of fields.
|
||||
*/
|
||||
const createFieldsFromUserInputs = (userInputs = []) =>
|
||||
userInputs.map(({ name, description, label, ...restOfUserInput }) => ({
|
||||
name,
|
||||
label: label || name,
|
||||
...(description && { tooltip: description }),
|
||||
...schemaUserInput(restOfUserInput),
|
||||
}))
|
||||
const createFieldsFromUserInputs = (userInputs = []) => {
|
||||
const res = userInputs.map(
|
||||
({ name, description, label, ...restOfUserInput }) => ({
|
||||
name,
|
||||
label: label || name,
|
||||
...(description && { tooltip: description }),
|
||||
...schemaUserInput(restOfUserInput),
|
||||
})
|
||||
)
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups the user inputs by app and group using the following convetion: ONEAPP_<APP_NAME>_<GROUP_NAME>_<FIELD_NAME>.
|
||||
@ -610,13 +611,14 @@ const groupUserInputs = (userInputs, userInputsMetadata, prefix) => {
|
||||
const groupServiceUserInputs = (service) => {
|
||||
// Get and order service user inputs
|
||||
const serviceUserInputs = userInputsToArray(
|
||||
service?.TEMPLATE?.BODY?.custom_attrs
|
||||
service?.TEMPLATE?.BODY?.user_inputs
|
||||
)
|
||||
|
||||
// Get service user inputs metadata
|
||||
const serviceUserInputsMetadata = responseDataToArray(
|
||||
service?.TEMPLATE?.BODY?.custom_attrs_metadata
|
||||
service?.TEMPLATE?.BODY?.user_inputs_metadata
|
||||
)
|
||||
|
||||
const serviceUserInputsLayout = groupUserInputs(
|
||||
serviceUserInputs,
|
||||
serviceUserInputsMetadata
|
||||
|
@ -223,7 +223,7 @@ const MUTABLE_FIELDS = (oneConfig, adminGroup) =>
|
||||
* @param {boolean} stepProps.adminGroup - If the user belongs to oneadmin group
|
||||
* @returns {BaseSchema} Schema
|
||||
*/
|
||||
const SCHEMA = ({ isUpdate, oneConfig, adminGroup }) =>
|
||||
const SCHEMA = ({ isUpdate, oneConfig, adminGroup } = {}) =>
|
||||
getObjectSchemaFromFields([
|
||||
...(isUpdate
|
||||
? MUTABLE_FIELDS(oneConfig, adminGroup)
|
||||
|
@ -20,8 +20,8 @@ import {
|
||||
import { jsonToXml } from '@ModelsModule'
|
||||
import { createForm } from '@UtilsModule'
|
||||
|
||||
const AddRangeForm = createForm(SCHEMA, FIELDS, {
|
||||
const ReserveForm = createForm(SCHEMA, FIELDS, {
|
||||
transformBeforeSubmit: (formData) => jsonToXml({ ...formData }),
|
||||
})
|
||||
|
||||
export default AddRangeForm
|
||||
export default ReserveForm
|
||||
|
@ -99,6 +99,7 @@ const createArgField = (argName) => ({
|
||||
dependOf: ACTION_FIELD_NAME,
|
||||
htmlType: (action) =>
|
||||
!getRequiredArgsByAction(action)?.includes(argName) && INPUT_TYPES.HIDDEN,
|
||||
grid: { md: 12 },
|
||||
})
|
||||
|
||||
/**
|
||||
@ -122,7 +123,7 @@ const ACTION_FIELD = (vm) => ({
|
||||
}
|
||||
),
|
||||
validation: ACTION_FIELD_VALIDATION,
|
||||
grid: { xs: 12 },
|
||||
grid: { md: 12 },
|
||||
})
|
||||
|
||||
/** @type {Field} Action name field */
|
||||
@ -148,6 +149,7 @@ const ARGS_DS_ID_FIELD = {
|
||||
return arrayToOptions(
|
||||
datastores.filter(({ TEMPLATE }) => TEMPLATE.TYPE === 'BACKUP_DS'),
|
||||
{
|
||||
addEmpty: false,
|
||||
getText: ({ NAME, ID } = {}) => `${ID}: ${NAME}`,
|
||||
getValue: ({ ID } = {}) => ID,
|
||||
}
|
||||
@ -165,6 +167,7 @@ const ARGS_DISK_ID_FIELD = (vm) => ({
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
optionsOnly: true,
|
||||
values: arrayToOptions(getDisks(vm), {
|
||||
addEmpty: false,
|
||||
getText: ({ IMAGE_ID, IMAGE, TARGET, SIZE } = {}) => {
|
||||
const isVolatile = !IMAGE && !IMAGE_ID
|
||||
const diskImage = isVolatile
|
||||
@ -194,6 +197,7 @@ const ARGS_SNAPSHOT_ID_FIELD = (vm) => ({
|
||||
type: INPUT_TYPES.AUTOCOMPLETE,
|
||||
optionsOnly: true,
|
||||
values: arrayToOptions(getSnapshotList(vm), {
|
||||
addEmpty: false,
|
||||
getText: ({ NAME } = {}) => NAME,
|
||||
getValue: ({ SNAPSHOT_ID } = {}) => SNAPSHOT_ID,
|
||||
}),
|
||||
@ -248,6 +252,7 @@ const TIME_FIELD = {
|
||||
fieldProps: {
|
||||
minDateTime: getNow(),
|
||||
},
|
||||
grid: { md: 12 },
|
||||
}
|
||||
// --------------------------------------------------------
|
||||
// Periodic fields
|
||||
@ -274,7 +279,7 @@ const REPEAT_FIELD = {
|
||||
? schema.required()
|
||||
: schema
|
||||
),
|
||||
grid: { md: 6 },
|
||||
grid: { md: 12 },
|
||||
notNull: true,
|
||||
}
|
||||
|
||||
@ -311,7 +316,7 @@ const WEEKLY_FIELD = {
|
||||
)
|
||||
.afterSubmit((value) => value?.join?.(','))
|
||||
),
|
||||
grid: { md: 6 },
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
/** @type {Field} Monthly field */
|
||||
@ -330,7 +335,7 @@ const MONTHLY_FIELD = {
|
||||
) && INPUT_TYPES.HIDDEN
|
||||
)
|
||||
},
|
||||
grid: { md: 6 },
|
||||
grid: { md: 12 },
|
||||
validation: lazy((_, { context }) =>
|
||||
string()
|
||||
.trim()
|
||||
@ -360,7 +365,7 @@ const YEARLY_FIELD = {
|
||||
) && INPUT_TYPES.HIDDEN
|
||||
)
|
||||
},
|
||||
grid: { md: 6 },
|
||||
grid: { md: 12 },
|
||||
validation: lazy((_, { context }) =>
|
||||
string()
|
||||
.trim()
|
||||
@ -378,7 +383,7 @@ const HOURLY_FIELD = {
|
||||
dependOf: [PERIODIC_FIELD_NAME, REPEAT_FIELD.name],
|
||||
type: INPUT_TYPES.TEXT,
|
||||
label: T.EachXHours,
|
||||
grid: { md: 6 },
|
||||
grid: { md: 12 },
|
||||
htmlType: (_, context) => {
|
||||
const values = context?.getValues() || {}
|
||||
|
||||
@ -453,6 +458,7 @@ const END_TYPE_FIELD = {
|
||||
getValue: (value) => END_TYPE_VALUES[value],
|
||||
}),
|
||||
validation: mixed().notRequired(),
|
||||
grid: { md: 12 },
|
||||
}
|
||||
|
||||
/** @type {Field} End value field */
|
||||
@ -467,11 +473,11 @@ const END_VALUE_FIELD = {
|
||||
typeAction === SCHEDULE_TYPE.PERIODIC && endType === END_TYPE_VALUES.DATE
|
||||
? INPUT_TYPES.TIME
|
||||
: INPUT_TYPES.TEXT,
|
||||
htmlType: (_, context) => {
|
||||
const values = context?.getValues() || {}
|
||||
htmlType: (depends = []) => {
|
||||
const [PERIODIC, END_TYPE] = depends
|
||||
|
||||
return values?.PERIODIC === SCHEDULE_TYPE.PERIODIC &&
|
||||
values?.END_TYPE !== END_TYPE_VALUES.NEVER
|
||||
return PERIODIC === SCHEDULE_TYPE.PERIODIC &&
|
||||
END_TYPE !== END_TYPE_VALUES.NEVER
|
||||
? 'number'
|
||||
: INPUT_TYPES.HIDDEN
|
||||
},
|
||||
@ -493,6 +499,7 @@ const END_VALUE_FIELD = {
|
||||
}
|
||||
}
|
||||
),
|
||||
grid: { md: 12 },
|
||||
fieldProps: ([_, endType] = []) =>
|
||||
endType === END_TYPE_VALUES.DATE && { defaultValue: getNextWeek() },
|
||||
}
|
||||
|
@ -259,9 +259,7 @@ export const USER_INPUTS_SCHEMA = object({
|
||||
USER_INPUTS: array(USER_INPUT_SCHEMA)
|
||||
.ensure()
|
||||
.afterSubmit((userInputs, { context }) => {
|
||||
const capacityInputs = userInputsToArray(context?.general?.MODIFICATION, {
|
||||
filterCapacityInputs: false,
|
||||
})
|
||||
const capacityInputs = userInputsToArray(context?.general?.MODIFICATION)
|
||||
.map(({ name, ...userInput }) => ({
|
||||
name,
|
||||
...userInput,
|
||||
|
@ -19,11 +19,7 @@ import BasicConfiguration, {
|
||||
import Networking from '@modules/components/Forms/VrTemplate/InstantiateForm/Steps/Networking'
|
||||
import TemplateSelection from '@modules/components/Forms/VrTemplate/InstantiateForm/Steps/TemplateSelection'
|
||||
import UserInputs from '@modules/components/Forms/VrTemplate/InstantiateForm/Steps/UserInputs'
|
||||
import {
|
||||
getUserInputParams,
|
||||
parseRangeToArray,
|
||||
userInputsToArray,
|
||||
} from '@ModelsModule'
|
||||
import { userInputsToArray } from '@ModelsModule'
|
||||
import { createSteps } from '@UtilsModule'
|
||||
import { groupUserInputs } from '@modules/components/Forms/UserInputs'
|
||||
|
||||
@ -56,39 +52,13 @@ const Steps = createSteps(
|
||||
].filter(Boolean)
|
||||
},
|
||||
{
|
||||
transformInitialValue: (vmTemplate, schema) => {
|
||||
if (vmTemplate?.TEMPLATE?.USER_INPUTS) {
|
||||
;['MEMORY', 'CPU', 'VCPU'].forEach((element) => {
|
||||
if (vmTemplate?.TEMPLATE?.USER_INPUTS?.[element]) {
|
||||
const valuesOfUserInput = getUserInputParams(
|
||||
vmTemplate.TEMPLATE.USER_INPUTS[element]
|
||||
)
|
||||
if (valuesOfUserInput?.default) {
|
||||
let options = valuesOfUserInput?.options
|
||||
valuesOfUserInput?.type === 'range' &&
|
||||
(options = parseRangeToArray(options[0], options[1]))
|
||||
|
||||
if (!options.includes(valuesOfUserInput.default)) {
|
||||
delete vmTemplate?.TEMPLATE?.USER_INPUTS?.[element]
|
||||
} else {
|
||||
vmTemplate?.TEMPLATE?.[element] &&
|
||||
delete vmTemplate?.TEMPLATE?.[element]
|
||||
}
|
||||
} else {
|
||||
vmTemplate?.TEMPLATE?.[element] &&
|
||||
delete vmTemplate?.TEMPLATE?.[element]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return schema.cast(
|
||||
transformInitialValue: (vmTemplate, schema) =>
|
||||
schema.cast(
|
||||
{
|
||||
[BASIC_ID]: vmTemplate?.TEMPLATE,
|
||||
},
|
||||
{ stripUnknown: true }
|
||||
)
|
||||
},
|
||||
),
|
||||
transformBeforeSubmit: (formData, vmTemplate) => {
|
||||
const { [BASIC_ID]: { name, instances, hold, vmname } = {} } =
|
||||
formData ?? {}
|
||||
|
@ -17,7 +17,7 @@ import { useMemo } from 'react'
|
||||
import { useTheme, Box, Typography } from '@mui/material'
|
||||
import { css } from '@emotion/css'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { PlayOutline, Trash, Group, RefreshCircular } from 'iconoir-react'
|
||||
import { AddCircledOutline, Trash, Group, RefreshCircular } from 'iconoir-react'
|
||||
|
||||
import { useViews, ServiceAPI } from '@FeaturesModule'
|
||||
|
||||
@ -85,7 +85,7 @@ const Actions = () => {
|
||||
{
|
||||
accessor: SERVICE_TEMPLATE_ACTIONS.INSTANTIATE_DIALOG,
|
||||
tooltip: T.Instantiate,
|
||||
icon: PlayOutline,
|
||||
icon: AddCircledOutline,
|
||||
options: [
|
||||
{
|
||||
isConfirmDialog: true,
|
||||
|
@ -25,6 +25,8 @@ export const rowStyles = ({ palette, typography, breakpoints } = {}) => ({
|
||||
fontSize: '1em',
|
||||
borderRadius: 6,
|
||||
display: 'flex',
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
border: `1px solid ${palette.divider}`,
|
||||
gap: 8,
|
||||
[breakpoints.down('md')]: {
|
||||
flexWrap: 'wrap',
|
||||
|
@ -15,16 +15,21 @@
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { ReactElement, memo, useState, useMemo } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { object } from 'yup'
|
||||
|
||||
import { ButtonGenerator } from '@modules/components/Tabs/Service/ButtonGenerator'
|
||||
import { ServiceAPI, VmTemplateAPI, useGeneralApi } from '@FeaturesModule'
|
||||
import { ServiceAPI, useGeneralApi } from '@FeaturesModule'
|
||||
|
||||
import { deepClean } from '@UtilsModule'
|
||||
|
||||
import { VmsTable } from '@modules/components/Tables'
|
||||
import { StatusCircle } from '@modules/components/Status'
|
||||
import { getRoleState } from '@ModelsModule'
|
||||
import { Box, Dialog, Typography, CircularProgress } from '@mui/material'
|
||||
import { Content as RoleAddDialog } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig'
|
||||
import { Button, Box, Dialog, Typography } from '@mui/material'
|
||||
import RoleStep from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles'
|
||||
import { ScaleDialog } from '@modules/components/Tabs/Service/ScaleDialog'
|
||||
import { useForm, FormProvider } from 'react-hook-form'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import {
|
||||
Plus,
|
||||
Trash,
|
||||
@ -41,6 +46,38 @@ import { Tr } from '@modules/components/HOC'
|
||||
// Filters actions based on the data-cy key
|
||||
const filterActions = ['vm_resume', 'vm-manage', 'vm-host', 'vm-terminate']
|
||||
|
||||
const { resolver: rolesResolver, content: RoleAddDialog } = RoleStep()
|
||||
|
||||
/* eslint-disable react/prop-types */
|
||||
const AddRoleDialog = ({ open, onClose, onSubmit }) => {
|
||||
const methods = useForm({
|
||||
mode: 'onSubmit',
|
||||
defaultValues: rolesResolver.default(),
|
||||
resolver: yupResolver(object().shape({ roles: rolesResolver })),
|
||||
})
|
||||
|
||||
const { handleSubmit } = methods
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||
<FormProvider {...methods}>
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<RoleAddDialog standaloneModal />
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="submit"
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
{T.Submit}
|
||||
</Button>
|
||||
</Box>
|
||||
</FormProvider>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
/* eslint-enable react/prop-types */
|
||||
|
||||
/**
|
||||
* Renders template tab.
|
||||
*
|
||||
@ -49,8 +86,6 @@ const filterActions = ['vm_resume', 'vm-manage', 'vm-host', 'vm-terminate']
|
||||
* @returns {ReactElement} Roles tab
|
||||
*/
|
||||
const RolesTab = ({ id }) => {
|
||||
const [fetch, { data, error, isFetching }] =
|
||||
VmTemplateAPI.useLazyGetTemplatesQuery()
|
||||
const { enqueueError, enqueueSuccess, enqueueInfo } = useGeneralApi()
|
||||
// wrapper
|
||||
const createApiCallback = (apiFunction) => async (params) => {
|
||||
@ -59,12 +94,20 @@ const RolesTab = ({ id }) => {
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// api calls
|
||||
const [addRole] = ServiceAPI.useServiceAddRoleMutation()
|
||||
const [addRoleAction] = ServiceAPI.useServiceRoleActionMutation()
|
||||
const [scaleRole] = ServiceAPI.useServiceScaleRoleMutation()
|
||||
// api handlers
|
||||
const handleAddRole = createApiCallback(addRole)
|
||||
|
||||
const handleAddRole = async (data) => {
|
||||
const cleanedRole = deepClean(data?.roles?.[0])
|
||||
const result = await addRole({ id, role: cleanedRole })
|
||||
|
||||
handleCloseAddRole()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const handleAddRoleAction = async (actionType) => {
|
||||
for (const roleIdx of selectedRoles) {
|
||||
@ -88,7 +131,6 @@ const RolesTab = ({ id }) => {
|
||||
const handleScaleRole = createApiCallback(scaleRole)
|
||||
|
||||
const [activeRole, setActiveRole] = useState({ idx: null, roleName: null })
|
||||
|
||||
const [isAddRoleOpen, setAddRoleOpen] = useState(false)
|
||||
const [isScaleDialogOpen, setScaleDialogOpen] = useState(false)
|
||||
|
||||
@ -109,21 +151,6 @@ const RolesTab = ({ id }) => {
|
||||
[roles]
|
||||
)
|
||||
|
||||
/* eslint-disable react/prop-types */
|
||||
const AddRoleDialog = ({ open, onClose }) => (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||
<RoleAddDialog
|
||||
standaloneModal
|
||||
standaloneModalCallback={(params) => {
|
||||
handleAddRole(params)
|
||||
onClose()
|
||||
}}
|
||||
fetchedVmTemplates={{ vmTemplates: data, error: error }}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
/* eslint-enable react/prop-types */
|
||||
|
||||
const handleRoleClick = (idx, role, event) => {
|
||||
event.stopPropagation()
|
||||
|
||||
@ -150,8 +177,7 @@ const RolesTab = ({ id }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenAddRole = async () => {
|
||||
await fetch()
|
||||
const handleOpenAddRole = () => {
|
||||
setAddRoleOpen(true)
|
||||
}
|
||||
|
||||
@ -185,11 +211,10 @@ const RolesTab = ({ id }) => {
|
||||
items={{
|
||||
name: T.AddRole,
|
||||
onClick: handleOpenAddRole,
|
||||
icon: isFetching ? <CircularProgress size={24} /> : <Plus />,
|
||||
icon: <Plus />,
|
||||
}}
|
||||
options={{
|
||||
singleButton: {
|
||||
disabled: !!isFetching,
|
||||
sx: {
|
||||
fontSize: '0.95rem',
|
||||
padding: '6px 8px',
|
||||
@ -202,7 +227,11 @@ const RolesTab = ({ id }) => {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<AddRoleDialog open={isAddRoleOpen} onClose={handleCloseAddRole} />
|
||||
<AddRoleDialog
|
||||
open={isAddRoleOpen}
|
||||
onClose={handleCloseAddRole}
|
||||
onSubmit={handleAddRole}
|
||||
/>
|
||||
</>
|
||||
|
||||
<ButtonGenerator
|
||||
@ -414,7 +443,7 @@ const RolesTab = ({ id }) => {
|
||||
minHeight="500px"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<VmsTable
|
||||
<VmsTable.Table
|
||||
globalActions={filteredActions}
|
||||
filterData={roleVms?.[activeRole?.roleName]}
|
||||
filterLoose={false}
|
||||
@ -431,7 +460,7 @@ RolesTab.propTypes = { tabProps: PropTypes.object, id: PropTypes.string }
|
||||
RolesTab.displayName = 'RolesTab'
|
||||
|
||||
const RoleComponent = memo(({ role, selected, status }) => {
|
||||
const { name, cardinality, vm_template: templateId } = role
|
||||
const { name, cardinality, template_id: templateId } = role
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
@ -23,7 +23,7 @@ import { Translate } from '@modules/components/HOC'
|
||||
import { T, Role } from '@ConstantsModule'
|
||||
import { PATH } from '@modules/components/path'
|
||||
|
||||
const COLUMNS = [T.Name, T.Cardinality, T.VMTemplate, T.Parents]
|
||||
const COLUMNS = [T.Name, T.Type, T.Cardinality, T.VMTemplate, T.Parents]
|
||||
|
||||
/**
|
||||
* Renders roles tab.
|
||||
@ -41,7 +41,7 @@ const RolesTab = ({ id }) => {
|
||||
return (
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateColumns="repeat(4, 1fr)"
|
||||
gridTemplateColumns="repeat(5, 1fr)"
|
||||
padding="1em"
|
||||
bgcolor="background.default"
|
||||
>
|
||||
@ -70,7 +70,7 @@ RolesTab.displayName = 'RolesTab'
|
||||
|
||||
const RoleComponent = memo(({ role }) => {
|
||||
/** @type {Role} */
|
||||
const { name, cardinality, vm_template: templateId, parents } = role
|
||||
const { name, type, cardinality, template_id: templateId, parents } = role
|
||||
|
||||
const { data: template, isLoading } = VmTemplateAPI.useGetTemplatesQuery(
|
||||
undefined,
|
||||
@ -94,6 +94,9 @@ const RoleComponent = memo(({ role }) => {
|
||||
<Typography {...commonProps} data-cy="name">
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography {...commonProps} data-cy="type">
|
||||
{type}
|
||||
</Typography>
|
||||
<Typography {...commonProps} data-cy="cardinality">
|
||||
{cardinality}
|
||||
</Typography>
|
||||
|
@ -40,7 +40,7 @@ const TabContent = styled('div')(({ hidden, border, theme }) => ({
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: `thin solid ${theme.palette.secondary.main}`,
|
||||
borderTop: 'none',
|
||||
borderRadius: `8px 8px 0 0`,
|
||||
borderRadius: `0 0 8px 8px`,
|
||||
}),
|
||||
}))
|
||||
|
||||
|
@ -15,6 +15,11 @@
|
||||
* ------------------------------------------------------------------------- */
|
||||
import PropTypes from 'prop-types'
|
||||
import { ReactElement } from 'react'
|
||||
import {
|
||||
bindSecGroupTemplate,
|
||||
isRestrictedAttributes,
|
||||
unbindSecGroupTemplate,
|
||||
} from '@UtilsModule'
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import {
|
||||
@ -27,8 +32,6 @@ import { SecurityGroupAPI, VnAPI, useGeneralApi } from '@FeaturesModule'
|
||||
import { T, VN_ACTIONS } from '@ConstantsModule'
|
||||
import { GlobalAction, SecurityGroupsTable } from '@modules/components/Tables'
|
||||
|
||||
import { isRestrictedAttributes, unbindSecGroupTemplate } from '@UtilsModule'
|
||||
|
||||
import { ChangeForm } from '@modules/components/Forms/SecurityGroups'
|
||||
|
||||
import { SecurityGroupCard } from '@modules/components/Cards'
|
||||
@ -137,7 +140,13 @@ const SecurityTab = ({
|
||||
{
|
||||
dialogProps: { title: T.SecurityGroup },
|
||||
form: () => ChangeForm({ initialValues: vnet }),
|
||||
onSubmit: () => async (xml) => {
|
||||
onSubmit: () => async (formData) => {
|
||||
const { secgroups } = formData
|
||||
|
||||
const newTemplate = bindSecGroupTemplate(vnet, secgroups)
|
||||
|
||||
const xml = jsonToXml(newTemplate)
|
||||
|
||||
const response = await update({
|
||||
id: vnet.ID,
|
||||
template: xml,
|
||||
|
@ -81,6 +81,7 @@ module.exports = {
|
||||
Close: 'Close',
|
||||
Collapse: 'Collapse',
|
||||
Configuration: 'Configuration',
|
||||
Definition: 'Definition',
|
||||
CopiedToClipboard: 'Copied to clipboard',
|
||||
Create: 'Create',
|
||||
CreateDatastore: 'Create Datastore',
|
||||
@ -304,7 +305,7 @@ module.exports = {
|
||||
DayOfWeek: 'Day of week',
|
||||
DayOfMonth: 'Day of month',
|
||||
DayOfYear: 'Day of year',
|
||||
EachXHours: "Each 'x' hours",
|
||||
EachXHours: "Every 'x' hours",
|
||||
EndType: 'End type',
|
||||
DaysBetween0_6: 'Days should be between 0 (Sunday) and 6 (Saturday)',
|
||||
DaysBetween1_31: 'Days should be between 1 and 31',
|
||||
@ -421,6 +422,7 @@ module.exports = {
|
||||
ProviderTemplate: 'Provider template',
|
||||
ProvisionTemplate: 'Provision template',
|
||||
ConfigureInputs: 'Configure inputs',
|
||||
UserInputs: 'User inputs',
|
||||
Log: 'Log',
|
||||
AddIP: 'Add IP',
|
||||
AddHost: 'Add Host',
|
||||
@ -847,6 +849,8 @@ module.exports = {
|
||||
Services: 'Services',
|
||||
ServiceTemplate: 'Service Template',
|
||||
ServiceTemplates: 'Service Templates',
|
||||
StraightStrategyConcept:
|
||||
'Straight strategy will instantiate each role in order: parents role will be deployed before their children. None strategy will instantiate the roles regardless of their relationships.',
|
||||
VirtualRouterTemplate: 'Virtual Router Template',
|
||||
VirtualRouterTemplates: 'Virtual Router Templates',
|
||||
VirtualRouterNICStart: 'Add a NIC to Start Configuring',
|
||||
@ -1215,6 +1219,7 @@ module.exports = {
|
||||
ElasticityPolicies: 'Elasticity Policies',
|
||||
ScheduledPolicy: 'Scheduled Policy',
|
||||
ScheduledPolicies: 'Scheduled Policies',
|
||||
AddPolicy: 'Add Policy',
|
||||
AssociateToVMGroup: 'Associate VM to a VM Group',
|
||||
/* VM Template schema - placement */
|
||||
HostRequirements: 'Host Requirements',
|
||||
@ -1322,6 +1327,9 @@ module.exports = {
|
||||
Context: 'Context',
|
||||
SshPublicKey: 'SSH public key',
|
||||
AddUserSshPublicKey: 'Add user SSH public key',
|
||||
AddNetwork: 'Add Network',
|
||||
AddUserInput: 'Add User input',
|
||||
AddScheduleAction: 'Add Schedule action',
|
||||
AddNetworkContextualization: 'Add Network contextualization',
|
||||
AddNetworkContextualizationConcept: `
|
||||
Add network contextualization parameters. For each NIC defined in
|
||||
@ -1997,7 +2005,6 @@ module.exports = {
|
||||
AuthDriver: 'Auth Driver',
|
||||
PasswordHash: 'Password Hash',
|
||||
TokenPasswordHash: 'Token Password Hash',
|
||||
UserInputs: 'User Inputs',
|
||||
UserInputsService: 'Service Inputs',
|
||||
UserInputsRole: 'Roles Inputs',
|
||||
UserInputsConcept: `
|
||||
@ -2036,6 +2043,8 @@ module.exports = {
|
||||
ManualNetwork: 'Manual Network',
|
||||
OpennebulaVirtualNetwork: 'OpenNebula Virtual Network',
|
||||
SelectNewNetwork: 'Please select a network from the list',
|
||||
NewNetwork: 'New Network',
|
||||
NewUserInput: 'New User input',
|
||||
MessageAddSecGroupDefault:
|
||||
'The default Security Group 0 is automatically added to new Virtual Networks',
|
||||
NotVmsCurrentySecGroups:
|
||||
@ -2056,7 +2065,7 @@ module.exports = {
|
||||
/* Validation - mixed */
|
||||
'validation.mixed.default': 'Is invalid',
|
||||
'validation.mixed.required': 'Is a required field',
|
||||
'validation.mixed.oneOf': 'Must be one of the following values: %s',
|
||||
'validation.mixed.oneOf': 'Must be one of the following values',
|
||||
'validation.mixed.notOneOf': 'Must not be one of the following values: %s',
|
||||
'validation.mixed.notType': 'Invalid type',
|
||||
'validation.mixed.notType.string': 'Must be a string type',
|
||||
@ -2079,14 +2088,16 @@ module.exports = {
|
||||
'validation.string.uppercase': 'Must be a upper case string',
|
||||
'validation.string.invalidFormat': 'File has invalid format',
|
||||
/* Validation - number */
|
||||
'validation.number.min': 'Must be greater than or equal to %s',
|
||||
'validation.number.max': 'Must be less than or equal to %s',
|
||||
'validation.number.lessThan': 'Must be less than %s',
|
||||
'validation.number.min': 'Must be greater than or equal to',
|
||||
'validation.number.max': 'Must be less than or equal to',
|
||||
'validation.number.lessThan': 'Must be less than',
|
||||
'validation.number.moreThan': 'Must be greater than %s',
|
||||
'validation.number.positive': 'Must be a positive number',
|
||||
'validation.number.negative': 'Must be a negative number',
|
||||
'validation.number.integer': 'Must be an integer',
|
||||
'validation.number.isDivisible': 'Should be divisible by %s',
|
||||
'validation.number.isFinite': 'Must be a valid number',
|
||||
'validation.number.isFloat': 'Must be a floating point number',
|
||||
/* Validation - date */
|
||||
'validation.date.min': 'Must be later than %s',
|
||||
'validation.date.max': 'Must be at earlier than %s',
|
||||
|
@ -69,14 +69,49 @@ export function InstantiateServiceTemplate() {
|
||||
})
|
||||
|
||||
const onSubmit = async (jsonTemplate) => {
|
||||
const { instances = 1 } = jsonTemplate
|
||||
const { instances = 1, SCHED_ACTION = [] } = jsonTemplate
|
||||
|
||||
const {
|
||||
TEMPLATE: {
|
||||
BODY: { roles, networks_values: networksValues, networks },
|
||||
},
|
||||
} = apiTemplateData
|
||||
|
||||
const formatNetworkValues = networksValues?.map((network) => {
|
||||
const [key, values] = Object.entries(network)?.pop()
|
||||
|
||||
const [, networkString] = Object.entries(networks)?.find(
|
||||
([net]) => net === key
|
||||
)
|
||||
|
||||
const [type, value] = networkString?.split('|')?.[4]?.trim()?.split(':')
|
||||
|
||||
return {
|
||||
[key]: {
|
||||
...values,
|
||||
...(type && value ? { [type]: value } : {}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
const formatRoles = roles?.map(({ vm_template_id_content, ...role }) => ({
|
||||
...role,
|
||||
...(SCHED_ACTION?.length > 0
|
||||
? { template_contents: { SCHED_ACTION } }
|
||||
: {}),
|
||||
}))
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from({ length: instances }, async () =>
|
||||
instantiate({
|
||||
id: templateId,
|
||||
template: jsonTemplate,
|
||||
template: {
|
||||
...jsonTemplate,
|
||||
networks_values: formatNetworkValues,
|
||||
roles: formatRoles,
|
||||
},
|
||||
}).unwrap()
|
||||
)
|
||||
)
|
||||
|
@ -48,7 +48,7 @@ import { Fragment, ReactElement, useCallback, useMemo, useState } from 'react'
|
||||
import { FormProvider, useForm } from 'react-hook-form'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
const useStyles = ({ palette }) => ({
|
||||
const useStyles = () => ({
|
||||
buttonSubmit: css({
|
||||
width: '100%',
|
||||
marginTop: '1rem',
|
||||
|
@ -119,7 +119,7 @@ const basicEndpoints = (builder) => ({
|
||||
* @param {object} params - Request params
|
||||
* @param {string} params.id - Service template id
|
||||
* @param {object} params.template - The new template contents
|
||||
* @param {boolean} [params.append]
|
||||
* @param {boolean} [params.merge]
|
||||
* - ``true``: Merge new template with the existing one.
|
||||
* - ``false``: Replace the whole template.
|
||||
*
|
||||
@ -127,10 +127,10 @@ const basicEndpoints = (builder) => ({
|
||||
* @returns {number} Service template id
|
||||
* @throws Fails when response isn't code 200
|
||||
*/
|
||||
query: ({ template = {}, append = true, ...params }) => {
|
||||
query: ({ template = {}, merge = true, ...params }) => {
|
||||
params.action = {
|
||||
perform: 'update',
|
||||
params: { template_json: JSON.stringify(template), append },
|
||||
params: { template_json: JSON.stringify(template), append: merge },
|
||||
}
|
||||
|
||||
const name = Actions.SERVICE_TEMPLATE_ACTION
|
||||
@ -336,7 +336,7 @@ const extendedEnpoints = (builder) => ({
|
||||
serviceTemplate?.TEMPLATE?.BODY?.roles?.map(async (role) => {
|
||||
const vmTemplate = await dispatch(
|
||||
oneApi.endpoints.getTemplate.initiate(
|
||||
{ id: role?.vm_template },
|
||||
{ id: role?.template_id },
|
||||
{ forceRefetch: true }
|
||||
)
|
||||
).unwrap()
|
||||
|
@ -485,7 +485,6 @@ export const parseRangeToArray = (start, end) => {
|
||||
*
|
||||
* @param {object} userInputs - List of user inputs in string format
|
||||
* @param {object} [options] - Options to filter user inputs
|
||||
* @param {boolean} [options.filterCapacityInputs]
|
||||
* - If false, will not filter capacity inputs: MEMORY, CPU, VCPU. By default `true`
|
||||
* @param {string} [options.order] - List separated by comma of input names
|
||||
* @example
|
||||
@ -512,10 +511,7 @@ export const parseRangeToArray = (start, end) => {
|
||||
* }]
|
||||
* @returns {UserInputObject[]} User input object
|
||||
*/
|
||||
export const userInputsToArray = (
|
||||
userInputs = {},
|
||||
{ filterCapacityInputs = true, order } = {}
|
||||
) => {
|
||||
export const userInputsToArray = (userInputs = {}, { order } = {}) => {
|
||||
const orderedList = order?.split(',') ?? []
|
||||
const userInputsArray = Object.entries(userInputs)
|
||||
|
||||
@ -524,11 +520,6 @@ export const userInputsToArray = (
|
||||
...(typeof ui === 'string' ? getUserInputParams(ui) : ui),
|
||||
}))
|
||||
|
||||
if (filterCapacityInputs) {
|
||||
const capacityInputs = ['MEMORY', 'CPU', 'VCPU']
|
||||
list = list.filter((ui) => !capacityInputs.includes(ui.name))
|
||||
}
|
||||
|
||||
if (orderedList.length) {
|
||||
list = list.sort((a, b) => {
|
||||
const upperAName = a.name?.toUpperCase?.()
|
||||
|
@ -437,9 +437,10 @@ const createAppTheme = (appTheme, mode = SCHEMES.DARK) => {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: background.paper,
|
||||
borderRadius: `8px 8px 0 0`,
|
||||
borderRadius: `0 0 8px 8px`,
|
||||
border: `thin solid ${secondary.main}`,
|
||||
paddingInline: '1rem',
|
||||
marginBottom: '0.5rem',
|
||||
},
|
||||
flexContainer: {
|
||||
height: '100%',
|
||||
|
@ -533,17 +533,16 @@ export const extractIDValues = (arr = []) => {
|
||||
* Generates a simple hash from a string.
|
||||
*
|
||||
* @param {string} str - The string to hash.
|
||||
* @returns {number} The hash value.
|
||||
* @returns {string} The hash value in hex.
|
||||
*/
|
||||
export const simpleHash = (str) => {
|
||||
let hash = 0
|
||||
let hash = 2166136261
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash |= 0
|
||||
hash ^= str.charCodeAt(i)
|
||||
hash = (hash * 16777619) >>> 0
|
||||
}
|
||||
|
||||
return hash
|
||||
return hash.toString(16)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -719,3 +718,20 @@ export const getLocked = (OpennebulaObject) => !!+OpennebulaObject.LOCK?.LOCKED
|
||||
*/
|
||||
export const formatError = (errorId = '', { fallback = '' } = {}) =>
|
||||
sentenceCase(ERROR_LOOKUP_TABLE?.[String(errorId) || ''] ?? fallback)
|
||||
|
||||
/**
|
||||
* @param {object} obj - Dirty object
|
||||
* @returns {object} - Object with falsy values removed
|
||||
*/
|
||||
export const deepClean = (obj) => {
|
||||
if (_.isArray(obj)) {
|
||||
return obj.map(deepClean)
|
||||
} else if (_.isObject(obj)) {
|
||||
return _.pickBy(
|
||||
_.mapValues(obj, deepClean),
|
||||
(value) => !_.isEmpty(value) || _.isNumber(value) || _.isBoolean(value)
|
||||
)
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
@ -15,7 +15,12 @@
|
||||
* ------------------------------------------------------------------------- */
|
||||
import templateToObject from '@modules/utils/parser/templateToObject'
|
||||
import {
|
||||
parseNetworkString,
|
||||
toNetworkString,
|
||||
fromNetworkString,
|
||||
toNetworksValueString,
|
||||
fromNetworksValueString,
|
||||
toUserInputString,
|
||||
fromUserInputString,
|
||||
parseCustomInputString,
|
||||
convertKeysToCase,
|
||||
} from '@modules/utils/parser/parseServiceTemplate'
|
||||
@ -37,7 +42,12 @@ export {
|
||||
parseAcl,
|
||||
parseCustomInputString,
|
||||
templateToObject,
|
||||
parseNetworkString,
|
||||
toNetworkString,
|
||||
toNetworksValueString,
|
||||
fromNetworksValueString,
|
||||
toUserInputString,
|
||||
fromNetworkString,
|
||||
fromUserInputString,
|
||||
parsePayload,
|
||||
parseTouchedDirty,
|
||||
parseVmTemplateContents,
|
||||
|
@ -13,60 +13,156 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
/* eslint-disable jsdoc/require-jsdoc */
|
||||
|
||||
import _ from 'lodash'
|
||||
|
||||
const NETWORK_TYPE = {
|
||||
template_id: 'create',
|
||||
id: 'existing',
|
||||
reserve_from: 'reserve',
|
||||
export const toUserInputString = ({
|
||||
name,
|
||||
type,
|
||||
mandatory,
|
||||
description = '',
|
||||
options = '',
|
||||
// eslint-disable-next-line camelcase
|
||||
options_1 = '',
|
||||
default: def,
|
||||
} = {}) => {
|
||||
const opts = Array.isArray(options)
|
||||
? options?.join(',')
|
||||
: // eslint-disable-next-line camelcase
|
||||
[options, options_1]?.filter(Boolean)?.join('..')
|
||||
|
||||
return [
|
||||
name,
|
||||
`${mandatory ? 'M' : 'O'}|${type}|${description}|${opts}|${
|
||||
Array?.isArray(def) ? def?.join(',') : def
|
||||
}`,
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a formatted network string back into an object.
|
||||
*
|
||||
* @param {string} networkString - The formatted network string to parse.
|
||||
* @returns {object | null} An object with properties describing the network, or null if the string is invalid.
|
||||
*/
|
||||
const formatNetworkString = (networkString) => {
|
||||
const parts = networkString?.split('|')
|
||||
const [netType, id, extra] = parts?.slice(-1)[0]?.split(':')
|
||||
export const fromUserInputString = (userInput) => {
|
||||
const [name, userInputString] = userInput
|
||||
|
||||
const networkType = NETWORK_TYPE?.[netType]
|
||||
if (parts.length < 3 || !networkType) {
|
||||
return null
|
||||
const [mandatory, type, description, opts, def] = userInputString.split('|')
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
const [options, options_1] = opts.split(/\.{2}|,/)
|
||||
|
||||
return {
|
||||
name,
|
||||
mandatory: mandatory === 'M',
|
||||
type,
|
||||
description,
|
||||
options,
|
||||
options_1,
|
||||
default: def,
|
||||
}
|
||||
}
|
||||
|
||||
export const toNetworkString = ({ name, description, type, value } = {}) => [
|
||||
name,
|
||||
`M|network|${description}||${type}:${value}`,
|
||||
]
|
||||
|
||||
export const toNetworksValueString = (
|
||||
{ name, SIZE: size },
|
||||
{ AR = [], SECURITY_GROUPS = [] } = {}
|
||||
) => {
|
||||
if (!name) return
|
||||
|
||||
let extra = []
|
||||
|
||||
if (AR?.length) {
|
||||
const ARs = AR?.map(
|
||||
(ar) =>
|
||||
`AR=[${Object.entries(ar)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(',')}]`
|
||||
)
|
||||
|
||||
extra.push(ARs)
|
||||
}
|
||||
|
||||
if (SECURITY_GROUPS?.length) {
|
||||
const SGs = `SECURITY_GROUPS="${SECURITY_GROUPS.map((sg) => sg?.ID).join(
|
||||
','
|
||||
)}"`
|
||||
|
||||
extra.push(SGs)
|
||||
}
|
||||
|
||||
if (size) {
|
||||
const SIZE = `SIZE=${size}`
|
||||
extra.push(SIZE)
|
||||
}
|
||||
|
||||
extra = extra?.join(',')
|
||||
|
||||
if (!extra?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
type: networkType,
|
||||
name: parts[0],
|
||||
description: parts[3],
|
||||
...(id && { network: id }),
|
||||
...(extra && { netextra: extra }),
|
||||
[name]: {
|
||||
extra,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a network object into a string or reverses the operation based on the reverse flag.
|
||||
*
|
||||
* @param {object | string} network - The network object to format or the network string to parse.
|
||||
* @param {boolean} [reverse=false] - Reverse operation flag.
|
||||
* @returns {string | object | null} A formatted network string or an object representing the network, or null for invalid input in reverse mode.
|
||||
*/
|
||||
export const parseNetworkString = (network, reverse = false) => {
|
||||
if (reverse) {
|
||||
return formatNetworkString(typeof network === 'string' ? network : '')
|
||||
}
|
||||
export const fromNetworksValueString = (nv) => {
|
||||
const [valueString] = nv
|
||||
const { extra } = valueString
|
||||
|
||||
const type = Object.keys(NETWORK_TYPE).find(
|
||||
(key) => NETWORK_TYPE[key] === network?.type?.toLowerCase()
|
||||
let conf = []
|
||||
|
||||
const SECURITY_GROUPS = extra
|
||||
?.match(/SECURITY_GROUPS="([^"]+)"/)
|
||||
?.pop()
|
||||
?.split(',')
|
||||
?.map((id) => ({ ID: id }))
|
||||
|
||||
const AR = extra?.match(/AR=\[([^\]]+)\]/g)?.map((ar) =>
|
||||
Object.fromEntries(
|
||||
ar
|
||||
.replace(/^AR=\[|\]$/g, '')
|
||||
?.split(',')
|
||||
?.map((arg) => arg?.split('='))
|
||||
)
|
||||
)
|
||||
|
||||
const result = `M|network|${network?.description ?? ''}| |${type ?? ''}:${
|
||||
network?.network ?? ''
|
||||
}:${network?.netextra ?? ''}`
|
||||
const SIZE = [
|
||||
extra?.match(/(?:^|,)(SIZE=\d+)(?=,|$)/)?.[1]?.split('=')?.[1],
|
||||
]?.filter(Boolean)
|
||||
|
||||
return result
|
||||
if (SECURITY_GROUPS?.length) {
|
||||
conf?.push(['SECURITY_GROUPS', SECURITY_GROUPS])
|
||||
}
|
||||
|
||||
if (AR?.length) {
|
||||
conf?.push(['AR', AR])
|
||||
}
|
||||
|
||||
if (SIZE?.length) {
|
||||
conf?.push(['SIZE', ...SIZE])
|
||||
}
|
||||
|
||||
conf = Object.fromEntries(conf)
|
||||
|
||||
return conf
|
||||
}
|
||||
|
||||
export const fromNetworkString = (network) => {
|
||||
const [name, networkString] = network
|
||||
|
||||
const [description, , tv] = networkString?.split('|').slice(2)
|
||||
const [type, value] = tv.split(':')
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -73,6 +73,20 @@ const buildMethods = () => {
|
||||
(value) => isDivisibleBy(value, divisor)
|
||||
)
|
||||
})
|
||||
addMethod(number, 'isFinite', function () {
|
||||
return this.test(
|
||||
'is-valid-number',
|
||||
T['validation.number.isFinite'],
|
||||
(value) => !isNaN(value) && typeof value === 'number' && isFinite(value)
|
||||
)
|
||||
})
|
||||
addMethod(number, 'isFloat', function () {
|
||||
return this.test(
|
||||
'is-floating-point-number',
|
||||
T['validation.number.isFloat'],
|
||||
(value) => Number(value) === value && value % 1 !== 0
|
||||
)
|
||||
})
|
||||
addMethod(string, 'isBase64', function () {
|
||||
return this.test(
|
||||
'is-base64',
|
||||
|
@ -27,12 +27,24 @@ const role = {
|
||||
default: 1,
|
||||
minimum: 0,
|
||||
},
|
||||
vm_template: {
|
||||
template_id: {
|
||||
type: 'integer',
|
||||
required: true,
|
||||
},
|
||||
vm_template_contents: {
|
||||
type: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
ha_mode: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
},
|
||||
floating_ip: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
template_contents: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
},
|
||||
parents: {
|
||||
@ -178,12 +190,7 @@ const service = {
|
||||
items: { $ref: '/Role' },
|
||||
required: true,
|
||||
},
|
||||
custom_attrs: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: false,
|
||||
},
|
||||
custom_attrs_values: {
|
||||
user_inputs: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: false,
|
||||
|
@ -229,9 +229,9 @@ class ServiceLCM
|
||||
service.report_ready?)
|
||||
|
||||
if !OpenNebula.is_error?(rc)
|
||||
service.set_state(Service::STATE['DEPLOYING'])
|
||||
service.state = Service::STATE['DEPLOYING']
|
||||
else
|
||||
service.set_state(Service::STATE['FAILED_DEPLOYING'])
|
||||
service.state = Service::STATE['FAILED_DEPLOYING']
|
||||
end
|
||||
|
||||
service.update
|
||||
@ -259,14 +259,14 @@ class ServiceLCM
|
||||
rc = service.deploy_networks
|
||||
|
||||
if OpenNebula.is_error?(rc)
|
||||
service.set_state(Service::STATE['FAILED_DEPLOYING'])
|
||||
service.state = Service::STATE['FAILED_DEPLOYING']
|
||||
service.update
|
||||
|
||||
break rc
|
||||
end
|
||||
end
|
||||
|
||||
service.set_state(Service::STATE['DEPLOYING_NETS'])
|
||||
service.state = Service::STATE['DEPLOYING_NETS']
|
||||
|
||||
@event_manager.trigger_action(
|
||||
:wait_deploy_nets_action,
|
||||
@ -292,7 +292,7 @@ class ServiceLCM
|
||||
# @return [OpenNebula::Error] Error if any
|
||||
def undeploy_nets_action(external_user, service_id)
|
||||
rc = @srv_pool.get(service_id, external_user) do |service|
|
||||
service.set_state(Service::STATE['UNDEPLOYING_NETS'])
|
||||
service.state = Service::STATE['UNDEPLOYING_NETS']
|
||||
|
||||
@event_manager.trigger_action(
|
||||
:wait_undeploy_nets_action,
|
||||
@ -320,12 +320,22 @@ class ServiceLCM
|
||||
rc = @srv_pool.get(service_id, external_user) do |service|
|
||||
set_deploy_strategy(service)
|
||||
|
||||
# Replace all variables and attributes in the template
|
||||
rc = service.fill_template
|
||||
|
||||
if OpenNebula.is_error?(rc)
|
||||
service.state = Service::STATE['FAILED_DEPLOYING']
|
||||
service.update
|
||||
|
||||
break rc
|
||||
end
|
||||
|
||||
roles = service.roles_deploy
|
||||
|
||||
# Maybe roles.empty? because are being deploying in other threads
|
||||
if roles.empty?
|
||||
if service.all_roles_running?
|
||||
service.set_state(Service::STATE['RUNNING'])
|
||||
service.state = Service::STATE['RUNNING']
|
||||
service.update
|
||||
|
||||
@wd.add_service(service)
|
||||
@ -343,11 +353,11 @@ class ServiceLCM
|
||||
service.report_ready?)
|
||||
|
||||
if !OpenNebula.is_error?(rc) & service.on_hold?
|
||||
service.set_state(Service::STATE['HOLD'])
|
||||
service.state = Service::STATE['HOLD']
|
||||
elsif !OpenNebula.is_error?(rc) & !service.on_hold?
|
||||
service.set_state(Service::STATE['DEPLOYING'])
|
||||
service.state = Service::STATE['DEPLOYING']
|
||||
else
|
||||
service.set_state(Service::STATE['FAILED_DEPLOYING'])
|
||||
service.state = Service::STATE['FAILED_DEPLOYING']
|
||||
end
|
||||
|
||||
service.update
|
||||
@ -396,9 +406,9 @@ class ServiceLCM
|
||||
:wait_undeploy_action)
|
||||
|
||||
if !OpenNebula.is_error?(rc)
|
||||
service.set_state(Service::STATE['UNDEPLOYING'])
|
||||
service.state = Service::STATE['UNDEPLOYING']
|
||||
else
|
||||
service.set_state(Service::STATE['FAILED_UNDEPLOYING'])
|
||||
service.state = Service::STATE['FAILED_UNDEPLOYING']
|
||||
end
|
||||
|
||||
service.update
|
||||
@ -477,9 +487,9 @@ class ServiceLCM
|
||||
end
|
||||
|
||||
if !OpenNebula.is_error?(rc)
|
||||
service.set_state(Service::STATE['SCALING'])
|
||||
service.state = Service::STATE['SCALING']
|
||||
else
|
||||
service.set_state(Service::STATE['FAILED_SCALING'])
|
||||
service.state = Service::STATE['FAILED_SCALING']
|
||||
end
|
||||
|
||||
service.update
|
||||
@ -520,10 +530,10 @@ class ServiceLCM
|
||||
elsif service.can_recover_undeploy_nets?
|
||||
recover_nets(:wait_undeploy_nets_action, external_user, service)
|
||||
elsif Service::STATE['COOLDOWN'] == service.state
|
||||
service.set_state(Service::STATE['RUNNING'])
|
||||
service.state = Service::STATE['RUNNING']
|
||||
|
||||
service.roles.each do |_, role|
|
||||
role.set_state(Role::STATE['RUNNING'])
|
||||
role.state = Role::STATE['RUNNING']
|
||||
end
|
||||
else
|
||||
break OpenNebula::Error.new(
|
||||
@ -625,15 +635,24 @@ class ServiceLCM
|
||||
end
|
||||
|
||||
role = service.add_role(role)
|
||||
|
||||
break role if OpenNebula.is_error?(role)
|
||||
|
||||
# Replace all variables and attributes in the template
|
||||
rc = service.fill_template
|
||||
|
||||
if OpenNebula.is_error?(rc)
|
||||
service.state = Service::STATE['FAILED_DEPLOYING']
|
||||
service.update
|
||||
|
||||
break rc
|
||||
end
|
||||
|
||||
service.update
|
||||
|
||||
rc = service.deploy_networks(false)
|
||||
|
||||
if OpenNebula.is_error?(rc)
|
||||
service.set_state(Service::STATE['FAILED_DEPLOYING'])
|
||||
service.state = Service::STATE['FAILED_DEPLOYING']
|
||||
service.update
|
||||
|
||||
break rc
|
||||
@ -684,7 +703,7 @@ class ServiceLCM
|
||||
undeploy = false
|
||||
|
||||
rc = @srv_pool.get(service_id, external_user) do |service|
|
||||
service.roles[role_name].set_state(Role::STATE['RUNNING'])
|
||||
service.roles[role_name].state = Role::STATE['RUNNING']
|
||||
|
||||
service.roles[role_name].nodes.delete_if do |node|
|
||||
if nodes[node] && service.roles[role_name].cardinality > 0
|
||||
@ -698,7 +717,7 @@ class ServiceLCM
|
||||
undeploy = service.check_role(service.roles[role_name])
|
||||
|
||||
if service.all_roles_running?
|
||||
service.set_state(Service::STATE['RUNNING'])
|
||||
service.state = Service::STATE['RUNNING']
|
||||
elsif service.strategy == 'straight'
|
||||
set_deploy_strategy(service)
|
||||
|
||||
@ -731,10 +750,8 @@ class ServiceLCM
|
||||
# stop actions for the service if deploy fails
|
||||
@event_manager.cancel_action(service_id)
|
||||
|
||||
service.set_state(Service::STATE['FAILED_DEPLOYING'])
|
||||
service.roles[role_name].set_state(
|
||||
Role::STATE['FAILED_DEPLOYING']
|
||||
)
|
||||
service.state = Service::STATE['FAILED_DEPLOYING']
|
||||
service.roles[role_name].state = Role::STATE['FAILED_DEPLOYING']
|
||||
|
||||
service.update
|
||||
end
|
||||
@ -751,7 +768,7 @@ class ServiceLCM
|
||||
# stop actions for the service if deploy fails
|
||||
@event_manager.cancel_action(service_id)
|
||||
|
||||
service.set_state(Service::STATE['FAILED_DEPLOYING_NETS'])
|
||||
service.state = Service::STATE['FAILED_DEPLOYING_NETS']
|
||||
service.update
|
||||
end
|
||||
|
||||
@ -762,7 +779,7 @@ class ServiceLCM
|
||||
undeploy_nets = false
|
||||
|
||||
rc = @srv_pool.get(service_id, external_user) do |service|
|
||||
service.roles[role_name].set_state(Role::STATE['DONE'])
|
||||
service.roles[role_name].state = Role::STATE['DONE']
|
||||
|
||||
service.roles[role_name].nodes.delete_if do |node|
|
||||
!nodes[:failure].include?(node['deploy_id']) &&
|
||||
@ -811,7 +828,7 @@ class ServiceLCM
|
||||
# stop actions for the service if deploy fails
|
||||
@event_manager.cancel_action(service_id)
|
||||
|
||||
service.set_state(Service::STATE['FAILED_UNDEPLOYING_NETS'])
|
||||
service.state = Service::STATE['FAILED_UNDEPLOYING_NETS']
|
||||
service.update
|
||||
end
|
||||
|
||||
@ -823,10 +840,8 @@ class ServiceLCM
|
||||
# stop actions for the service if deploy fails
|
||||
@event_manager.cancel_action(service_id)
|
||||
|
||||
service.set_state(Service::STATE['FAILED_UNDEPLOYING'])
|
||||
service.roles[role_name].set_state(
|
||||
Role::STATE['FAILED_UNDEPLOYING']
|
||||
)
|
||||
service.state = Service::STATE['FAILED_UNDEPLOYING']
|
||||
service.roles[role_name].state = Role::STATE['FAILED_UNDEPLOYING']
|
||||
|
||||
service.roles[role_name].nodes.delete_if do |node|
|
||||
!nodes[:failure].include?(node['deploy_id']) &&
|
||||
@ -849,8 +864,8 @@ class ServiceLCM
|
||||
nodes[node]
|
||||
end
|
||||
|
||||
service.set_state(Service::STATE['COOLDOWN'])
|
||||
service.roles[role_name].set_state(Role::STATE['COOLDOWN'])
|
||||
service.state = Service::STATE['COOLDOWN']
|
||||
service.roles[role_name].state = Role::STATE['COOLDOWN']
|
||||
|
||||
@event_manager.trigger_action(
|
||||
:wait_cooldown_action,
|
||||
@ -871,8 +886,8 @@ class ServiceLCM
|
||||
|
||||
def scaledown_cb(external_user, service_id, role_name, nodes)
|
||||
rc = @srv_pool.get(service_id, external_user) do |service|
|
||||
service.set_state(Service::STATE['COOLDOWN'])
|
||||
service.roles[role_name].set_state(Role::STATE['COOLDOWN'])
|
||||
service.state = Service::STATE['COOLDOWN']
|
||||
service.roles[role_name].state = Role::STATE['COOLDOWN']
|
||||
|
||||
service.roles[role_name].nodes.delete_if do |node|
|
||||
!nodes[:failure].include?(node['deploy_id']) &&
|
||||
@ -901,10 +916,8 @@ class ServiceLCM
|
||||
# stop actions for the service if deploy fails
|
||||
@event_manager.cancel_action(service_id)
|
||||
|
||||
service.set_state(Service::STATE['FAILED_SCALING'])
|
||||
service.roles[role_name].set_state(
|
||||
Role::STATE['FAILED_SCALING']
|
||||
)
|
||||
service.state = Service::STATE['FAILED_SCALING']
|
||||
service.roles[role_name].state = Role::STATE['FAILED_SCALING']
|
||||
|
||||
service.update
|
||||
end
|
||||
@ -919,8 +932,8 @@ class ServiceLCM
|
||||
|
||||
role = service.roles[role_name]
|
||||
|
||||
service.set_state(Service::STATE['FAILED_SCALING'])
|
||||
role.set_state(Role::STATE['FAILED_SCALING'])
|
||||
service.state = Service::STATE['FAILED_SCALING']
|
||||
role.state = Role::STATE['FAILED_SCALING']
|
||||
|
||||
role.nodes.delete_if do |node|
|
||||
!nodes[:failure].include?(node['deploy_id']) &&
|
||||
@ -937,8 +950,8 @@ class ServiceLCM
|
||||
undeploy = false
|
||||
|
||||
rc = @srv_pool.get(service_id, external_user) do |service|
|
||||
service.set_state(Service::STATE['RUNNING'])
|
||||
service.roles[role_name].set_state(Role::STATE['RUNNING'])
|
||||
service.state = Service::STATE['RUNNING']
|
||||
service.roles[role_name].state = Role::STATE['RUNNING']
|
||||
|
||||
service.update
|
||||
|
||||
@ -959,9 +972,8 @@ class ServiceLCM
|
||||
|
||||
def add_cb(external_user, service_id, role_name, _)
|
||||
rc = @srv_pool.get(service_id, external_user) do |service|
|
||||
service.roles[role_name].set_state(Role::STATE['RUNNING'])
|
||||
|
||||
service.set_state(Service::STATE['RUNNING'])
|
||||
service.roles[role_name].state = Role::STATE['RUNNING']
|
||||
service.state = Service::STATE['RUNNING']
|
||||
|
||||
rc = service.update
|
||||
|
||||
@ -978,10 +990,8 @@ class ServiceLCM
|
||||
# stop actions for the service if deploy fails
|
||||
@event_manager.cancel_action(service_id)
|
||||
|
||||
service.set_state(Service::STATE['FAILED_DEPLOYING'])
|
||||
service.roles[role_name].set_state(
|
||||
Role::STATE['FAILED_DEPLOYING']
|
||||
)
|
||||
service.state = Service::STATE['FAILED_DEPLOYING']
|
||||
service.roles[role_name].state = Role::STATE['FAILED_DEPLOYING']
|
||||
|
||||
service.update
|
||||
end
|
||||
@ -1003,7 +1013,7 @@ class ServiceLCM
|
||||
|
||||
service.delete
|
||||
else
|
||||
service.set_state(Service::STATE['RUNNING'])
|
||||
service.state = Service::STATE['RUNNING']
|
||||
|
||||
rc = service.update
|
||||
|
||||
@ -1021,10 +1031,8 @@ class ServiceLCM
|
||||
# stop actions for the service if deploy fails
|
||||
@event_manager.cancel_action(service_id)
|
||||
|
||||
service.set_state(Service::STATE['FAILED_UNDEPLOYING'])
|
||||
service.roles[role_name].set_state(
|
||||
Role::STATE['FAILED_UNDEPLOYING']
|
||||
)
|
||||
service.state = Service::STATE['FAILED_UNDEPLOYING']
|
||||
service.roles[role_name].state = Role::STATE['FAILED_UNDEPLOYING']
|
||||
|
||||
service.roles[role_name].nodes.delete_if do |node|
|
||||
!nodes[:failure].include?(node['deploy_id']) &&
|
||||
@ -1040,12 +1048,11 @@ class ServiceLCM
|
||||
def hold_cb(external_user, service_id, role_name)
|
||||
rc = @srv_pool.get(service_id, external_user) do |service|
|
||||
if service.roles[role_name].state != Role::STATE['HOLD']
|
||||
service.roles[role_name].set_state(Role::STATE['HOLD'])
|
||||
service.roles[role_name].state = Role::STATE['HOLD']
|
||||
end
|
||||
|
||||
if service.all_roles_hold? &&
|
||||
service.state != Service::STATE['HOLD']
|
||||
service.set_state(Service::STATE['HOLD'])
|
||||
if service.all_roles_hold? && service.state != Service::STATE['HOLD']
|
||||
service.state = Service::STATE['HOLD']
|
||||
elsif service.strategy == 'straight'
|
||||
set_deploy_strategy(service)
|
||||
|
||||
@ -1071,7 +1078,7 @@ class ServiceLCM
|
||||
undeploy = false
|
||||
|
||||
rc = @srv_pool.get(service_id, external_user) do |service|
|
||||
service.roles[role_name].set_state(Role::STATE['RUNNING'])
|
||||
service.roles[role_name].state = Role::STATE['RUNNING']
|
||||
|
||||
service.roles[role_name].nodes.delete_if do |node|
|
||||
if nodes[node] && service.roles[role_name].cardinality > 0
|
||||
@ -1085,7 +1092,7 @@ class ServiceLCM
|
||||
undeploy = service.check_role(service.roles[role_name])
|
||||
|
||||
if service.all_roles_running?
|
||||
service.set_state(Service::STATE['RUNNING'])
|
||||
service.state = Service::STATE['RUNNING']
|
||||
elsif service.strategy == 'straight'
|
||||
set_deploy_strategy(service)
|
||||
|
||||
@ -1122,11 +1129,11 @@ class ServiceLCM
|
||||
def error_wd_cb(external_user, service_id, role_name, _node)
|
||||
rc = @srv_pool.get(service_id, external_user) do |service|
|
||||
if service.state != Service::STATE['WARNING']
|
||||
service.set_state(Service::STATE['WARNING'])
|
||||
service.state = Service::STATE['WARNING']
|
||||
end
|
||||
|
||||
if service.roles[role_name].state != Role::STATE['WARNING']
|
||||
service.roles[role_name].set_state(Role::STATE['WARNING'])
|
||||
service.roles[role_name].state = Role::STATE['WARNING']
|
||||
end
|
||||
|
||||
service.update
|
||||
@ -1179,12 +1186,12 @@ class ServiceLCM
|
||||
role = service.roles[role_name]
|
||||
|
||||
if service.roles[role_name].state != Role::STATE['RUNNING']
|
||||
service.roles[role_name].set_state(Role::STATE['RUNNING'])
|
||||
service.roles[role_name].state = Role::STATE['RUNNING']
|
||||
end
|
||||
|
||||
if service.all_roles_running? &&
|
||||
service.state != Service::STATE['RUNNING']
|
||||
service.set_state(Service::STATE['RUNNING'])
|
||||
service.state = Service::STATE['RUNNING']
|
||||
end
|
||||
|
||||
# If the role has 0 nodes, delete role
|
||||
@ -1252,27 +1259,24 @@ class ServiceLCM
|
||||
# rubocop:enable Metrics/ParameterLists
|
||||
rc = roles.each do |name, role|
|
||||
# Only applies to new services (pending)
|
||||
if role.state == Role::STATE['PENDING']
|
||||
if role.state == Role::STATE['PENDING'] &&
|
||||
(role.service_on_hold? || role.any_parent_on_hold?)
|
||||
# Set all roles on hold if the on_hold option
|
||||
# is set at service level
|
||||
if role.service_on_hold?
|
||||
role.hold(true)
|
||||
elsif role.any_parent_on_hold?
|
||||
role.hold(true)
|
||||
end
|
||||
role.on_hold = true
|
||||
end
|
||||
|
||||
rc = role.deploy
|
||||
|
||||
if !rc[0]
|
||||
role.set_state(Role::STATE[error_state])
|
||||
role.state = Role::STATE[error_state]
|
||||
break OpenNebula::Error.new(
|
||||
"Error deploying role #{name}: #{rc[1]}"
|
||||
)
|
||||
end
|
||||
|
||||
if role.on_hold? && role.state == Role::STATE['PENDING']
|
||||
role.set_state(Role::STATE['HOLD'])
|
||||
role.state = Role::STATE['HOLD']
|
||||
@event_manager.trigger_action(:wait_hold_action,
|
||||
role.service.id,
|
||||
external_user,
|
||||
@ -1280,7 +1284,7 @@ class ServiceLCM
|
||||
role.name,
|
||||
rc[0])
|
||||
else
|
||||
role.set_state(Role::STATE[success_state])
|
||||
role.state = Role::STATE[success_state]
|
||||
@event_manager.trigger_action(action,
|
||||
role.service.id,
|
||||
external_user,
|
||||
@ -1302,13 +1306,13 @@ class ServiceLCM
|
||||
rc = role.shutdown(false)
|
||||
|
||||
if !rc[0]
|
||||
role.set_state(Role::STATE[error_state])
|
||||
role.state = Role::STATE[error_state]
|
||||
break OpenNebula::Error.new(
|
||||
"Error undeploying role #{name}: #{rc[1]}"
|
||||
)
|
||||
end
|
||||
|
||||
role.set_state(Role::STATE[success_state])
|
||||
role.state = Role::STATE[success_state]
|
||||
|
||||
# TODO, take only subset of nodes which needs to
|
||||
# be undeployed (new role.nodes_undeployed_ids ?)
|
||||
@ -1329,13 +1333,13 @@ class ServiceLCM
|
||||
rc = role.release
|
||||
|
||||
if !rc[1]
|
||||
role.set_state(Role::STATE[error_state])
|
||||
role.state = Role::STATE[error_state]
|
||||
break OpenNebula::Error.new(
|
||||
"Error releasing role #{name}: #{rc[1]}"
|
||||
)
|
||||
end
|
||||
|
||||
role.set_state(Role::STATE[success_state])
|
||||
role.state = Role::STATE[success_state]
|
||||
|
||||
@event_manager.trigger_action(action,
|
||||
role.service.id,
|
||||
@ -1440,9 +1444,9 @@ class ServiceLCM
|
||||
service.report_ready?)
|
||||
|
||||
if !OpenNebula.is_error?(rc)
|
||||
service.set_state(Service::STATE['DEPLOYING'])
|
||||
service.state = Service::STATE['DEPLOYING']
|
||||
else
|
||||
service.set_state(Service::STATE['FAILED_DEPLOYING'])
|
||||
service.state = Service::STATE['FAILED_DEPLOYING']
|
||||
end
|
||||
|
||||
service.update
|
||||
@ -1464,9 +1468,9 @@ class ServiceLCM
|
||||
:wait_remove_action)
|
||||
|
||||
if !OpenNebula.is_error?(rc)
|
||||
service.set_state(Service::STATE['UNDEPLOYING'])
|
||||
service.state = Service::STATE['UNDEPLOYING']
|
||||
else
|
||||
service.set_state(Service::STATE['FAILED_UNDEPLOYING'])
|
||||
service.state = Service::STATE['FAILED_UNDEPLOYING']
|
||||
end
|
||||
|
||||
service.update
|
||||
|
@ -29,4 +29,6 @@ require 'opennebula/flow/service_template'
|
||||
require 'opennebula/flow/validator'
|
||||
|
||||
require 'models/role'
|
||||
require 'models/vmrole'
|
||||
require 'models/vrrole'
|
||||
require 'models/service'
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -104,8 +104,8 @@ module OpenNebula
|
||||
|
||||
# List of attributes that can't be changed in update operation
|
||||
#
|
||||
# custom_attrs: it only has sense when deploying, not in running
|
||||
# custom_attrs_values: it only has sense when deploying, not in running
|
||||
# user_inputs: it only has sense when deploying, not in running
|
||||
# user_inputs_values: it only has sense when deploying, not in running
|
||||
# deployment: changing this, changes the undeploy operation
|
||||
# log: this is just internal information, no sense to change it
|
||||
# name: this has to be changed using rename operation
|
||||
@ -115,8 +115,8 @@ module OpenNebula
|
||||
# state: this is internal information managed by OneFlow server
|
||||
# start_time: this is internal information managed by OneFlow server
|
||||
IMMUTABLE_ATTRS = [
|
||||
'custom_attrs',
|
||||
'custom_attrs_values',
|
||||
'user_inputs',
|
||||
'user_inputs_values',
|
||||
'deployment',
|
||||
'log',
|
||||
'name',
|
||||
@ -129,6 +129,12 @@ module OpenNebula
|
||||
|
||||
LOG_COMP = 'SER'
|
||||
|
||||
# Returns the service name
|
||||
# @return [String] the service name
|
||||
def name
|
||||
@body['name']
|
||||
end
|
||||
|
||||
# Returns the service state
|
||||
# @return [Integer] the service state
|
||||
def state
|
||||
@ -214,6 +220,10 @@ module OpenNebula
|
||||
self['UNAME']
|
||||
end
|
||||
|
||||
def uid
|
||||
self['UID'].to_i
|
||||
end
|
||||
|
||||
def gid
|
||||
self['GID'].to_i
|
||||
end
|
||||
@ -224,8 +234,9 @@ module OpenNebula
|
||||
@body['on_hold']
|
||||
end
|
||||
|
||||
def hold?
|
||||
state_str == 'HOLD'
|
||||
# Change the `on_hold` option value
|
||||
def on_hold=(on_hold)
|
||||
@body['on_hold'] = on_hold
|
||||
end
|
||||
|
||||
# Replaces this object's client with a new one
|
||||
@ -237,20 +248,14 @@ module OpenNebula
|
||||
# Sets a new state
|
||||
# @param [Integer] the new state
|
||||
# @return [true, false] true if the value was changed
|
||||
# rubocop:disable Naming/AccessorMethodName
|
||||
def set_state(state)
|
||||
# rubocop:enable Naming/AccessorMethodName
|
||||
if state < 0 || state > STATE_STR.size
|
||||
return false
|
||||
end
|
||||
def state=(state)
|
||||
return if state < 0 || state > STATE_STR.size
|
||||
|
||||
@body['state'] = state.to_i
|
||||
|
||||
msg = "New state: #{STATE_STR[state]}"
|
||||
Log.info LOG_COMP, msg, id
|
||||
log_info(msg)
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
# Returns true if all the nodes are correctly deployed
|
||||
@ -323,9 +328,6 @@ module OpenNebula
|
||||
|
||||
template['start_time'] = Integer(Time.now)
|
||||
|
||||
# Replace $attibute by the corresponding value
|
||||
resolve_attributes(template)
|
||||
|
||||
super(template.to_json, template['name'])
|
||||
end
|
||||
|
||||
@ -336,38 +338,38 @@ module OpenNebula
|
||||
if [Service::STATE['FAILED_DEPLOYING']].include?(state)
|
||||
@roles.each do |_name, role|
|
||||
if role.state == Role::STATE['FAILED_DEPLOYING']
|
||||
role.set_state(Role::STATE['PENDING'])
|
||||
role.state = Role::STATE['PENDING']
|
||||
end
|
||||
end
|
||||
|
||||
set_state(Service::STATE['DEPLOYING'])
|
||||
self.state = Service::STATE['DEPLOYING']
|
||||
|
||||
elsif state == Service::STATE['FAILED_SCALING']
|
||||
@roles.each do |_name, role|
|
||||
if role.state == Role::STATE['FAILED_SCALING']
|
||||
role.set_state(Role::STATE['SCALING'])
|
||||
role.state = Role::STATE['SCALING']
|
||||
end
|
||||
end
|
||||
|
||||
set_state(Service::STATE['SCALING'])
|
||||
self.state = Service::STATE['SCALING']
|
||||
|
||||
elsif state == Service::STATE['FAILED_UNDEPLOYING']
|
||||
@roles.each do |_name, role|
|
||||
if role.state == Role::STATE['FAILED_UNDEPLOYING']
|
||||
role.set_state(Role::STATE['RUNNING'])
|
||||
role.state = Role::STATE['RUNNING']
|
||||
end
|
||||
end
|
||||
|
||||
set_state(Service::STATE['UNDEPLOYING'])
|
||||
self.state = Service::STATE['UNDEPLOYING']
|
||||
|
||||
elsif state == Service::STATE['COOLDOWN']
|
||||
@roles.each do |_name, role|
|
||||
if role.state == Role::STATE['COOLDOWN']
|
||||
role.set_state(Role::STATE['RUNNING'])
|
||||
role.state = Role::STATE['RUNNING']
|
||||
end
|
||||
end
|
||||
|
||||
set_state(Service::STATE['RUNNING'])
|
||||
self.state = Service::STATE['RUNNING']
|
||||
|
||||
elsif state == Service::STATE['WARNING']
|
||||
@roles.each do |_name, role|
|
||||
@ -396,7 +398,7 @@ module OpenNebula
|
||||
if @body['roles']
|
||||
@body['roles'].each do |elem|
|
||||
elem['state'] ||= Role::STATE['PENDING']
|
||||
role = Role.new(elem, self)
|
||||
role = Role.for(elem, self)
|
||||
@roles[role.name] = role
|
||||
end
|
||||
end
|
||||
@ -411,7 +413,7 @@ module OpenNebula
|
||||
# @return [OpenNebula::Role] New role
|
||||
def add_role(template)
|
||||
template['state'] ||= Role::STATE['PENDING']
|
||||
role = Role.new(template, self)
|
||||
role = Role.for(template, self)
|
||||
|
||||
if @roles[role.name]
|
||||
return OpenNebula::Error.new("Role #{role.name} already exists")
|
||||
@ -444,7 +446,7 @@ module OpenNebula
|
||||
if @body['roles']
|
||||
@body['roles'].each do |elem|
|
||||
elem['state'] ||= Role::STATE['PENDING']
|
||||
role = Role.new(elem, self)
|
||||
role = Role.for(elem, self)
|
||||
@roles[role.name] = role
|
||||
end
|
||||
end
|
||||
@ -540,11 +542,11 @@ module OpenNebula
|
||||
# TODO: The update may not change the cardinality, only
|
||||
# the max and min vms...
|
||||
|
||||
role.set_state(Role::STATE['SCALING'])
|
||||
role.state = Role::STATE['SCALING']
|
||||
|
||||
role.set_default_cooldown_duration
|
||||
|
||||
set_state(Service::STATE['SCALING'])
|
||||
self.state = Service::STATE['SCALING']
|
||||
|
||||
update
|
||||
end
|
||||
@ -634,6 +636,19 @@ module OpenNebula
|
||||
[true, nil]
|
||||
end
|
||||
|
||||
# Fills the service template with the provided values.
|
||||
#
|
||||
# This method replaces placeholders in the service template with corresponding values
|
||||
# Placeholders are expected to be in the format $key.
|
||||
#
|
||||
# @return [nil, OpenNebula::Error] nil in case of success, Error otherwise
|
||||
def fill_template
|
||||
generate_template_contents
|
||||
rescue StandardError => e
|
||||
Log.error LOG_COMP, "Error generating VM template contents: #{e.message}"
|
||||
OpenNebula::Error('Error generating VM template contents')
|
||||
end
|
||||
|
||||
def deploy_networks(deploy = true)
|
||||
body = if deploy
|
||||
JSON.parse(self['TEMPLATE/BODY'])
|
||||
@ -659,9 +674,6 @@ module OpenNebula
|
||||
end
|
||||
end if deploy
|
||||
|
||||
# Replace $attibute by the corresponding value
|
||||
resolve_networks(body)
|
||||
|
||||
# @body = template.to_hash
|
||||
|
||||
update_body(body)
|
||||
@ -726,7 +738,7 @@ module OpenNebula
|
||||
if @body['roles']
|
||||
@body['roles'].each do |elem|
|
||||
elem['state'] ||= Role::STATE['PENDING']
|
||||
role = Role.new(elem, self)
|
||||
role = Role.for(elem, self)
|
||||
@roles[role.name] = role
|
||||
end
|
||||
end
|
||||
@ -779,60 +791,124 @@ module OpenNebula
|
||||
"#{net}-#{id}"
|
||||
end
|
||||
|
||||
# rubocop:disable Layout/LineLength
|
||||
def resolve_networks(template)
|
||||
template['roles'].each do |role|
|
||||
next unless role['vm_template_contents']
|
||||
# Generates and updates the `template_contents` for each role within a service.
|
||||
# This method handles VM attributes (like MEMORY, CPU, etc.) and CONTEXT attributes
|
||||
# within `template_contents` for each role. The contents are generated by combining
|
||||
# the `user_inputs_values` from both the service and the individual role, with the
|
||||
# role inputs taking precedence over the service inputs.
|
||||
#
|
||||
# The method also resolves network configurations for each role by mapping network
|
||||
# IDs from the service-level `networks_values` to the NICs defined in the role's
|
||||
# `template_contents`.
|
||||
#
|
||||
# @example
|
||||
# Given the following input data:
|
||||
# template_contents = {
|
||||
# 'MEMORY' => '1024',
|
||||
# 'NIC' => [
|
||||
# {
|
||||
# 'NAME' => 'NIC_0',
|
||||
# 'NETWORK_ID' => '$private'
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
#
|
||||
# networks_values = [{"private": {"id":"0"}}]
|
||||
# user_inputs_values = {"ATT_A": "VALUE_A"}
|
||||
#
|
||||
# After executing `generate_template_contents`, the result would be:
|
||||
# {
|
||||
# 'ATT_A' => 'VALUE_A',
|
||||
# 'MEMORY' => '1024',
|
||||
# 'NIC' => [
|
||||
# {
|
||||
# 'NAME' => 'NIC_0',
|
||||
# 'NETWORK_ID' => '0'
|
||||
# }
|
||||
# ],
|
||||
# 'CONTEXT' => {
|
||||
# 'ATT_A' => '$VALUE_A',
|
||||
# }
|
||||
# }
|
||||
#
|
||||
def generate_template_contents
|
||||
service_inputs = @body['user_inputs_values'] || {}
|
||||
service_networks = @body['networks_values'] || []
|
||||
|
||||
# $CUSTOM1_VAR Any word character
|
||||
# (letter, number, underscore)
|
||||
role['vm_template_contents'].scan(/\$(\w+)/).each do |key|
|
||||
net = template['networks_values'].find {|att| att.key? key[0] }
|
||||
@body['roles'].each do |role|
|
||||
template_contents = role['template_contents'] || {}
|
||||
role_inputs = role['user_inputs_values'] || {}
|
||||
role_nets = template_contents['NIC'] || []
|
||||
|
||||
next if net.nil?
|
||||
|
||||
role['vm_template_contents'].gsub!("$#{key[0]}", net[key[0]]['id'].to_s)
|
||||
# Resolve networks
|
||||
unless role_nets.empty?
|
||||
template_contents['NIC'] = resolve_networks(role_nets, service_networks)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_attributes(template)
|
||||
template['roles'].each do |role|
|
||||
if role['vm_template_contents']
|
||||
# $CUSTOM1_VAR Any word character
|
||||
# (letter, number, underscore)
|
||||
role['vm_template_contents'].scan(/\$(\w+)/).each do |key|
|
||||
# Check if $ var value is in custom_attrs_values within the role
|
||||
if !role['custom_attrs_values'].nil? && \
|
||||
role['custom_attrs_values'].key?(key[0])
|
||||
role['vm_template_contents'].gsub!(
|
||||
'$'+key[0],
|
||||
role['custom_attrs_values'][key[0]]
|
||||
)
|
||||
next
|
||||
end
|
||||
# Resolve inputs
|
||||
unless service_inputs.empty? && role_inputs.empty?
|
||||
# role inputs have precedence over service inputs
|
||||
role_inputs = service_inputs.deep_merge(role_inputs)
|
||||
|
||||
# Check if $ var value is in custom_attrs_values
|
||||
# Add the role inputs to the template_contents,
|
||||
# creating the CONTEXT section in case it doesn't exist
|
||||
template_contents['CONTEXT'] = {} unless template_contents.key?('CONTEXT')
|
||||
|
||||
next unless !template['custom_attrs_values'].nil? && \
|
||||
template['custom_attrs_values'].key?(key[0])
|
||||
|
||||
role['vm_template_contents'].gsub!(
|
||||
'$'+key[0],
|
||||
template['custom_attrs_values'][key[0]]
|
||||
)
|
||||
role_inputs.each do |key, value|
|
||||
template_contents[key] = value
|
||||
template_contents['CONTEXT'][key] = "$#{key}"
|
||||
end
|
||||
end
|
||||
|
||||
next unless role['user_inputs_values']
|
||||
|
||||
role['vm_template_contents'] ||= ''
|
||||
role['user_inputs_values'].each do |key, value|
|
||||
role['vm_template_contents'] += "\n#{key}=\"#{value}\""
|
||||
end
|
||||
role['template_contents'] = template_contents
|
||||
end
|
||||
end
|
||||
# rubocop:enable Layout/LineLength
|
||||
|
||||
# Replaces the `NETWORK_ID` placeholders in the given NICs with their corresponding
|
||||
# network IDs based on the provided `networks_values`. This method is used to resolve
|
||||
# dynamic network references (e.g., `$private`) in the role's NIC configuration with
|
||||
# the actual network IDs.
|
||||
#
|
||||
# @param nics [Array<Hash>] An array of NIC hashes for a role. Each NIC hash should
|
||||
# contain a `NETWORK_ID` key, which may have a value that
|
||||
# is a placeholder in the form `$network_name`.
|
||||
# @param networks_values [Array<Hash>] An array of network values, where each value
|
||||
# is a hash containing a network name as the key
|
||||
# and a network configuration as the value. The network
|
||||
# configuration should include an `id` key with the
|
||||
# actual network ID.
|
||||
#
|
||||
# @return [Array<Hash>] An array of NIC hashes with the `NETWORK_ID` placeholders replaced
|
||||
# by the corresponding network IDs from `networks_values`.
|
||||
#
|
||||
# @example
|
||||
# Given the following input data:
|
||||
# nics = [
|
||||
# { 'NAME' => 'NIC_0', 'NETWORK_ID' => '$private' },
|
||||
# { 'NAME' => 'NIC_1', 'NETWORK_ID' => '1' }
|
||||
# ]
|
||||
#
|
||||
# networks_values = [{ 'private' => { 'id' => '0' } }]
|
||||
#
|
||||
# After calling `resolve_networks(nics, networks_values)`, the result would be:
|
||||
# [
|
||||
# { 'NAME' => 'NIC_0', 'NETWORK_ID' => '0' },
|
||||
# { 'NAME' => 'NIC_1', 'NETWORK_ID' => '1' }
|
||||
# ]
|
||||
def resolve_networks(nics, networks_values)
|
||||
nics.each do |nic|
|
||||
next unless nic['NETWORK_ID']
|
||||
|
||||
match = nic['NETWORK_ID'].match(/\$(\w+)/)
|
||||
next unless match
|
||||
|
||||
net_name = match[1]
|
||||
network = networks_values.find {|att| att.key?(net_name) }
|
||||
nic['NETWORK_ID'] = network[net_name]['id'] if network
|
||||
end
|
||||
|
||||
nics
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
|
700
src/flow/lib/models/vmrole.rb
Normal file
700
src/flow/lib/models/vmrole.rb
Normal file
@ -0,0 +1,700 @@
|
||||
# -------------------------------------------------------------------------- #
|
||||
# Copyright 2002-2024, 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. #
|
||||
#--------------------------------------------------------------------------- #
|
||||
|
||||
module OpenNebula
|
||||
|
||||
# Service Role class
|
||||
class VMRole < Role
|
||||
|
||||
attr_reader :service
|
||||
|
||||
def initialize(body, service)
|
||||
super(body, service)
|
||||
|
||||
@body['cooldown'] = @@default_cooldown if @body['cooldown'].nil?
|
||||
end
|
||||
|
||||
# Sets a new state
|
||||
# @param [Integer] the new state
|
||||
def state=(state)
|
||||
super(state)
|
||||
return unless state == STATE['SCALING']
|
||||
|
||||
elasticity_pol = @body['elasticity_policies']
|
||||
|
||||
return if elasticity_pol.nil?
|
||||
|
||||
elasticity_pol.each do |policy|
|
||||
policy.delete('true_evals')
|
||||
end
|
||||
end
|
||||
|
||||
########################################################################
|
||||
# Operations
|
||||
########################################################################
|
||||
|
||||
# Changes the owner/group of all the nodes in this role
|
||||
#
|
||||
# @param [Integer] uid the new owner id. Set to -1 to leave the current
|
||||
# @param [Integer] gid the new group id. Set to -1 to leave the current
|
||||
#
|
||||
# @return [Array<true, nil>, Array<false, String>] true if all the VMs
|
||||
# were updated, false and the error reason if there was a problem
|
||||
# updating the VMs
|
||||
def chown(uid, gid)
|
||||
nodes.each do |node|
|
||||
vm_id = node['deploy_id']
|
||||
|
||||
Log.debug LOG_COMP,
|
||||
"Role #{name} : Chown for VM #{vm_id}",
|
||||
@service.id
|
||||
|
||||
vm = OpenNebula::VirtualMachine.new_with_id(vm_id,
|
||||
@service.client)
|
||||
rc = vm.chown(uid, gid)
|
||||
|
||||
if OpenNebula.is_error?(rc)
|
||||
msg = "Role #{name} : Chown failed for VM #{vm_id}; " \
|
||||
"#{rc.message}"
|
||||
|
||||
Log.error LOG_COMP, msg, @service.id
|
||||
@service.log_error(msg)
|
||||
|
||||
return [false, rc.message]
|
||||
else
|
||||
Log.debug LOG_COMP,
|
||||
"Role #{name} : Chown success for VM #{vm_id}",
|
||||
@service.id
|
||||
end
|
||||
end
|
||||
|
||||
[true, nil]
|
||||
end
|
||||
|
||||
# Updates the role
|
||||
# @param [Hash] template
|
||||
# @return [nil, OpenNebula::Error] nil in case of success, Error
|
||||
# otherwise
|
||||
def update(template)
|
||||
force = template['force'] == true
|
||||
new_cardinality = template['cardinality']
|
||||
|
||||
return if new_cardinality.nil?
|
||||
|
||||
new_cardinality = new_cardinality.to_i
|
||||
|
||||
if !force
|
||||
if new_cardinality < min_cardinality.to_i
|
||||
return OpenNebula::Error.new(
|
||||
"Minimum cardinality is #{min_cardinality}"
|
||||
)
|
||||
|
||||
elsif !max_cardinality.nil? &&
|
||||
new_cardinality > max_cardinality.to_i
|
||||
return OpenNebula::Error.new(
|
||||
"Maximum cardinality is #{max_cardinality}"
|
||||
)
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
self.cardinality = new_cardinality
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
########################################################################
|
||||
# Scheduler
|
||||
########################################################################
|
||||
|
||||
# Schedule the given action on all the VMs that belong to the Role
|
||||
# @param [String] action one of the available SCHEDULE_ACTIONS
|
||||
# @param [Integer] period
|
||||
# @param [Integer] vm_per_period
|
||||
# @param [String] action arguments
|
||||
def batch_action(action, period, vms_per_period, args)
|
||||
vms_id = []
|
||||
error_msgs = []
|
||||
nodes = @body['nodes']
|
||||
now = Time.now.to_i
|
||||
time_offset = 0
|
||||
|
||||
# if role is done, return error
|
||||
if state == 5
|
||||
return OpenNebula::Error.new("Role #{name} is in DONE state")
|
||||
end
|
||||
|
||||
do_offset = !period.nil? && period.to_i > 0 &&
|
||||
!vms_per_period.nil? && vms_per_period.to_i > 0
|
||||
|
||||
nodes.each_with_index do |node, index|
|
||||
vm_id = node['deploy_id']
|
||||
vm = OpenNebula::VirtualMachine.new_with_id(vm_id,
|
||||
@service.client)
|
||||
|
||||
if do_offset
|
||||
offset = (index / vms_per_period.to_i).floor
|
||||
time_offset = offset * period.to_i
|
||||
end
|
||||
|
||||
tmp_str = 'SCHED_ACTION = ['
|
||||
tmp_str << "ACTION = #{action},"
|
||||
tmp_str << "ARGS = \"#{args}\"," if args
|
||||
tmp_str << "TIME = #{now + time_offset}]"
|
||||
|
||||
rc = vm.sched_action_add(tmp_str)
|
||||
if OpenNebula.is_error?(rc)
|
||||
msg = "Role #{name} : VM #{vm_id} error scheduling "\
|
||||
"action; #{rc.message}"
|
||||
|
||||
error_msgs << msg
|
||||
|
||||
Log.error LOG_COMP, msg, @service.id
|
||||
|
||||
@service.log_error(msg)
|
||||
else
|
||||
vms_id << vm.id
|
||||
end
|
||||
end
|
||||
|
||||
log_msg = "Action:#{action} scheduled on Role:#{name}"\
|
||||
"VMs:#{vms_id.join(',')}"
|
||||
|
||||
Log.info LOG_COMP, log_msg, @service.id
|
||||
|
||||
return [true, log_msg] if error_msgs.empty?
|
||||
|
||||
error_msgs << log_msg
|
||||
|
||||
[false, error_msgs.join('\n')]
|
||||
end
|
||||
|
||||
########################################################################
|
||||
# Scalability
|
||||
########################################################################
|
||||
|
||||
# Returns the role max cardinality
|
||||
# @return [Integer,nil] the role cardinality or nil if it isn't defined
|
||||
def max_cardinality
|
||||
max = @body['max_vms']
|
||||
|
||||
return if max.nil?
|
||||
|
||||
max.to_i
|
||||
end
|
||||
|
||||
# Returns the role min cardinality
|
||||
# @return [Integer,nil] the role cardinality or nil if it isn't defined
|
||||
def min_cardinality
|
||||
min = @body['min_vms']
|
||||
|
||||
return if min.nil?
|
||||
|
||||
min.to_i
|
||||
end
|
||||
|
||||
# Returns a positive, 0, or negative number of nodes to adjust,
|
||||
# according to the elasticity and scheduled policies
|
||||
# @return [Array<Integer>] positive, 0, or negative number of nodes to
|
||||
# adjust, plus the cooldown period duration
|
||||
def scale?(vm_pool)
|
||||
elasticity_pol = @body['elasticity_policies']
|
||||
scheduled_pol = @body['scheduled_policies']
|
||||
|
||||
elasticity_pol ||= []
|
||||
scheduled_pol ||= []
|
||||
|
||||
scheduled_pol.each do |policy|
|
||||
diff, cooldown_duration = scale_time?(policy)
|
||||
|
||||
return [diff, cooldown_duration] if diff != 0
|
||||
end
|
||||
|
||||
elasticity_pol.each do |policy|
|
||||
diff, cooldown_duration = scale_attributes?(policy, vm_pool)
|
||||
|
||||
next if diff == 0
|
||||
|
||||
cooldown_duration = @body['cooldown'] if cooldown_duration.nil?
|
||||
cooldown_duration = @@default_cooldown if cooldown_duration.nil?
|
||||
|
||||
return [diff, cooldown_duration]
|
||||
end
|
||||
|
||||
# Implicit rule that scales up to maintain the min_cardinality, with
|
||||
# no cooldown period
|
||||
if cardinality < min_cardinality.to_i
|
||||
return [min_cardinality.to_i - cardinality, 0]
|
||||
end
|
||||
|
||||
[0, 0]
|
||||
end
|
||||
|
||||
def elasticity_policies
|
||||
@body['elasticity_policies']
|
||||
end
|
||||
|
||||
def update_elasticity_policies(new_policies)
|
||||
@body['elasticity_policies'] = new_policies
|
||||
end
|
||||
|
||||
def cooldown
|
||||
@body['cooldown']
|
||||
end
|
||||
|
||||
def update_cooldown(new_cooldown)
|
||||
@body['cooldown'] = new_cooldown unless new_cooldown.nil?
|
||||
end
|
||||
|
||||
def scale_way(way)
|
||||
@body['scale_way'] = SCALE_WAYS[way]
|
||||
end
|
||||
|
||||
def clean_scale_way
|
||||
@body.delete('scale_way')
|
||||
end
|
||||
|
||||
########################################################################
|
||||
# Deployment
|
||||
########################################################################
|
||||
|
||||
# Deploys all the nodes in this role
|
||||
#
|
||||
# @return [Array<true, nil>, Array<false, String>] true if all the VMs
|
||||
# were created, false and the error reason if there was a problem
|
||||
# creating the VMs
|
||||
def deploy
|
||||
deployed_nodes = []
|
||||
n_nodes = cardinality - nodes.size
|
||||
|
||||
return [deployed_nodes, nil] if n_nodes == 0
|
||||
|
||||
template_id, template, extra_template = init_template_attributes
|
||||
|
||||
n_nodes.times do
|
||||
vm_name = @@vm_name_template
|
||||
.gsub('$SERVICE_ID', @service.id.to_s)
|
||||
.gsub('$SERVICE_NAME', @service.name.to_s)
|
||||
.gsub('$ROLE_NAME', name.to_s)
|
||||
.gsub('$VM_NUMBER', @body['last_vmname'].to_s)
|
||||
|
||||
@body['last_vmname'] += 1
|
||||
|
||||
Log.debug(
|
||||
LOG_COMP,
|
||||
"Role #{name} : Instantiate template #{template_id}, name #{vm_name}",
|
||||
@service.id
|
||||
)
|
||||
|
||||
# Instantiate VM
|
||||
vm_id = template.instantiate(vm_name, on_hold?, extra_template)
|
||||
|
||||
if OpenNebula.is_error?(vm_id)
|
||||
msg = "Role #{name} : Instantiate failed for template " \
|
||||
"#{template_id}; #{vm_id.message}"
|
||||
|
||||
Log.error(LOG_COMP, msg, @service.id)
|
||||
|
||||
@service.log_error(msg)
|
||||
|
||||
return [false, "Error instantiating VM Template #{template_id} in Role " \
|
||||
"#{name}: #{vm_id.message}"]
|
||||
end
|
||||
|
||||
Log.debug(
|
||||
LOG_COMP,
|
||||
"Role #{name} : Instantiate success, VM ID #{vm_id}",
|
||||
@service.id
|
||||
)
|
||||
|
||||
# Once deployed, save VM info in role node body
|
||||
deployed_nodes << vm_id
|
||||
fill_node_info(vm_id)
|
||||
end
|
||||
|
||||
[deployed_nodes, nil]
|
||||
end
|
||||
|
||||
########################################################################
|
||||
# Recover
|
||||
########################################################################
|
||||
|
||||
def recover_deploy(report)
|
||||
nodes = @body['nodes']
|
||||
deployed_nodes = []
|
||||
|
||||
nodes.each do |node|
|
||||
vm_id = node['deploy_id']
|
||||
|
||||
vm = OpenNebula::VirtualMachine.new_with_id(vm_id,
|
||||
@service.client)
|
||||
|
||||
rc = vm.info
|
||||
|
||||
if OpenNebula.is_error?(rc)
|
||||
msg = "Role #{name} : Retry failed for VM "\
|
||||
"#{vm_id}; #{rc.message}"
|
||||
Log.error LOG_COMP, msg, @service.id
|
||||
|
||||
next true
|
||||
end
|
||||
|
||||
vm_state = vm.state
|
||||
lcm_state = vm.lcm_state
|
||||
|
||||
# ACTIVE/RUNNING
|
||||
next false if vm_state == 3 && lcm_state == 3 && !report
|
||||
|
||||
next true if vm_state == '6' # Delete DONE nodes
|
||||
|
||||
if Role.vm_failure?(vm_state, lcm_state)
|
||||
rc = vm.recover(2)
|
||||
|
||||
if OpenNebula.is_error?(rc)
|
||||
msg = "Role #{name} : Retry failed for VM "\
|
||||
"#{vm_id}; #{rc.message}"
|
||||
|
||||
Log.error LOG_COMP, msg, @service.id
|
||||
@service.log_error(msg)
|
||||
else
|
||||
deployed_nodes << vm_id
|
||||
end
|
||||
else
|
||||
vm.resume
|
||||
|
||||
deployed_nodes << vm_id
|
||||
end
|
||||
end
|
||||
|
||||
rc = deploy
|
||||
|
||||
unless rc[0]
|
||||
return [false, "Error deploying nodes for role `#{name}`"]
|
||||
end
|
||||
|
||||
deployed_nodes.concat(rc[0])
|
||||
|
||||
deployed_nodes
|
||||
end
|
||||
|
||||
def recover_undeploy
|
||||
undeployed_nodes = []
|
||||
|
||||
rc = shutdown(true)
|
||||
|
||||
undeployed_nodes.concat(rc[0]) if rc[1].nil?
|
||||
|
||||
undeployed_nodes
|
||||
end
|
||||
|
||||
def recover_scale(report)
|
||||
rc = nil
|
||||
|
||||
if @body['scale_way'] == SCALE_WAYS['UP']
|
||||
rc = [recover_deploy(report), true]
|
||||
elsif @body['scale_way'] == SCALE_WAYS['DOWN']
|
||||
rc = [recover_undeploy, false]
|
||||
end
|
||||
|
||||
rc
|
||||
end
|
||||
|
||||
########################################################################
|
||||
# Helpers
|
||||
########################################################################
|
||||
|
||||
private
|
||||
|
||||
# Shuts down all the given nodes
|
||||
# @param scale_down [true,false] True to set the 'disposed' node flag
|
||||
def shutdown_nodes(nodes, n_nodes, recover)
|
||||
success = true
|
||||
undeployed_nodes = []
|
||||
|
||||
action = @body['shutdown_action']
|
||||
|
||||
if action.nil?
|
||||
action = @service.shutdown_action
|
||||
end
|
||||
|
||||
if action.nil?
|
||||
action = @@default_shutdown
|
||||
end
|
||||
|
||||
nodes[0..n_nodes - 1].each do |node|
|
||||
vm_id = node['deploy_id']
|
||||
|
||||
Log.debug(LOG_COMP,
|
||||
"Role #{name} : Terminating VM #{vm_id}",
|
||||
@service.id)
|
||||
|
||||
vm = OpenNebula::VirtualMachine.new_with_id(vm_id,
|
||||
@service.client)
|
||||
|
||||
vm_state = nil
|
||||
lcm_state = nil
|
||||
|
||||
if recover
|
||||
vm.info
|
||||
|
||||
vm_state = vm.state
|
||||
lcm_state = vm.lcm_state
|
||||
end
|
||||
|
||||
if recover && Role.vm_failure?(vm_state, lcm_state)
|
||||
rc = vm.recover(2)
|
||||
elsif action == 'terminate-hard'
|
||||
rc = vm.terminate(true)
|
||||
else
|
||||
rc = vm.terminate
|
||||
end
|
||||
|
||||
if OpenNebula.is_error?(rc)
|
||||
msg = "Role #{name} : Terminate failed for VM #{vm_id}, " \
|
||||
"will perform a Delete; #{rc.message}"
|
||||
|
||||
Log.error LOG_COMP, msg, @service.id
|
||||
@service.log_error(msg)
|
||||
|
||||
if action != 'terminate-hard'
|
||||
rc = vm.terminate(true)
|
||||
end
|
||||
|
||||
if OpenNebula.is_error?(rc)
|
||||
rc = vm.delete
|
||||
end
|
||||
|
||||
if OpenNebula.is_error?(rc)
|
||||
msg = "Role #{name} : Delete failed for VM #{vm_id}; " \
|
||||
"#{rc.message}"
|
||||
|
||||
Log.error LOG_COMP, msg, @service.id
|
||||
@service.log_error(msg)
|
||||
|
||||
success = false
|
||||
else
|
||||
Log.debug(LOG_COMP,
|
||||
"Role #{name} : Delete success for VM " \
|
||||
"#{vm_id}",
|
||||
@service.id)
|
||||
|
||||
undeployed_nodes << vm_id
|
||||
end
|
||||
else
|
||||
Log.debug(LOG_COMP,
|
||||
"Role #{name}: Terminate success for VM #{vm_id}",
|
||||
@service.id)
|
||||
undeployed_nodes << vm_id
|
||||
end
|
||||
end
|
||||
|
||||
[success, undeployed_nodes]
|
||||
end
|
||||
|
||||
# Returns a positive, 0, or negative number of nodes to adjust,
|
||||
# according to a SCHEDULED type policy
|
||||
# @param [Hash] A SCHEDULED type policy
|
||||
# @return [Integer] positive, 0, or negative number of nodes to adjust
|
||||
def scale_time?(elasticity_pol)
|
||||
now = Time.now.to_i
|
||||
last_eval = elasticity_pol['last_eval'].to_i
|
||||
|
||||
elasticity_pol['last_eval'] = now
|
||||
|
||||
# If this is the first time this is evaluated, ignore it.
|
||||
# We don't want to execute actions planned in the past when the
|
||||
# server starts.
|
||||
|
||||
return 0 if last_eval == 0
|
||||
|
||||
start_time = elasticity_pol['start_time']
|
||||
target_vms = elasticity_pol['adjust']
|
||||
|
||||
# TODO: error msg
|
||||
return 0 if target_vms.nil?
|
||||
|
||||
if !(start_time.nil? || start_time.empty?)
|
||||
begin
|
||||
if !start_time.match(/^\d+$/)
|
||||
start_time = Time.parse(start_time).to_i
|
||||
else
|
||||
start_time = start_time.to_i
|
||||
end
|
||||
rescue ArgumentError
|
||||
# TODO: error msg
|
||||
return 0
|
||||
end
|
||||
else
|
||||
recurrence = elasticity_pol['recurrence']
|
||||
|
||||
# TODO: error msg
|
||||
return 0 if recurrence.nil? || recurrence.empty?
|
||||
|
||||
begin
|
||||
cron_parser = CronParser.new(recurrence)
|
||||
|
||||
# This returns the next planned time, starting from the last
|
||||
# step
|
||||
start_time = cron_parser.next(Time.at(last_eval)).to_i
|
||||
rescue StandardError
|
||||
# TODO: error msg bad format
|
||||
return 0
|
||||
end
|
||||
end
|
||||
|
||||
# Only actions planned between last step and this one are triggered
|
||||
if start_time > last_eval && start_time <= now
|
||||
Log.debug LOG_COMP,
|
||||
"Role #{name} : scheduled scalability for " \
|
||||
"#{Time.at(start_time)} triggered", @service.id
|
||||
|
||||
new_cardinality = calculate_new_cardinality(elasticity_pol)
|
||||
|
||||
return [new_cardinality - cardinality,
|
||||
elasticity_pol['cooldown']]
|
||||
end
|
||||
|
||||
[0, elasticity_pol['cooldown']]
|
||||
end
|
||||
|
||||
# Returns a positive, 0, or negative number of nodes to adjust,
|
||||
# according to a policy based on attributes
|
||||
# @param [Hash] A policy based on attributes
|
||||
# @return [Array<Integer>] positive, 0, or negative number of nodes to
|
||||
# adjust, plus the cooldown period duration
|
||||
def scale_attributes?(elasticity_pol, vm_pool)
|
||||
now = Time.now.to_i
|
||||
|
||||
# TODO: enforce true_up_evals type in ServiceTemplate::ROLE_SCHEMA ?
|
||||
|
||||
period_duration = elasticity_pol['period'].to_i
|
||||
period_number = elasticity_pol['period_number'].to_i
|
||||
last_eval = elasticity_pol['last_eval'].to_i
|
||||
true_evals = elasticity_pol['true_evals'].to_i
|
||||
expression = elasticity_pol['expression']
|
||||
|
||||
if !last_eval.nil? && now < (last_eval + period_duration)
|
||||
return [0, 0]
|
||||
end
|
||||
|
||||
elasticity_pol['last_eval'] = now
|
||||
|
||||
new_cardinality = cardinality
|
||||
new_evals = 0
|
||||
|
||||
exp_value, exp_st = scale_rule(expression, vm_pool)
|
||||
|
||||
if exp_value
|
||||
new_evals = true_evals + 1
|
||||
new_evals = period_number if new_evals > period_number
|
||||
|
||||
if new_evals >= period_number
|
||||
Log.debug LOG_COMP,
|
||||
"Role #{name} : elasticy policy #{exp_st} "\
|
||||
'triggered', @service.id
|
||||
|
||||
new_cardinality = calculate_new_cardinality(elasticity_pol)
|
||||
end
|
||||
end
|
||||
|
||||
elasticity_pol['true_evals'] = new_evals
|
||||
elasticity_pol['expression_evaluated'] = exp_st
|
||||
|
||||
[new_cardinality - cardinality, elasticity_pol['cooldown']]
|
||||
end
|
||||
|
||||
# Returns true if the scalability rule is triggered
|
||||
# @return true if the scalability rule is triggered
|
||||
def scale_rule(elas_expr, vm_pool)
|
||||
parser = ElasticityGrammarParser.new
|
||||
|
||||
if elas_expr.nil? || elas_expr.empty?
|
||||
return false
|
||||
end
|
||||
|
||||
treetop = parser.parse(elas_expr)
|
||||
|
||||
if treetop.nil?
|
||||
return [false,
|
||||
"Parse error. '#{elas_expr}': #{parser.failure_reason}"]
|
||||
end
|
||||
|
||||
val, st = treetop.result(self, vm_pool)
|
||||
|
||||
[val, st]
|
||||
end
|
||||
|
||||
def calculate_new_cardinality(elasticity_pol)
|
||||
type = elasticity_pol['type']
|
||||
adjust = elasticity_pol['adjust'].to_i
|
||||
|
||||
# Min is a hard limit, if the current cardinality + adjustment does
|
||||
# not reach it, the difference is added
|
||||
|
||||
max = [cardinality, max_cardinality.to_i].max
|
||||
# min = [cardinality(), min_cardinality.to_i].min()
|
||||
min = min_cardinality.to_i
|
||||
|
||||
case type.upcase
|
||||
when 'CHANGE'
|
||||
new_cardinality = cardinality + adjust
|
||||
when 'PERCENTAGE_CHANGE'
|
||||
min_adjust_step = elasticity_pol['min_adjust_step'].to_i
|
||||
|
||||
change = cardinality * adjust / 100.0
|
||||
|
||||
change > 0 ? sign = 1 : sign = -1
|
||||
change = change.abs
|
||||
|
||||
if change < 1
|
||||
change = 1
|
||||
else
|
||||
change = change.to_i
|
||||
end
|
||||
|
||||
change = sign * [change, min_adjust_step].max
|
||||
|
||||
new_cardinality = cardinality + change
|
||||
|
||||
when 'CARDINALITY'
|
||||
new_cardinality = adjust
|
||||
else
|
||||
Log.error(
|
||||
LOG_COMP,
|
||||
"Error calculating new cardinality for type #{type}",
|
||||
service.id
|
||||
)
|
||||
|
||||
return cardinality
|
||||
end
|
||||
|
||||
# The cardinality can be forced to be outside the min,max
|
||||
# range. If that is the case, the scale up/down will not
|
||||
# move further outside the range. It will move towards the
|
||||
# range with the adjustement set, instead of jumping the
|
||||
# difference
|
||||
if adjust > 0
|
||||
new_cardinality = max if new_cardinality > max
|
||||
elsif adjust < 0
|
||||
new_cardinality = min if new_cardinality < min
|
||||
end
|
||||
|
||||
new_cardinality
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
266
src/flow/lib/models/vrrole.rb
Normal file
266
src/flow/lib/models/vrrole.rb
Normal file
@ -0,0 +1,266 @@
|
||||
# -------------------------------------------------------------------------- #
|
||||
# Copyright 2002-2024, 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. #
|
||||
#--------------------------------------------------------------------------- #
|
||||
|
||||
module OpenNebula
|
||||
|
||||
# Virtual Route Role class
|
||||
class VRRole < Role
|
||||
|
||||
attr_reader :service
|
||||
|
||||
########################################################################
|
||||
# Operations
|
||||
########################################################################
|
||||
|
||||
def chown(uid, gid)
|
||||
vrouter_id = @body['vrouter_id']
|
||||
|
||||
vrouter = OpenNebula::VirtualRouter.new_with_id(
|
||||
vrouter_id, @service.client
|
||||
)
|
||||
|
||||
rc = vrouter.chown(uid, gid)
|
||||
|
||||
if OpenNebula.is_error?(rc)
|
||||
msg = "Role #{name} : Chown failed for VR #{vrouter_id}; " \
|
||||
"#{rc.message}"
|
||||
|
||||
Log.error(LOG_COMP, msg, @service.id)
|
||||
@service.log_error(msg)
|
||||
|
||||
return [false, rc.message]
|
||||
else
|
||||
msg = "Role #{name} : Chown success for VR #{vrouter_id}"
|
||||
Log.debug(LOG_COMP, msg, @service.id)
|
||||
|
||||
end
|
||||
|
||||
[true, nil]
|
||||
end
|
||||
|
||||
def update(_)
|
||||
return OpenNebula::Error.new(
|
||||
"Virtual Router role #{name} does not support cardinality update"
|
||||
)
|
||||
end
|
||||
|
||||
########################################################################
|
||||
# Scheduler
|
||||
########################################################################
|
||||
|
||||
def batch_action(_, _, _, _)
|
||||
return OpenNebula::Error.new(
|
||||
"Virtual Router role #{name} does not support schedule actions"
|
||||
)
|
||||
end
|
||||
|
||||
########################################################################
|
||||
# Scalability
|
||||
########################################################################
|
||||
|
||||
# Returns the role max cardinality
|
||||
# @return [Integer,nil] the role cardinality
|
||||
def max_cardinality
|
||||
return cardinality
|
||||
end
|
||||
|
||||
# Returns the role min cardinality
|
||||
# @return [Integer,nil] the role cardinality
|
||||
def min_cardinality
|
||||
return cardinality
|
||||
end
|
||||
|
||||
def scale?(_)
|
||||
return [0, 0]
|
||||
end
|
||||
|
||||
def elasticity_policies
|
||||
[]
|
||||
end
|
||||
|
||||
def update_elasticity_policies(_)
|
||||
[]
|
||||
end
|
||||
|
||||
def cooldown
|
||||
[]
|
||||
end
|
||||
|
||||
def update_cooldown(_)
|
||||
[]
|
||||
end
|
||||
|
||||
def scale_way(_)
|
||||
[]
|
||||
end
|
||||
|
||||
def clean_scale_way
|
||||
[]
|
||||
end
|
||||
|
||||
########################################################################
|
||||
# Deployment
|
||||
########################################################################
|
||||
|
||||
# Deploys all the nodes in this role
|
||||
#
|
||||
# @return [Array<true, nil>, Array<false, String>] true if all the VMs
|
||||
# were created, false and the error reason if there was a problem
|
||||
# creating the VMs
|
||||
def deploy
|
||||
deployed_nodes = []
|
||||
n_nodes = cardinality - nodes.size
|
||||
|
||||
return [deployed_nodes, nil] if n_nodes == 0
|
||||
|
||||
vr_name = @@vr_name_template
|
||||
.gsub('$SERVICE_ID', @service.id.to_s)
|
||||
.gsub('$SERVICE_NAME', @service.name.to_s)
|
||||
.gsub('$ROLE_NAME', name.to_s)
|
||||
|
||||
@body['template_contents']['NAME'] = vr_name
|
||||
template_id, _, extra_template = init_template_attributes
|
||||
|
||||
# Create vrouter Object and description
|
||||
vrouter = VirtualRouter.new(
|
||||
VirtualRouter.build_xml(@service.uid),
|
||||
@service.client
|
||||
)
|
||||
|
||||
Log.debug(
|
||||
LOG_COMP,
|
||||
"Role #{name} : Creating service VRouter",
|
||||
@service.id
|
||||
)
|
||||
|
||||
# Allocating VR with role description provided
|
||||
rc = vrouter.allocate(extra_template)
|
||||
|
||||
if OpenNebula.is_error?(rc)
|
||||
msg = "Role #{name} : Allocate failed for Vrouter " \
|
||||
"#{template_id}; #{rc.message}"
|
||||
|
||||
Log.error(LOG_COMP, msg, @service.id)
|
||||
@service.log_error(msg)
|
||||
|
||||
return [false, "Error allocating Vrouter #{template_id} in Role " \
|
||||
"#{name}: #{rc.message}"]
|
||||
end
|
||||
|
||||
Log.debug(
|
||||
LOG_COMP,
|
||||
"Role #{name} : Instantiating VRouter #{vrouter.id}",
|
||||
@service.id
|
||||
)
|
||||
|
||||
# Instantiating Vrouters
|
||||
vm_name = @@vm_name_template
|
||||
.gsub('$SERVICE_ID', @service.id.to_s)
|
||||
.gsub('$SERVICE_NAME', @service.name.to_s)
|
||||
.gsub('$ROLE_NAME', name.to_s)
|
||||
.gsub('$VM_NUMBER', '%i')
|
||||
|
||||
rc = vrouter.instantiate(
|
||||
n_nodes,
|
||||
template_id,
|
||||
vm_name,
|
||||
on_hold?,
|
||||
extra_template
|
||||
)
|
||||
|
||||
if OpenNebula.is_error?(rc)
|
||||
msg = "Role #{name} : Instantiate failed for Vrouter " \
|
||||
"#{vrouter.id}; #{rc.message}"
|
||||
|
||||
Log.error(LOG_COMP, msg, @service.id)
|
||||
@service.log_error(msg)
|
||||
|
||||
return [false, "Error instantiating Vrouter #{vrouter.id} in Role " \
|
||||
"#{name}: #{rc.message}"]
|
||||
end
|
||||
|
||||
vrouter.info
|
||||
|
||||
# Once deployed, save VM info in role node body
|
||||
deployed_nodes.concat(vrouter.vm_ids)
|
||||
|
||||
deployed_nodes.each do |vm_id|
|
||||
fill_node_info(vm_id)
|
||||
end
|
||||
|
||||
@body['vrouter_id'] = vrouter.id
|
||||
|
||||
# Fill vrouter IP in vrouter role body
|
||||
vrouter_nics = vrouter.to_hash['VROUTER']['TEMPLATE']['NIC']
|
||||
|
||||
if vrouter_nics.is_a?(Array) && !vrouter_nics.empty?
|
||||
@body['vrouter_ips'] = vrouter_nics.map do |nic|
|
||||
next unless nic.is_a?(Hash) && nic.key?('VROUTER_IP')
|
||||
|
||||
{
|
||||
'NETWORK_ID' => nic['NETWORK_ID'].to_i,
|
||||
'VROUTER_IP' => nic['VROUTER_IP']
|
||||
}
|
||||
end.compact
|
||||
end
|
||||
|
||||
[deployed_nodes, nil]
|
||||
end
|
||||
|
||||
########################################################################
|
||||
# Recover
|
||||
########################################################################
|
||||
|
||||
# VRs do not support scale operations, returing empty array with
|
||||
# zero nodes deployed / shutdown
|
||||
def recover_scale(_)
|
||||
[]
|
||||
end
|
||||
|
||||
########################################################################
|
||||
# Helpers
|
||||
########################################################################
|
||||
|
||||
def shutdown_nodes(nodes, n_nodes, _)
|
||||
success = true
|
||||
vrouter_id = @body['vrouter_id']
|
||||
|
||||
return [success, nodes] if nodes.empty? && vrouter_id.nil?
|
||||
|
||||
msg = "Role #{name} : Terminating VR #{vrouter_id} (#{n_nodes} VMs associated)"
|
||||
Log.debug(LOG_COMP, msg, @service.id)
|
||||
|
||||
vrouter = OpenNebula::VirtualRouter.new_with_id(
|
||||
vrouter_id, @service.client
|
||||
)
|
||||
|
||||
rc = vrouter.delete
|
||||
|
||||
if OpenNebula.is_error?(rc)
|
||||
msg = "Role #{name} : Delete failed for VR #{vrouter_id}: #{rc.message}"
|
||||
|
||||
Log.error(LOG_COMP, msg, @service.id)
|
||||
@service.log_error(msg)
|
||||
|
||||
success = false
|
||||
end
|
||||
|
||||
[success, nodes]
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
@ -75,6 +75,7 @@ require 'LifeCycleManager'
|
||||
require 'EventManager'
|
||||
|
||||
DEFAULT_VM_NAME_TEMPLATE = '$ROLE_NAME_$VM_NUMBER_(service_$SERVICE_ID)'
|
||||
DEFAULT_VR_NAME_TEMPLATE = '$ROLE_NAME(service_$SERVICE_ID)'
|
||||
|
||||
##############################################################################
|
||||
# Configuration
|
||||
@ -99,6 +100,7 @@ conf[:shutdown_action] ||= 'terminate'
|
||||
conf[:action_number] ||= 1
|
||||
conf[:action_period] ||= 60
|
||||
conf[:vm_name_template] ||= DEFAULT_VM_NAME_TEMPLATE
|
||||
conf[:vr_name_template] ||= DEFAULT_VR_NAME_TEMPLATE
|
||||
conf[:wait_timeout] ||= 30
|
||||
conf[:concurrency] ||= 10
|
||||
conf[:auth] = 'opennebula'
|
||||
@ -204,24 +206,24 @@ def one_error_to_http(error)
|
||||
end
|
||||
end
|
||||
|
||||
# Check if the custom_attrs and their respective values are correct
|
||||
# Check if the user_inputs and their respective values are correct
|
||||
#
|
||||
# @param custom_attrs [Hash] Custom attrs of the service/role
|
||||
# @param custom_attrs_values [Hash] Custom attrs values to check
|
||||
def check_custom_attrs(custom_attrs, custom_attrs_values)
|
||||
return if custom_attrs.nil? || custom_attrs.empty?
|
||||
# @param user_inputs [Hash] User inputs of the service/role
|
||||
# @param user_inputs_values [Hash] User inputs values to check
|
||||
def check_user_inptus(user_inputs, user_inputs_values)
|
||||
return if user_inputs.nil? || user_inputs.empty?
|
||||
|
||||
if custom_attrs_values.nil?
|
||||
raise 'The Service template specifies custom attributes but no values have been found'
|
||||
if user_inputs_values.nil?
|
||||
raise 'The Service template specifies User Inputs but no values have been found'
|
||||
end
|
||||
|
||||
if !custom_attrs.is_a?(Hash) || !custom_attrs_values.is_a?(Hash)
|
||||
raise 'Wrong custom_attrs or custom_attrs_values format'
|
||||
if !user_inputs.is_a?(Hash) || !user_inputs_values.is_a?(Hash)
|
||||
raise 'Wrong User Inputs or User Inputs Values format'
|
||||
end
|
||||
|
||||
return if (custom_attrs.keys - custom_attrs_values.keys).empty?
|
||||
return if (user_inputs.keys - user_inputs_values.keys).empty?
|
||||
|
||||
raise 'Verify that every custom attribute have its corresponding value defined'
|
||||
raise 'Verify that every User Input have its corresponding value defined'
|
||||
end
|
||||
|
||||
##############################################################################
|
||||
@ -232,6 +234,7 @@ Role.init_default_cooldown(conf[:default_cooldown])
|
||||
Role.init_default_shutdown(conf[:shutdown_action])
|
||||
Role.init_force_deletion(conf[:force_deletion])
|
||||
Role.init_default_vm_name_template(conf[:vm_name_template])
|
||||
Role.init_default_vr_name_template(conf[:vr_name_template])
|
||||
|
||||
ServiceTemplate.init_default_vn_name_template(conf[:vn_name_template])
|
||||
|
||||
@ -671,34 +674,44 @@ post '/service_template/:id/action' do
|
||||
body = service_json['DOCUMENT']['TEMPLATE']['BODY']
|
||||
|
||||
begin
|
||||
# Check service custom_attrs
|
||||
custom_attrs = body['custom_attrs']
|
||||
custom_attrs_values = merge_template['custom_attrs_values']
|
||||
check_custom_attrs(custom_attrs, custom_attrs_values)
|
||||
# Check service user_inputs
|
||||
user_inputs = body['user_inputs']
|
||||
user_inputs_values = merge_template['user_inputs_values']
|
||||
check_user_inptus(user_inputs, user_inputs_values)
|
||||
|
||||
# Check custom attrs in each role
|
||||
body['roles'].each do |role|
|
||||
next if role['custom_attrs'].nil?
|
||||
# Check that the JSON template_contents is valid for each role
|
||||
template_contents = role.fetch('template_contents', {})
|
||||
|
||||
unless template_contents.is_a?(Hash)
|
||||
raise 'Error validating template_contents object for the' \
|
||||
"role #{role['name']}. The object must be a valid hash."
|
||||
end
|
||||
|
||||
# Check that user inputs for each role are correct if they exist
|
||||
next if role['user_inputs'].nil?
|
||||
|
||||
roles_merge_template = merge_template['roles']
|
||||
|
||||
# merge_template must have 'role' key if role has custom attributes
|
||||
# merge_template must have 'role' key if role has user inputs
|
||||
if roles_merge_template.nil?
|
||||
raise 'The Service template specifies custom attributes for the role ' \
|
||||
raise 'The Service template specifies user inputs for the role ' \
|
||||
"#{role['name']} but no values have been found"
|
||||
end
|
||||
|
||||
if !roles_merge_template.is_a?(Array) || roles_merge_template.empty?
|
||||
raise 'The role custom attributes are empty or do not have a valid format'
|
||||
raise 'The role user inputs are empty or do not have a valid format'
|
||||
end
|
||||
|
||||
# Select role from merge_template by role name
|
||||
merge_role = roles_merge_template.find {|item| item['name'] == role['name'] }
|
||||
|
||||
role_custom_attrs = role['custom_attrs']
|
||||
role_custom_attrs_values = merge_role['custom_attrs_values']
|
||||
next unless merge_role
|
||||
|
||||
check_custom_attrs(role_custom_attrs, role_custom_attrs_values)
|
||||
role_user_inputs = role['user_inputs']
|
||||
role_user_inputs_values = merge_role['user_inputs_values']
|
||||
|
||||
check_user_inptus(role_user_inputs, role_user_inputs_values)
|
||||
end
|
||||
rescue StandardError => e
|
||||
return internal_error(e.message, VALIDATION_EC)
|
||||
|
@ -21,7 +21,7 @@ module OpenNebula
|
||||
# Service Template
|
||||
class ServiceTemplate < DocumentJSON
|
||||
|
||||
ROLE_SCHEMA = {
|
||||
VM_ROLE_SCHEMA = {
|
||||
:type => :object,
|
||||
:properties => {
|
||||
'name' => {
|
||||
@ -29,25 +29,33 @@ module OpenNebula
|
||||
:required => true,
|
||||
:regex => /^\w+$/
|
||||
},
|
||||
'type' => {
|
||||
:type => :string,
|
||||
:enum => [
|
||||
'vm'
|
||||
],
|
||||
:required => true
|
||||
},
|
||||
'cardinality' => {
|
||||
:type => :integer,
|
||||
:default => 0,
|
||||
:minimum => 0
|
||||
},
|
||||
'vm_template' => {
|
||||
'template_id' => {
|
||||
:type => :integer,
|
||||
:required => true
|
||||
},
|
||||
'vm_template_contents' => {
|
||||
:type => :string,
|
||||
:required => false
|
||||
},
|
||||
'custom_attrs' => {
|
||||
'template_contents' => {
|
||||
:type => :object,
|
||||
:properties => {},
|
||||
:required => false
|
||||
},
|
||||
'custom_attrs_values' => {
|
||||
'user_inputs' => {
|
||||
:type => :object,
|
||||
:properties => {},
|
||||
:required => false
|
||||
},
|
||||
'user_inputs_values' => {
|
||||
:type => :object,
|
||||
:properties => {},
|
||||
:required => false
|
||||
@ -173,6 +181,58 @@ module OpenNebula
|
||||
}
|
||||
}
|
||||
|
||||
VR_ROLE_SCHEMA = {
|
||||
:type => :object,
|
||||
:properties => {
|
||||
'name' => {
|
||||
:type => :string,
|
||||
:required => true,
|
||||
:regex => /^\w+$/
|
||||
},
|
||||
'type' => {
|
||||
:type => :string,
|
||||
:enum => [
|
||||
'vr'
|
||||
],
|
||||
:required => true
|
||||
},
|
||||
'template_id' => {
|
||||
:type => :integer,
|
||||
:required => true
|
||||
},
|
||||
'cardinality' => {
|
||||
:type => :integer,
|
||||
:default => 0,
|
||||
:minimum => 0
|
||||
},
|
||||
'template_contents' => {
|
||||
:type => :object,
|
||||
:properties => {},
|
||||
:required => false
|
||||
},
|
||||
'user_inputs' => {
|
||||
:type => :object,
|
||||
:properties => {},
|
||||
:required => false
|
||||
},
|
||||
'user_inputs_values' => {
|
||||
:type => :object,
|
||||
:properties => {},
|
||||
:required => false
|
||||
},
|
||||
'on_hold' => {
|
||||
:type => :boolean,
|
||||
:required => false
|
||||
},
|
||||
'parents' => {
|
||||
:type => :array,
|
||||
:items => {
|
||||
:type => :string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCHEMA = {
|
||||
:type => :object,
|
||||
:properties => {
|
||||
@ -201,15 +261,15 @@ module OpenNebula
|
||||
},
|
||||
'roles' => {
|
||||
:type => :array,
|
||||
:items => ROLE_SCHEMA,
|
||||
:items => [],
|
||||
:required => true
|
||||
},
|
||||
'custom_attrs' => {
|
||||
'user_inputs' => {
|
||||
:type => :object,
|
||||
:properties => {},
|
||||
:required => false
|
||||
},
|
||||
'custom_attrs_values' => {
|
||||
'user_inputs_values' => {
|
||||
:type => :object,
|
||||
:properties => {},
|
||||
:required => false
|
||||
@ -376,11 +436,11 @@ module OpenNebula
|
||||
|
||||
# iterate over roles to clone templates
|
||||
rc = body['roles'].each do |role|
|
||||
t_id = role['vm_template']
|
||||
t_id = role['template_id']
|
||||
|
||||
# if the template has already been cloned, just update the value
|
||||
if cloned_templates.keys.include?(t_id)
|
||||
role['vm_template'] = cloned_templates[t_id]
|
||||
role['template_id'] = cloned_templates[t_id]
|
||||
next
|
||||
end
|
||||
|
||||
@ -404,7 +464,7 @@ module OpenNebula
|
||||
# add new ID to the hash
|
||||
cloned_templates[t_id] = rc
|
||||
|
||||
role['vm_template'] = rc
|
||||
role['template_id'] = rc
|
||||
end
|
||||
|
||||
# if any error, rollback and delete the left templates
|
||||
@ -477,6 +537,10 @@ module OpenNebula
|
||||
|
||||
validator.validate!(template, SCHEMA)
|
||||
|
||||
template['roles'].each do |role|
|
||||
validate_role(role)
|
||||
end
|
||||
|
||||
validate_values(template)
|
||||
end
|
||||
|
||||
@ -487,7 +551,16 @@ module OpenNebula
|
||||
:allow_extra_properties => true
|
||||
)
|
||||
|
||||
validator.validate!(template, ROLE_SCHEMA)
|
||||
tmplt_type = template.fetch('type', 'vm')
|
||||
|
||||
case tmplt_type
|
||||
when 'vm'
|
||||
validator.validate!(template, VM_ROLE_SCHEMA)
|
||||
when 'vr'
|
||||
validator.validate!(template, VR_ROLE_SCHEMA)
|
||||
else
|
||||
raise Validator::ParseException, "Unsupported role type \"#{template['type']}\""
|
||||
end
|
||||
end
|
||||
|
||||
def instantiate(merge_template)
|
||||
@ -496,6 +569,8 @@ module OpenNebula
|
||||
if merge_template.nil?
|
||||
instantiate_template = JSON.parse(@body.to_json)
|
||||
else
|
||||
@body = handle_nested_values(@body, merge_template)
|
||||
|
||||
instantiate_template = JSON.parse(@body.to_json)
|
||||
.deep_merge(merge_template)
|
||||
end
|
||||
@ -518,11 +593,10 @@ module OpenNebula
|
||||
end
|
||||
|
||||
def self.validate_values(template)
|
||||
parser = ElasticityGrammarParser.new
|
||||
|
||||
roles = template['roles']
|
||||
|
||||
roles.each_with_index do |role, role_index|
|
||||
# General verification (applies to all roles)
|
||||
roles[role_index+1..-1].each do |other_role|
|
||||
if role['name'] == other_role['name']
|
||||
raise Validator::ParseException,
|
||||
@ -530,95 +604,16 @@ module OpenNebula
|
||||
end
|
||||
end
|
||||
|
||||
if !role['min_vms'].nil? &&
|
||||
role['min_vms'].to_i > role['cardinality'].to_i
|
||||
|
||||
# Specific values verification per role type
|
||||
case role['type']
|
||||
when 'vm'
|
||||
parser = ElasticityGrammarParser.new
|
||||
validate_vmvalues(role, parser)
|
||||
when 'vr'
|
||||
validate_vrvalues(role)
|
||||
else
|
||||
raise Validator::ParseException,
|
||||
"Role '#{role['name']}' 'cardinality' must be " \
|
||||
"greater than or equal to 'min_vms'"
|
||||
end
|
||||
|
||||
if !role['max_vms'].nil? &&
|
||||
role['max_vms'].to_i < role['cardinality'].to_i
|
||||
|
||||
raise Validator::ParseException,
|
||||
"Role '#{role['name']}' 'cardinality' must be " \
|
||||
"lower than or equal to 'max_vms'"
|
||||
end
|
||||
|
||||
if ((role['elasticity_policies'] &&
|
||||
!role['elasticity_policies'].empty?) ||
|
||||
(role['scheduled_policies'] &&
|
||||
!role['scheduled_policies'].empty?)) &&
|
||||
(role['min_vms'].nil? || role['max_vms'].nil?)
|
||||
raise Validator::ParseException,
|
||||
"Role '#{role['name']}' with " \
|
||||
" 'elasticity_policies' or " \
|
||||
"'scheduled_policies'must define both 'min_vms'" \
|
||||
" and 'max_vms'"
|
||||
end
|
||||
|
||||
if role['elasticity_policies']
|
||||
role['elasticity_policies'].each_with_index do |policy, index|
|
||||
exp = policy['expression']
|
||||
|
||||
if exp.empty?
|
||||
raise Validator::ParseException,
|
||||
"Role '#{role['name']}', elasticity policy " \
|
||||
"##{index} 'expression' cannot be empty"
|
||||
end
|
||||
|
||||
treetop = parser.parse(exp)
|
||||
next unless treetop.nil?
|
||||
|
||||
raise Validator::ParseException,
|
||||
"Role '#{role['name']}', elasticity policy " \
|
||||
"##{index} 'expression' parse error: " \
|
||||
"#{parser.failure_reason}"
|
||||
end
|
||||
end
|
||||
|
||||
next unless role['scheduled_policies']
|
||||
|
||||
role['scheduled_policies'].each_with_index do |policy, index|
|
||||
start_time = policy['start_time']
|
||||
recurrence = policy['recurrence']
|
||||
|
||||
if !start_time.nil?
|
||||
if !policy['recurrence'].nil?
|
||||
raise Validator::ParseException,
|
||||
"Role '#{role['name']}', scheduled policy "\
|
||||
"##{index} must define "\
|
||||
"'start_time' or 'recurrence', but not both"
|
||||
end
|
||||
|
||||
begin
|
||||
next if start_time.match(/^\d+$/)
|
||||
|
||||
Time.parse(start_time)
|
||||
rescue ArgumentError
|
||||
raise Validator::ParseException,
|
||||
"Role '#{role['name']}', scheduled policy " \
|
||||
"##{index} 'start_time' is not a valid " \
|
||||
'Time. Try with YYYY-MM-DD hh:mm:ss or ' \
|
||||
'0YYY-MM-DDThh:mm:ssZ'
|
||||
end
|
||||
elsif !recurrence.nil?
|
||||
begin
|
||||
cron_parser = CronParser.new(recurrence)
|
||||
cron_parser.next
|
||||
rescue StandardError
|
||||
raise Validator::ParseException,
|
||||
"Role '#{role['name']}', scheduled policy " \
|
||||
"##{index} 'recurrence' is not a valid " \
|
||||
'cron expression'
|
||||
end
|
||||
else
|
||||
raise Validator::ParseException,
|
||||
"Role '#{role['name']}', scheduled policy #" \
|
||||
"#{index} needs to define either " \
|
||||
"'start_time' or 'recurrence'"
|
||||
end
|
||||
"Unsupported role type \"#{template['type']}\""
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -634,13 +629,145 @@ module OpenNebula
|
||||
ret = []
|
||||
|
||||
@body['roles'].each do |role|
|
||||
t_id = Integer(role['vm_template'])
|
||||
t_id = Integer(role['template_id'])
|
||||
ret << t_id unless ret.include?(t_id)
|
||||
end
|
||||
|
||||
ret
|
||||
end
|
||||
|
||||
def self.validate_vrvalues(vrrole)
|
||||
nic_array = vrrole.dig('template_contents', 'NIC')
|
||||
cardinality = vrrole['cardinality']
|
||||
|
||||
return if nic_array.nil? || !nic_array.is_a?(Array)
|
||||
|
||||
contains_floating_key = nic_array.any? do |nic|
|
||||
nic.keys.any? do |key|
|
||||
key.to_s.start_with?('FLOATING')
|
||||
end
|
||||
end
|
||||
|
||||
return unless cardinality > 1 && !contains_floating_key
|
||||
|
||||
raise(
|
||||
Validator::ParseException,
|
||||
"Role '#{vrrole['name']}' with 'cardinality' greather " \
|
||||
'than one must define a floating IP'
|
||||
)
|
||||
end
|
||||
|
||||
def self.validate_vmvalues(vmrole, parser)
|
||||
if !vmrole['min_vms'].nil? &&
|
||||
vmrole['min_vms'].to_i > vmrole['cardinality'].to_i
|
||||
|
||||
raise Validator::ParseException,
|
||||
"Role '#{vmrole['name']}' 'cardinality' must be " \
|
||||
"greater than or equal to 'min_vms'"
|
||||
end
|
||||
|
||||
if !vmrole['max_vms'].nil? &&
|
||||
vmrole['max_vms'].to_i < vmrole['cardinality'].to_i
|
||||
|
||||
raise Validator::ParseException,
|
||||
"Role '#{vmrole['name']}' 'cardinality' must be " \
|
||||
"lower than or equal to 'max_vms'"
|
||||
end
|
||||
|
||||
if ((vmrole['elasticity_policies'] &&
|
||||
!vmrole['elasticity_policies'].empty?) ||
|
||||
(vmrole['scheduled_policies'] &&
|
||||
!vmrole['scheduled_policies'].empty?)) &&
|
||||
(vmrole['min_vms'].nil? || vmrole['max_vms'].nil?)
|
||||
raise Validator::ParseException,
|
||||
"Role '#{vmrole['name']}' with " \
|
||||
" 'elasticity_policies' or " \
|
||||
"'scheduled_policies'must define both 'min_vms'" \
|
||||
" and 'max_vms'"
|
||||
end
|
||||
|
||||
if vmrole['elasticity_policies']
|
||||
vmrole['elasticity_policies'].each_with_index do |policy, index|
|
||||
exp = policy['expression']
|
||||
|
||||
if exp.empty?
|
||||
raise Validator::ParseException,
|
||||
"Role '#{vmrole['name']}', elasticity policy " \
|
||||
"##{index} 'expression' cannot be empty"
|
||||
end
|
||||
|
||||
treetop = parser.parse(exp)
|
||||
next unless treetop.nil?
|
||||
|
||||
raise Validator::ParseException,
|
||||
"Role '#{vmrole['name']}', elasticity policy " \
|
||||
"##{index} 'expression' parse error: " \
|
||||
"#{parser.failure_reason}"
|
||||
end
|
||||
end
|
||||
|
||||
return unless vmrole['scheduled_policies']
|
||||
|
||||
vmrole['scheduled_policies'].each_with_index do |policy, index|
|
||||
start_time = policy['start_time']
|
||||
recurrence = policy['recurrence']
|
||||
|
||||
if !start_time.nil?
|
||||
if !policy['recurrence'].nil?
|
||||
raise Validator::ParseException,
|
||||
"Role '#{vmrole['name']}', scheduled policy "\
|
||||
"##{index} must define "\
|
||||
"'start_time' or 'recurrence', but not both"
|
||||
end
|
||||
|
||||
begin
|
||||
next if start_time.match(/^\d+$/)
|
||||
|
||||
Time.parse(start_time)
|
||||
rescue ArgumentError
|
||||
raise Validator::ParseException,
|
||||
"Role '#{vmrole['name']}', scheduled policy " \
|
||||
"##{index} 'start_time' is not a valid " \
|
||||
'Time. Try with YYYY-MM-DD hh:mm:ss or ' \
|
||||
'0YYY-MM-DDThh:mm:ssZ'
|
||||
end
|
||||
elsif !recurrence.nil?
|
||||
begin
|
||||
cron_parser = CronParser.new(recurrence)
|
||||
cron_parser.next
|
||||
rescue StandardError
|
||||
raise Validator::ParseException,
|
||||
"Role '#{vmrole['name']}', scheduled policy " \
|
||||
"##{index} 'recurrence' is not a valid " \
|
||||
'cron expression'
|
||||
end
|
||||
else
|
||||
raise Validator::ParseException,
|
||||
"Role '#{vmrole['name']}', scheduled policy #" \
|
||||
"#{index} needs to define either " \
|
||||
"'start_time' or 'recurrence'"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_nested_values(template, extra_template)
|
||||
roles = template['roles']
|
||||
extra_roles = extra_template.fetch('roles', [])
|
||||
|
||||
return template if extra_roles.empty?
|
||||
|
||||
roles.each_with_index do |role, index|
|
||||
extra_role = extra_roles.find {|item| item['name'] == role['name'] }
|
||||
next unless extra_role
|
||||
|
||||
roles[index] = role.deep_merge(extra_role)
|
||||
end
|
||||
|
||||
extra_template.delete('roles')
|
||||
|
||||
template
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
|
@ -16,7 +16,9 @@
|
||||
|
||||
require 'uri'
|
||||
|
||||
# Overwriting hash class with new methods
|
||||
class Hash
|
||||
|
||||
# Returns a new hash containing the contents of other_hash and the
|
||||
# contents of self. If the value for entries with duplicate keys
|
||||
# is a Hash, it will be merged recursively, otherwise it will be that
|
||||
@ -35,344 +37,390 @@ class Hash
|
||||
target = dup
|
||||
|
||||
other_hash.each do |hash_key, hash_value|
|
||||
if hash_value.is_a?(Hash) and self[hash_key].is_a?(Hash)
|
||||
if hash_value.is_a?(Hash) && self[hash_key].is_a?(Hash)
|
||||
target[hash_key] = self[hash_key].deep_merge(hash_value)
|
||||
elsif hash_value.is_a?(Array) and self[hash_key].is_a?(Array)
|
||||
hash_value.each_with_index { |elem, i|
|
||||
if self[hash_key][i].is_a?(Hash) and elem.is_a?(Hash)
|
||||
target[hash_key][i] = self[hash_key][i].deep_merge(elem)
|
||||
elsif hash_value.is_a?(Array) && self[hash_key].is_a?(Array)
|
||||
hash_value.each_with_index do |elem, i|
|
||||
if self[hash_key][i].is_a?(Hash) && elem.is_a?(Hash)
|
||||
target[hash_key] = self[hash_key] + hash_value
|
||||
else
|
||||
target[hash_key] = hash_value
|
||||
end
|
||||
}
|
||||
end
|
||||
else
|
||||
target[hash_key] = hash_value
|
||||
end
|
||||
end
|
||||
|
||||
target
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
|
||||
# Converts a hash to a raw String in the form KEY = VAL
|
||||
#
|
||||
# @param template [String] Hash content
|
||||
#
|
||||
# @return [Hash, OpenNebula::Error] String representation in the form KEY = VALUE of
|
||||
# the hash, or an OpenNebula Error if the conversion fails
|
||||
def to_raw(content_hash)
|
||||
return '' if content_hash.nil? || content_hash.empty?
|
||||
|
||||
content = ''
|
||||
content_hash.each do |key, value|
|
||||
case value
|
||||
when Hash
|
||||
sub_content = to_raw(value)
|
||||
|
||||
content += "#{key} = [\n"
|
||||
content_lines = sub_content.split("\n")
|
||||
|
||||
content_lines.each_with_index do |line, index|
|
||||
content += line.to_s
|
||||
content += ",\n" unless index == content_lines.size - 1
|
||||
end
|
||||
|
||||
content += "\n]\n"
|
||||
when Array
|
||||
value.each do |element|
|
||||
content += to_raw({ key.to_s => element })
|
||||
end
|
||||
else
|
||||
content += "#{key} = \"#{value}\"\n"
|
||||
end
|
||||
end
|
||||
|
||||
content
|
||||
rescue StandardError => e
|
||||
return OpenNebula::Error.new("Error wrapping the hash: #{e.message}")
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
module Validator
|
||||
|
||||
class ParseException < StandardError; end
|
||||
class SchemaException < StandardError; end
|
||||
class ParseException < StandardError; end
|
||||
class SchemaException < StandardError; end
|
||||
|
||||
class Validator
|
||||
# The Validator class is used to validate a JSON body based on a schema
|
||||
# which is a Hash that describes the structure of the body.
|
||||
class Validator
|
||||
|
||||
# @param [Hash] opts the options to validate a body
|
||||
# @option opts [Boolean] :default_values Set default values if the schema
|
||||
# specifies it (if true)
|
||||
# @option opts [Boolean] :delete_extra_properties If the body contains properties
|
||||
# not specified in the schema delete them from the body (if true)
|
||||
# or raise an exception (if false)
|
||||
# @option opts [Boolean] :allow_extra_properties Allow properties
|
||||
# not specified in the schema
|
||||
def initialize(opts={})
|
||||
@opts = {
|
||||
:default_values => true,
|
||||
:delete_extra_properties => false,
|
||||
:allow_extra_properties => false
|
||||
}.merge(opts)
|
||||
end
|
||||
|
||||
# Recursively validate and modify a JSON body based on a schema.
|
||||
#
|
||||
# @see http://tools.ietf.org/html/draft-zyp-json-schema-03
|
||||
#
|
||||
# @param [Hash, Array, String, nil] body JSON represented as Ruby objects
|
||||
# @param [Hash] schema that will be used to validate
|
||||
# @param [String] key of the body that will be validated in this step
|
||||
#
|
||||
# @return [Hash, Array, String, nil] The modified body
|
||||
#
|
||||
# @raise [SchemaException] If the schema is not correctly defined
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate a User
|
||||
# schema = {
|
||||
# :type => :object,
|
||||
# :properties => {
|
||||
# 'username' => {
|
||||
# :type => :string
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# hash = {
|
||||
# 'username' => 'pepe'
|
||||
# }
|
||||
#
|
||||
# Validator.validate!(hash, schema)
|
||||
# #=> {'username' => 'pepe'}
|
||||
#
|
||||
# @note The parameter body will be modified
|
||||
# @note Schema options supported
|
||||
# :extends
|
||||
# :type => [:object, :array, :string, :null]
|
||||
#
|
||||
def validate!(body, schema, key="")
|
||||
if schema[:extends]
|
||||
base_schema = schema.delete(:extends)
|
||||
schema = base_schema.deep_merge(schema)
|
||||
# @param [Hash] opts the options to validate a body
|
||||
# @option opts [Boolean] :default_values Set default values if the schema
|
||||
# specifies it (if true)
|
||||
# @option opts [Boolean] :delete_extra_properties If the body contains properties
|
||||
# not specified in the schema delete them from the body (if true)
|
||||
# or raise an exception (if false)
|
||||
# @option opts [Boolean] :allow_extra_properties Allow properties
|
||||
# not specified in the schema
|
||||
def initialize(opts = {})
|
||||
@opts = {
|
||||
:default_values => true,
|
||||
:delete_extra_properties => false,
|
||||
:allow_extra_properties => false
|
||||
}.merge(opts)
|
||||
end
|
||||
|
||||
case schema[:type]
|
||||
when :object then validate_object(body, schema, key)
|
||||
when :array then validate_array(body, schema, key)
|
||||
when :string then validate_string(body, schema, key)
|
||||
when :integer then validate_integer(body, schema, key)
|
||||
when :null then validate_null(body, schema, key)
|
||||
when :boolean then validate_boolean(body, schema, key)
|
||||
else raise SchemaException, "type #{schema[:type]} is not a valid type"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Validate an object type
|
||||
#
|
||||
# @param [Hash] body to be validated
|
||||
# @param [Hash] schema_object of the objectto validate the body
|
||||
# @param [String] key of the body that will be validated in this step
|
||||
#
|
||||
# @return [Hash] The modified body
|
||||
#
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate with default values
|
||||
# schema_body = {
|
||||
# :type => :object,
|
||||
# :properties => {
|
||||
# 'username' => {
|
||||
# :type => :string,
|
||||
# :default => 'def'
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# body = {}
|
||||
#
|
||||
# Validator.validate_object(body, schema_body)
|
||||
# #=> {'username' => 'def'}
|
||||
#
|
||||
# @note The parameter body will be modified
|
||||
# @note Schema options supported
|
||||
# :properties
|
||||
# :required
|
||||
# :default
|
||||
#
|
||||
def validate_object(body, schema_object, key)
|
||||
unless body.is_a?(Hash)
|
||||
raise ParseException, "KEY: #{key} must be a Hash; SCHEMA:"
|
||||
end
|
||||
|
||||
new_body = body.dup
|
||||
|
||||
schema_object[:properties].each{ |schema_key, schema_value|
|
||||
body_value = new_body.delete(schema_key)
|
||||
|
||||
if body_value
|
||||
body[schema_key] = validate!(body_value, schema_value,
|
||||
schema_key)
|
||||
else
|
||||
if schema_value[:required]
|
||||
raise ParseException, "KEY: '#{schema_key}' is required;"
|
||||
end
|
||||
|
||||
if @opts[:default_values] && schema_value[:default]
|
||||
body[schema_key] = schema_value[:default]
|
||||
end
|
||||
# Recursively validate and modify a JSON body based on a schema.
|
||||
#
|
||||
# @see http://tools.ietf.org/html/draft-zyp-json-schema-03
|
||||
#
|
||||
# @param [Hash, Array, String, nil] body JSON represented as Ruby objects
|
||||
# @param [Hash] schema that will be used to validate
|
||||
# @param [String] key of the body that will be validated in this step
|
||||
#
|
||||
# @return [Hash, Array, String, nil] The modified body
|
||||
#
|
||||
# @raise [SchemaException] If the schema is not correctly defined
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate a User
|
||||
# schema = {
|
||||
# :type => :object,
|
||||
# :properties => {
|
||||
# 'username' => {
|
||||
# :type => :string
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# hash = {
|
||||
# 'username' => 'pepe'
|
||||
# }
|
||||
#
|
||||
# Validator.validate!(hash, schema)
|
||||
# #=> {'username' => 'pepe'}
|
||||
#
|
||||
# @note The parameter body will be modified
|
||||
# @note Schema options supported
|
||||
# :extends
|
||||
# :type => [:object, :array, :string, :null]
|
||||
#
|
||||
def validate!(body, schema, key = '')
|
||||
if schema[:extends]
|
||||
base_schema = schema.delete(:extends)
|
||||
schema = base_schema.deep_merge(schema)
|
||||
end
|
||||
}
|
||||
|
||||
# raise error if body.keys is not empty
|
||||
unless new_body.keys.empty?
|
||||
if @opts[:delete_extra_properties]
|
||||
new_body.keys.each{ |key|
|
||||
body.delete(key)
|
||||
}
|
||||
else
|
||||
if @opts[:allow_extra_properties]
|
||||
return body
|
||||
case schema[:type]
|
||||
when :object then validate_object(body, schema, key)
|
||||
when :array then validate_array(body, schema, key)
|
||||
when :string then validate_string(body, schema, key)
|
||||
when :integer then validate_integer(body, schema, key)
|
||||
when :null then validate_null(body, schema, key)
|
||||
when :boolean then validate_boolean(body, schema, key)
|
||||
else raise SchemaException, "type #{schema[:type]} is not a valid type"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Validate an object type
|
||||
#
|
||||
# @param [Hash] body to be validated
|
||||
# @param [Hash] schema_object of the objectto validate the body
|
||||
# @param [String] key of the body that will be validated in this step
|
||||
#
|
||||
# @return [Hash] The modified body
|
||||
#
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate with default values
|
||||
# schema_body = {
|
||||
# :type => :object,
|
||||
# :properties => {
|
||||
# 'username' => {
|
||||
# :type => :string,
|
||||
# :default => 'def'
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# body = {}
|
||||
#
|
||||
# Validator.validate_object(body, schema_body)
|
||||
# #=> {'username' => 'def'}
|
||||
#
|
||||
# @note The parameter body will be modified
|
||||
# @note Schema options supported
|
||||
# :properties
|
||||
# :required
|
||||
# :default
|
||||
#
|
||||
def validate_object(body, schema_object, key)
|
||||
unless body.is_a?(Hash)
|
||||
raise ParseException, "KEY: #{key} must be a Hash; SCHEMA:"
|
||||
end
|
||||
|
||||
return body if schema_object[:properties].empty?
|
||||
|
||||
new_body = body.dup
|
||||
|
||||
schema_object[:properties].each do |schema_key, schema_value|
|
||||
body_value = new_body.delete(schema_key)
|
||||
|
||||
if body_value
|
||||
body[schema_key] = validate!(body_value, schema_value, schema_key)
|
||||
else
|
||||
raise ParseException, "KEY: #{new_body.keys.join(', ')} not"\
|
||||
" allowed;"
|
||||
if schema_value[:required]
|
||||
raise ParseException, "KEY: '#{schema_key}' is required;"
|
||||
end
|
||||
|
||||
if @opts[:default_values] && schema_value[:default]
|
||||
body[schema_key] = schema_value[:default]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# raise error if body.keys is not empty
|
||||
unless new_body.keys.empty?
|
||||
if @opts[:delete_extra_properties]
|
||||
new_body.keys.each {|key| body.delete(key) }
|
||||
else
|
||||
return body if @opts[:allow_extra_properties]
|
||||
|
||||
raise ParseException, "KEY: #{new_body.keys.join(', ')} not allowed;"
|
||||
end
|
||||
end
|
||||
|
||||
body
|
||||
end
|
||||
|
||||
body
|
||||
end
|
||||
# Validate an array type
|
||||
#
|
||||
# @param [Array] body to be validated
|
||||
# @param [Hash] schema_array of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [Hash] The modified body
|
||||
#
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :array,
|
||||
# :items => {
|
||||
# :type => :string
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# body = ['pepe', 'luis', 'juan']
|
||||
#
|
||||
# Validator.validate_array(body, schema)
|
||||
# #=> 'username' => ['pepe', 'luis', 'juan']
|
||||
#
|
||||
# @note The parameter body will be modified
|
||||
# @note Schema options supported
|
||||
# :items
|
||||
#
|
||||
def validate_array(body, schema_array, schema_key)
|
||||
unless body.instance_of?(Array)
|
||||
raise ParseException, "KEY: '#{schema_key}' must be an Array;"
|
||||
end
|
||||
|
||||
# Validate an array type
|
||||
#
|
||||
# @param [Array] body to be validated
|
||||
# @param [Hash] schema_array of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [Hash] The modified body
|
||||
#
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :array,
|
||||
# :items => {
|
||||
# :type => :string
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# body = ['pepe', 'luis', 'juan']
|
||||
#
|
||||
# Validator.validate_array(body, schema)
|
||||
# #=> 'username' => ['pepe', 'luis', 'juan']
|
||||
#
|
||||
# @note The parameter body will be modified
|
||||
# @note Schema options supported
|
||||
# :items
|
||||
#
|
||||
def validate_array(body, schema_array, schema_key)
|
||||
if body.instance_of?(Array)
|
||||
body.collect { |body_item|
|
||||
return body if schema_array[:items].empty?
|
||||
|
||||
body.collect do |body_item|
|
||||
validate!(body_item, schema_array[:items], schema_key)
|
||||
}
|
||||
else
|
||||
raise ParseException, "KEY: '#{schema_key}' must be an Array;"
|
||||
end
|
||||
end
|
||||
|
||||
# Validate an integer type
|
||||
#
|
||||
# @param [Array] body to be validated
|
||||
# @param [Hash] schema_array of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [Hash] The modified body
|
||||
#
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :integer
|
||||
# }
|
||||
#
|
||||
# body = 5
|
||||
#
|
||||
# Validator.validate_integer(body, schema)
|
||||
# #=> 5
|
||||
#
|
||||
#
|
||||
def validate_integer(body, schema_array, schema_key)
|
||||
value = Integer(body)
|
||||
|
||||
if schema_array[:maximum]
|
||||
excl = schema_array[:exclusiveMaximum]
|
||||
max = schema_array[:maximum]
|
||||
if !(excl ? value < max : value <= max)
|
||||
raise ParseException, "KEY: '#{schema_key}' must be "\
|
||||
"lower than #{excl ? '' : 'or equal to'} #{max};"
|
||||
end
|
||||
end
|
||||
|
||||
if schema_array[:minimum]
|
||||
excl = schema_array[:exclusiveMinimum]
|
||||
min = schema_array[:minimum]
|
||||
if !(excl ? value > min : value >= min)
|
||||
raise ParseException, "KEY: '#{schema_key}' must be "\
|
||||
"greater than #{excl ? '' : 'or equal to'} #{min};"
|
||||
# Validate an integer type
|
||||
#
|
||||
# @param [Array] body to be validated
|
||||
# @param [Hash] schema_array of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [Hash] The modified body
|
||||
#
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :integer
|
||||
# }
|
||||
#
|
||||
# body = 5
|
||||
#
|
||||
# Validator.validate_integer(body, schema)
|
||||
# #=> 5
|
||||
#
|
||||
#
|
||||
def validate_integer(body, schema_array, schema_key)
|
||||
value = Integer(body)
|
||||
|
||||
if schema_array[:maximum]
|
||||
excl = schema_array[:exclusiveMaximum]
|
||||
max = schema_array[:maximum]
|
||||
if !(excl ? value < max : value <= max)
|
||||
raise ParseException, "KEY: '#{schema_key}' must be "\
|
||||
"lower than #{excl ? '' : 'or equal to'} #{max};"
|
||||
end
|
||||
end
|
||||
|
||||
if schema_array[:minimum]
|
||||
excl = schema_array[:exclusiveMinimum]
|
||||
min = schema_array[:minimum]
|
||||
if !(excl ? value > min : value >= min)
|
||||
raise ParseException, "KEY: '#{schema_key}' must be "\
|
||||
"greater than #{excl ? '' : 'or equal to'} #{min};"
|
||||
end
|
||||
end
|
||||
|
||||
value
|
||||
rescue ArgumentError
|
||||
raise ParseException, "KEY: '#{schema_key}' must be an Integer;"
|
||||
end
|
||||
|
||||
value
|
||||
rescue ArgumentError
|
||||
raise ParseException, "KEY: '#{schema_key}' must be an Integer;"
|
||||
end
|
||||
# Validate an null type
|
||||
#
|
||||
# @param [nil] body to be validated
|
||||
# @param [Hash] schema_null of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [nil]
|
||||
#
|
||||
# @raise [ParseException] if the body is not nil
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :null
|
||||
# }
|
||||
#
|
||||
# body = nil
|
||||
#
|
||||
# Validator.validate_null(body, schema)
|
||||
# #=> nil
|
||||
#
|
||||
#
|
||||
def validate_null(body, schema_null, schema_key)
|
||||
return if body.nil?
|
||||
|
||||
# Validate an null type
|
||||
#
|
||||
# @param [nil] body to be validated
|
||||
# @param [Hash] schema_null of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [nil]
|
||||
#
|
||||
# @raise [ParseException] if the body is not nil
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :null
|
||||
# }
|
||||
#
|
||||
# body = nil
|
||||
#
|
||||
# Validator.validate_null(body, schema)
|
||||
# #=> nil
|
||||
#
|
||||
#
|
||||
def validate_null(body, schema_null, schema_key)
|
||||
if body != nil
|
||||
raise ParseException, "KEY: '#{schema_key}' is not allowed;"
|
||||
end
|
||||
end
|
||||
|
||||
# Validate an boolean type
|
||||
#
|
||||
# @param [Object] body to be validated
|
||||
# @param [Hash] schema_boolean of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [nil]
|
||||
#
|
||||
# @raise [ParseException] if the body is not a boolean
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :boolean
|
||||
# }
|
||||
#
|
||||
# body = true
|
||||
#
|
||||
# Validator.validate_boolean(body, schema)
|
||||
# #=> nil
|
||||
#
|
||||
#
|
||||
def validate_boolean(body, schema_boolean, schema_key)
|
||||
if body != true && body != false
|
||||
raise ParseException, "KEY: '#{schema_key}' is not allowed;"
|
||||
raise ParseException, "KEY: '#{schema_key}' is not allowed in #{schema_null};"
|
||||
end
|
||||
|
||||
body
|
||||
end
|
||||
# Validate an boolean type
|
||||
#
|
||||
# @param [Object] body to be validated
|
||||
# @param [Hash] schema_boolean of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [nil]
|
||||
#
|
||||
# @raise [ParseException] if the body is not a boolean
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :boolean
|
||||
# }
|
||||
#
|
||||
# body = true
|
||||
#
|
||||
# Validator.validate_boolean(body, schema)
|
||||
# #=> nil
|
||||
#
|
||||
#
|
||||
def validate_boolean(body, schema_boolean, schema_key)
|
||||
if body != true && body != false
|
||||
raise ParseException, "KEY: '#{schema_key}' is not allowed in #{schema_boolean};"
|
||||
end
|
||||
|
||||
body
|
||||
end
|
||||
|
||||
# Validate an string type
|
||||
#
|
||||
# @param [String] body to be validated
|
||||
# @param [Hash] schema_string of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [String] The modified body
|
||||
#
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :string
|
||||
# }
|
||||
#
|
||||
# body = "pepe"
|
||||
#
|
||||
# Validator.validate_string(body, schema)
|
||||
# #=> "pepe"
|
||||
#
|
||||
# @note The parameter body will be modified
|
||||
# @note Schema options supported
|
||||
# :format
|
||||
# :enum
|
||||
# :regex
|
||||
#
|
||||
def validate_string(body, schema_string, schema_key)
|
||||
unless body.instance_of?(String)
|
||||
raise ParseException, "KEY: '#{schema_key}' must be a String;"
|
||||
end
|
||||
|
||||
# Validate an string type
|
||||
#
|
||||
# @param [String] body to be validated
|
||||
# @param [Hash] schema_string of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [String] The modified body
|
||||
#
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :string
|
||||
# }
|
||||
#
|
||||
# body = "pepe"
|
||||
#
|
||||
# Validator.validate_string(body, schema)
|
||||
# #=> "pepe"
|
||||
#
|
||||
# @note The parameter body will be modified
|
||||
# @note Schema options supported
|
||||
# :format
|
||||
# :enum
|
||||
# :regex
|
||||
#
|
||||
def validate_string(body, schema_string, schema_key)
|
||||
if body.instance_of?(String)
|
||||
if schema_string[:format]
|
||||
check_format(body, schema_string, schema_key)
|
||||
elsif schema_string[:enum]
|
||||
@ -382,118 +430,119 @@ class Validator
|
||||
else
|
||||
body
|
||||
end
|
||||
else
|
||||
raise ParseException, "KEY: '#{schema_key}' must be a String;"
|
||||
end
|
||||
end
|
||||
|
||||
# Validate an string format
|
||||
#
|
||||
# @param [String] body_value to be validated
|
||||
# @param [Hash] schema_string of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [String] The modified body
|
||||
#
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :string,
|
||||
# :format => :url
|
||||
# }
|
||||
#
|
||||
# body = "http://localhost:4567"
|
||||
#
|
||||
# Validator.check_format(body, schema)
|
||||
# #=> "http://localhost:4567"
|
||||
#
|
||||
# @note The parameter body will be modified
|
||||
# @note Schema options supported
|
||||
# :url
|
||||
#
|
||||
def check_format(body_value, schema_string, schema_key)
|
||||
case schema_string[:format]
|
||||
when :uri
|
||||
begin
|
||||
require 'uri'
|
||||
uri = URI.parse(body_value)
|
||||
rescue
|
||||
raise ParseException, "KEY: '#{schema_key}' must be a valid URL;"
|
||||
# Validate an string format
|
||||
#
|
||||
# @param [String] body_value to be validated
|
||||
# @param [Hash] schema_string of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [String] The modified body
|
||||
#
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :string,
|
||||
# :format => :url
|
||||
# }
|
||||
#
|
||||
# body = "http://localhost:4567"
|
||||
#
|
||||
# Validator.check_format(body, schema)
|
||||
# #=> "http://localhost:4567"
|
||||
#
|
||||
# @note The parameter body will be modified
|
||||
# @note Schema options supported
|
||||
# :url
|
||||
#
|
||||
def check_format(body_value, schema_string, schema_key)
|
||||
case schema_string[:format]
|
||||
when :uri
|
||||
begin
|
||||
require 'uri'
|
||||
URI.parse(body_value)
|
||||
rescue URI::InvalidURIError
|
||||
raise ParseException, "KEY: '#{schema_key}' must be a valid URL;"
|
||||
end
|
||||
|
||||
body_value
|
||||
end
|
||||
|
||||
body_value
|
||||
end
|
||||
|
||||
body_value
|
||||
end
|
||||
|
||||
# Validate an string enum
|
||||
#
|
||||
# @param [String] body_value to be validated
|
||||
# @param [Hash] schema_string of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [String] The modified body
|
||||
#
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :string,
|
||||
# :enum => ['juan', 'luis']
|
||||
# }
|
||||
#
|
||||
# body = "juan"
|
||||
#
|
||||
# Validator.check_enum(body, schema)
|
||||
# #=> "juan"
|
||||
#
|
||||
# @note The parameter body will be modified
|
||||
# @note Schema options supported
|
||||
# :enum
|
||||
#
|
||||
def check_enum(body_value, schema_string, schema_key)
|
||||
if schema_string[:enum].include?(body_value)
|
||||
body_value
|
||||
else
|
||||
raise ParseException, "KEY: '#{schema_key}' must be one of"\
|
||||
" #{schema_string[:enum].join(', ')};"
|
||||
# Validate an string enum
|
||||
#
|
||||
# @param [String] body_value to be validated
|
||||
# @param [Hash] schema_string of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [String] The modified body
|
||||
#
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :string,
|
||||
# :enum => ['juan', 'luis']
|
||||
# }
|
||||
#
|
||||
# body = "juan"
|
||||
#
|
||||
# Validator.check_enum(body, schema)
|
||||
# #=> "juan"
|
||||
#
|
||||
# @note The parameter body will be modified
|
||||
# @note Schema options supported
|
||||
# :enum
|
||||
#
|
||||
def check_enum(body_value, schema_string, schema_key)
|
||||
if schema_string[:enum].include?(body_value)
|
||||
body_value
|
||||
else
|
||||
raise ParseException, "KEY: '#{schema_key}' must be one of"\
|
||||
" #{schema_string[:enum].join(', ')};"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Validate an string regex
|
||||
#
|
||||
# @param [String] body_value to be validated
|
||||
# @param [Hash] schema_string of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [String] The modified body
|
||||
#
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :string,
|
||||
# :regex => /^\w+$/
|
||||
# }
|
||||
#
|
||||
# body = "juan"
|
||||
#
|
||||
# Validator.check_regex(body, schema)
|
||||
# #=> "juan"
|
||||
#
|
||||
# @note The parameter body will be modified
|
||||
# @note Schema options supported
|
||||
# :enum
|
||||
#
|
||||
def check_regex(body_value, schema_string, schema_key)
|
||||
if schema_string[:regex] =~ body_value
|
||||
body_value
|
||||
else
|
||||
raise ParseException, "KEY: '#{schema_key}' must match regexp #{schema_string[:regex].inspect};"
|
||||
# Validate an string regex
|
||||
#
|
||||
# @param [String] body_value to be validated
|
||||
# @param [Hash] schema_string of the object to validate the body
|
||||
# @param [String] schema_key of the body that will be validated in this step
|
||||
#
|
||||
# @return [String] The modified body
|
||||
#
|
||||
# @raise [ParseException] if the body does not meet the schema definition
|
||||
#
|
||||
# @example Validate array
|
||||
# schema = {
|
||||
# :type => :string,
|
||||
# :regex => /^\w+$/
|
||||
# }
|
||||
#
|
||||
# body = "juan"
|
||||
#
|
||||
# Validator.check_regex(body, schema)
|
||||
# #=> "juan"
|
||||
#
|
||||
# @note The parameter body will be modified
|
||||
# @note Schema options supported
|
||||
# :enum
|
||||
#
|
||||
def check_regex(body_value, schema_string, schema_key)
|
||||
if schema_string[:regex] =~ body_value
|
||||
body_value
|
||||
else
|
||||
raise(
|
||||
ParseException,
|
||||
"KEY: '#{schema_key}' must match regexp #{schema_string[:regex].inspect};"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
Loading…
x
Reference in New Issue
Block a user