diff --git a/install.sh b/install.sh index a0d56ba7af..5aefa6bb3f 100755 --- a/install.sh +++ b/install.sh @@ -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" #----------------------------------------------------------------------------- diff --git a/src/cli/one_helper/oneflowtemplate_helper.rb b/src/cli/one_helper/oneflowtemplate_helper.rb index d68b09e341..e3a53ea37d 100644 --- a/src/cli/one_helper/oneflowtemplate_helper.rb +++ b/src/cli/one_helper/oneflowtemplate_helper.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 diff --git a/src/cli/oneflow-template b/src/cli/oneflow-template index 6da6862c1f..ed83657419 100755 --- a/src/cli/oneflow-template +++ b/src/cli/oneflow-template @@ -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']) diff --git a/src/fireedge/src/modules/components/Cards/AddressRangeCard.js b/src/fireedge/src/modules/components/Cards/AddressRangeCard.js index ab95274ac4..00564e534b 100644 --- a/src/fireedge/src/modules/components/Cards/AddressRangeCard.js +++ b/src/fireedge/src/modules/components/Cards/AddressRangeCard.js @@ -98,14 +98,7 @@ const AddressRangeCard = memo( ].filter(Boolean) return ( - +
diff --git a/src/fireedge/src/modules/components/Cards/SecurityGroupCard.js b/src/fireedge/src/modules/components/Cards/SecurityGroupCard.js index f75f40b022..cbdc34319c 100644 --- a/src/fireedge/src/modules/components/Cards/SecurityGroupCard.js +++ b/src/fireedge/src/modules/components/Cards/SecurityGroupCard.js @@ -96,7 +96,7 @@ const SecurityGroupCard = memo( ) return ( -
+
diff --git a/src/fireedge/src/modules/components/FormControl/AutocompleteController.js b/src/fireedge/src/modules/components/FormControl/AutocompleteController.js index fdc8802a43..d979e12b4a 100644 --- a/src/fireedge/src/modules/components/FormControl/AutocompleteController.js +++ b/src/fireedge/src/modules/components/FormControl/AutocompleteController.js @@ -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 ( { - 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, diff --git a/src/fireedge/src/modules/components/FormStepper/Stepper.js b/src/fireedge/src/modules/components/FormStepper/Stepper.js index 6f75bdfc16..7276de83e8 100644 --- a/src/fireedge/src/modules/components/FormStepper/Stepper.js +++ b/src/fireedge/src/modules/components/FormStepper/Stepper.js @@ -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 && ( - // - - } - label={} - labelPlacement="end" - /> - ) - // - } + {enableShowMandatoryOnly && ( + + } + label={} + labelPlacement="end" + /> + )} diff --git a/src/fireedge/src/modules/components/FormStepper/index.js b/src/fireedge/src/modules/components/FormStepper/index.js index 07987b1904..1504460013 100644 --- a/src/fireedge/src/modules/components/FormStepper/index.js +++ b/src/fireedge/src/modules/components/FormStepper/index.js @@ -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) } } diff --git a/src/fireedge/src/modules/components/Forms/Commons/VNetwork/Tabs/configuration/index.js b/src/fireedge/src/modules/components/Forms/Commons/VNetwork/Tabs/configuration/index.js index e4b3ad3f42..76192ea320 100644 --- a/src/fireedge/src/modules/components/Forms/Commons/VNetwork/Tabs/configuration/index.js +++ b/src/fireedge/src/modules/components/Forms/Commons/VNetwork/Tabs/configuration/index.js @@ -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 = ( - <> - - - ) +const ConfigurationContent = (stepId) => { + const ConfigurationComponent = ({ + 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 } /** diff --git a/src/fireedge/src/modules/components/Forms/FormWithSchema.js b/src/fireedge/src/modules/components/Forms/FormWithSchema.js index 03c7dc3391..acbe5d7505 100644 --- a/src/fireedge/src/modules/components/Forms/FormWithSchema.js +++ b/src/fireedge/src/modules/components/Forms/FormWithSchema.js @@ -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] && ( - - {createElement(INPUT_CONTROLLER[type], { - key, - control: formContext.control, - cy: dataCy, - dependencies: nameOfDependField, - name: inputName, - type: htmlType === false ? undefined : htmlType, - dependOf, - onConditionChange: handleConditionChange, - ...fieldProps, - })} - - ) - ) + function* generateInputs() { + for (let i = 0; i < splits; i++) { + yield ( + + {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, + })} + + ) + } + } + + return INPUT_CONTROLLER[type] && <>{[...generateInputs()]} } ) diff --git a/src/fireedge/src/modules/components/Forms/SecurityGroups/ChangeForm/index.js b/src/fireedge/src/modules/components/Forms/SecurityGroups/ChangeForm/index.js index 53454d7e48..1fff7b82f6 100644 --- a/src/fireedge/src/modules/components/Forms/SecurityGroups/ChangeForm/index.js +++ b/src/fireedge/src/modules/components/Forms/SecurityGroups/ChangeForm/index.js @@ -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 diff --git a/src/fireedge/src/modules/components/Forms/SecurityGroups/CreateForm/Steps/Rules/schema.js b/src/fireedge/src/modules/components/Forms/SecurityGroups/CreateForm/Steps/Rules/schema.js index 2cac4f76dd..b7d3bc7b2a 100644 --- a/src/fireedge/src/modules/components/Forms/SecurityGroups/CreateForm/Steps/Rules/schema.js +++ b/src/fireedge/src/modules/components/Forms/SecurityGroups/CreateForm/Steps/Rules/schema.js @@ -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)) diff --git a/src/fireedge/src/modules/components/Forms/Service/PerformAction/schema.js b/src/fireedge/src/modules/components/Forms/Service/PerformAction/schema.js index 90485edbfb..6d62867680 100644 --- a/src/fireedge/src/modules/components/Forms/Service/PerformAction/schema.js +++ b/src/fireedge/src/modules/components/Forms/Service/PerformAction/schema.js @@ -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 */ diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/index.js index 73d44cd7e6..1cf4a7db35 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/index.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/index.js @@ -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 ( - ( + + - - - - - - - - - ) + + + + +) + +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 diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/schema.js index 014e9eac52..951cf38275 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/schema.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/schema.js @@ -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 = [ diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes/index.js deleted file mode 100644 index 6a7e776c1e..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes/index.js +++ /dev/null @@ -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 ( - - - - - - - - - - - {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 ( - - remove(index)} - icon={} - /> - } - sx={{ '&:hover': { bgcolor: 'action.hover' } }} - > - - - - ) - } - )} - - - ) -} - -CustomAttributesSection.propTypes = { - stepId: PropTypes.string, -} - -export default CustomAttributesSection diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes/schema.js deleted file mode 100644 index 622f859648..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes/schema.js +++ /dev/null @@ -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 -) diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/index.js index ebb26cc1c8..fbfe2707b2 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/index.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/index.js @@ -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( - () => ( - - - - - { + const { control } = useFormContext() + const tabs = useMemo( + () => + TABS.map(({ Content: TabContent, name, getError, ...section } = {}) => ({ + ...section, + name, + label: name, + renderContent: () => ( + - - - - - ), + ), + })), [STEP_ID] ) + return +} + Content.propTypes = { data: PropTypes.any, setFormData: PropTypes.func, diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/index.js new file mode 100644 index 0000000000..da4b22d893 --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/index.js @@ -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 }) => ( + + + + + + + + {[AR, SG].map(({ Section }, idx) => ( + + +
+ + + ))} + + + +) + +export default ExtraDropdown diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/schema.js similarity index 59% rename from src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/schema.js rename to src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/schema.js index a69d340501..8db9c5a94e 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/schema.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/schema.js @@ -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 +}) diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections/addressRange.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections/addressRange.js new file mode 100644 index 0000000000..923b0c8712 --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections/addressRange.js @@ -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 ( + <> + + + {addressRanges?.map((ar, idx) => ( + + handleUpdate(idx, updatedAr)} + /> + + + } + /> + ))} + + + ) +} + +export const AR = { + Section: AddressRanges, + id: SECTION_ID, +} diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections/index.js new file mode 100644 index 0000000000..ca4bdcb6b0 --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections/index.js @@ -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' diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections/securityGroup.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections/securityGroup.js new file mode 100644 index 0000000000..064166ad11 --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections/securityGroup.js @@ -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 ( + <> + , + label: T.SecurityGroup, + variant: 'outlined', + }} + options={[ + { + dialogProps: { + title: T.SecurityGroup, + dataCy: 'modal-add-sg', + }, + form: () => AddSgForm(), + onSubmit: handleAdd, + }, + ]} + /> + + {secGroups.map((sg, idx) => ( + + ))} + + + ) +} + +export const SG = { + Section: SecurityGroups, + id: SECTION_ID, +} diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/index.js index 003f1c26e0..6e12037747 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/index.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/index.js @@ -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 ( - - - - - + <> + + - - - - - {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) + + {networks?.map((network, idx) => { + const networkName = watch(`${EXTRA_ID}.${TAB_ID}.${idx}.name`) - return ( - + return ( remove(index)} - icon={} - /> - } - 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', + }, + }} > - + handleRemove(event, idx)} + sx={{ mr: 1.5, size: 'small' }} + > + + +
+ {networkName || T.NewNetwork} +
-
- ) - } - )} -
-
+ ) + })} + + + + {selectedNetwork != null && wNetworks?.length > 0 && ( + <> + + + + + + + + + )} + + + ) } -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 diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/schema.js index 68fb6b2471..30f4498cfd 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/schema.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/schema.js @@ -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 } diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/scheduledActions/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/scheduledActions/index.js index d7faa6a150..0ee471f50c 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/scheduledActions/index.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/scheduledActions/index.js @@ -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 ( <> - - - - - - + + + + + {schedActions?.map((userinput, idx) => { + const schedActionType = watch( + `${EXTRA_ID}.${TAB_ID}.${idx}.ACTION` + ) - - {scheduleActions?.map((schedule, index) => { - const { ID, NAME } = schedule - const fakeValues = { ...schedule, ID: index } + return ( + setSelectedSchedAction(idx)} + sx={{ + border: '1px solid', + borderColor: 'divider', + borderRadius: '4px', + minHeight: '70px', + my: 0.5, + overflowX: 'hidden', + padding: 2, - return ( - - handleUpdate(newAction, index)} - oneConfig={oneConfig} - adminGroup={adminGroup} - /> - handleRemove(index)} - oneConfig={oneConfig} - adminGroup={adminGroup} - /> - - } + bgcolor: + idx === selectedSchedAction + ? 'action.selected' + : 'inherit', + '&:hover': { + bgcolor: 'action.hover', + }, + }} + > + handleRemove(event, idx)} + sx={{ mr: 1.5, size: 'small' }} + > + + + +
+ {`${T.ScheduleAction}#${idx}`} +
+
+ {schedActionType || `${T.No} ${T.Type}`} +
+
+
+ ) + })} +
+
+ + {selectedSchedAction != null && wSchedActions?.length > 0 && ( + + - ) - })} - - -
+ + )} + + ) } -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 diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/schema.js index 14d5a7b964..183d9561c2 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/schema.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/schema.js @@ -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) +) diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/userInputs/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/userInputs/index.js new file mode 100644 index 0000000000..4e874ab0ca --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/userInputs/index.js @@ -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 ( + <> + + + + + {userinputs?.map((userinput, idx) => { + const userinputName = watch(`${EXTRA_ID}.${TAB_ID}.${idx}.name`) + + return ( + 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', + }, + }} + > + { + handleRemove(event, idx)} + sx={{ mr: 1.5, size: 'small' }} + > + + + } +
+ {userinputName || T.NewUserInput} +
+
+ ) + })} +
+
+ + {selectedUInput != null && wUserInputs?.length > 0 && ( + + + + )} + +
+ + ) +} + +Content.propTypes = { + stepId: PropTypes.string, +} + +const TAB = { + id: TAB_ID, + name: T.UserInputs, + icon: NetworkIcon, + Content, + getError: (error) => !!error?.[TAB_ID], +} + +export default TAB diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/userInputs/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/userInputs/schema.js new file mode 100644 index 0000000000..da58dd745b --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/userInputs/schema.js @@ -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 +]) diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/General/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/General/index.js index 36e51f23d6..a49071f0b5 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/General/index.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/General/index.js @@ -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, diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/General/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/General/schema.js index 6951b8ed09..5507083f92 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/General/schema.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/General/schema.js @@ -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', diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters/index.js deleted file mode 100644 index 3c5f80afef..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters/index.js +++ /dev/null @@ -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 ( - - - - {Tr(T.AdvancedParams)} - - - - - - - - - - - ) -} - -AdvancedParametersSection.propTypes = { - selectedRoleIndex: PropTypes.number, - stepId: PropTypes.string, - roleConfigs: PropTypes.object, - onChange: PropTypes.func, -} - -export default AdvancedParametersSection diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies/index.js deleted file mode 100644 index bba8e96af7..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies/index.js +++ /dev/null @@ -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 ( - - - - {Tr(T.ElasticityPolicies)} - - - - - - - - - - - {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 ( - - remove(index)} - icon={} - /> - } - sx={{ '&:hover': { bgcolor: 'action.hover' } }} - > - - - - ) - } - )} - - - - - - ) -} - -ElasticityPoliciesSection.propTypes = { - stepId: PropTypes.string, - selectedRoleIndex: PropTypes.number, -} - -export default ElasticityPoliciesSection diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies/schema.js deleted file mode 100644 index a1bf87d5da..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies/schema.js +++ /dev/null @@ -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)) -} diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms/index.js deleted file mode 100644 index b3bf0b2b3b..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms/index.js +++ /dev/null @@ -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 ( - - - - - - ) -} - -MinMaxVms.propTypes = { - stepId: PropTypes.string, - selectedRoleIndex: PropTypes.number, -} - -export default MinMaxVms diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms/schema.js deleted file mode 100644 index 34dcff243d..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms/schema.js +++ /dev/null @@ -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)) -} diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies/index.js deleted file mode 100644 index e2ff238b6e..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies/index.js +++ /dev/null @@ -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 ( - - - - {Tr(T.ScheduledPolicies)} - - - - - - - - - - - - {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 ( - - remove(index)} - icon={} - /> - } - sx={{ '&:hover': { bgcolor: 'action.hover' } }} - > - - - - ) - } - )} - - - - - - ) -} - -ScheduledPoliciesSection.propTypes = { - stepId: PropTypes.string, - selectedRoleIndex: PropTypes.number, -} - -export default ScheduledPoliciesSection diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies/schema.js deleted file mode 100644 index c61a1ad3d7..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies/schema.js +++ /dev/null @@ -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)) -} diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/index.js deleted file mode 100644 index 154c847552..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/index.js +++ /dev/null @@ -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 = ( - - {!standaloneModal && ( - - - - )} - - - {!standaloneModal && ( - - )} - - - {standaloneModal && ( - - - - )} - - {standaloneModal && ( - - - - )} - - - - - - - - - - {standaloneModal && ( - - - - )} - - - - {!standaloneModal && ( - - - - )} - - ) - - return standaloneModal ? ( - {ComponentContent} - ) : ( - 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 diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/roleNetwork.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/roleNetwork.js deleted file mode 100644 index addbd49c59..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/roleNetwork.js +++ /dev/null @@ -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) => ( - 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 && ( - 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 && ( - { - const fieldArray = getValues(fieldArrayLocation)?.[index] - - return ( - net?.name !== params?.row?.network && - fieldArray?.rowSelected && - fieldArray?.aliasIdx === -1 - ) - }) - - ?.map((net) => net?.name)} - renderOption={(props, option) => ( -
  • - {option} -
  • - )} - renderInput={(props) => } - 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 ( - - - - - {networks?.length > 0 && ( - - row?.rowSelected - )} - value={watchedRdpConfig?.[selectedRoleIndex] ?? ''} - getOptionLabel={(option) => option?.name || option || ''} - onChange={(_event, value) => handleSetRdp(value)} - renderInput={(params) => ( - - )} - /> - - )} - - ) -} - -RoleNetwork.propTypes = { - networks: PropTypes.array, - roleConfigs: PropTypes.object, - stepId: PropTypes.string, - selectedRoleIndex: PropTypes.number, -} - -export default RoleNetwork diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/index.js new file mode 100644 index 0000000000..c6852a73d8 --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/index.js @@ -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' diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/network.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/network.js new file mode 100644 index 0000000000..87c02fb0a2 --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/network.js @@ -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 ( + + 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 ( + + 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 ( + 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) => ( + + )} + fullWidth + /> + ) + }, + }, + ] + + return ( + + + + + + + NIC?.NETWORK_ID)} + rows={networks} + columns={columns} + disableColumnMenu + autoHeight + /> + + + ) +} diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/policies.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/policies.js new file mode 100644 index 0000000000..be26ee4f91 --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/policies.js @@ -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 }) => ( + + + + + + + + + + + {[ELASTICITY, SCHEDULED].map(({ Section }, idx) => ( + + +
    + + + ))} + + + +) + +export default PoliciesDropdown diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/elasticity/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/elasticity/index.js new file mode 100644 index 0000000000..eb07bcf62f --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/elasticity/index.js @@ -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 ( + + + + + + + + + {selectedPolicy != null && ( + <> + + {wPolicies?.length > 0 && ( + + )} + + + {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 ( + 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', + }, + }} + > + + + handleRemove(event, idx)} + sx={{ + mr: 1.5, + size: 'small', + }} + > + + + + ) + })} + + + + )} + + + + + ) +} + +export const ELASTICITY = { + Section: ElasticityPolicies, + id: SECTION_ID, +} diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/elasticity/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/elasticity/schema.js new file mode 100644 index 0000000000..1f8f895a7c --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/elasticity/schema.js @@ -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 +) diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/index.js new file mode 100644 index 0000000000..2cff827ca9 --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/index.js @@ -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' diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/minMax.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/minMax.js new file mode 100644 index 0000000000..70be10d108 --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/minMax.js @@ -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) diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/scheduled/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/scheduled/index.js new file mode 100644 index 0000000000..ec695522f7 --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/scheduled/index.js @@ -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 ( + + + + + + + + + {selectedPolicy != null && ( + <> + + {wPolicies?.length > 0 && ( + + )} + + + {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 ( + 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', + }, + }} + > + + + handleRemove(event, idx)} + sx={{ + mr: 1.5, + size: 'small', + }} + > + + + + ) + })} + + + + )} + + + + + ) +} + +export const SCHEDULED = { + Section: ScheduledPolicies, + id: SECTION_ID, +} diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/scheduled/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/scheduled/schema.js new file mode 100644 index 0000000000..2811ff1f89 --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/scheduled/schema.js @@ -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 +) diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/index.js index 44752ea2b7..00d7899e02 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/index.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/index.js @@ -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 ( + + + + + ) + } return ( - - - - - - - - - - - - + + {!standaloneModal && ( + + + + {roles?.map((role, idx) => { + const roleName = watch(`${STEP_ID}.${idx}.name`) + + return ( + 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 && ( + handleRemove(event, idx)} + sx={{ mr: 1.5, size: 'small' }} + > + + + )} +
    + {roleName || T.NewRole} +
    +
    + ) + })} +
    +
    + )} + + {selectedRole != null && ( + + + !standaloneModal || !standaloneExcludedFields?.includes(name) + )} + /> + + {!standaloneModal && ( + + )} + {!isVr && ( + + )} + + + )}
    ) } -/** - * 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 diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/roleSummary.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/roleSummary.js deleted file mode 100644 index b359b9eb0a..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/roleSummary.js +++ /dev/null @@ -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 ( - - - - #{selectedRoleIndex + 1 ?? 0} {Tr(T.RoleConfiguration)} - - - - {Tr(T.Name)}: {role?.NAME || Tr(T.RoleEnterName)} - - - - {Tr(T.NumberOfVms)}: {role?.CARDINALITY} - - - {role?.SELECTED_VM_TEMPLATE_ID ? ( - <> - - {translations.template}: {role?.SELECTED_VM_TEMPLATE_ID} - - - ) : ( - - {translations.selectTemplate} - - )} - - - {Tr(T.Networks)}: {role?.NETWORKS || ' ' + Tr(T.RoleSelectNetwork)} - - - - {Tr(T.RoleElasticity)} - - - - {Tr(T.RolesMinVms)}:{role?.MINVMS || ' ' + Tr(T.RoleMinElasticity)} - - - - {Tr(T.RolesMaxVms)}:{role?.MAXVMS || ' ' + Tr(T.RoleMaxElasticity)} - - - - {Tr(T.Cooldown)}:{role?.COOLDOWN || ' ' + Tr(T.RoleDurationScale)} - - - - {Tr(T.ElasticityPolicies)} - - - - {Tr(T.Type)}: - {role?.ELASTICITYPOLICIES?.TYPE || ' ' + Tr(T.RoleAdjustmentType)} - - - - {Tr(T.Adjust)}: - {role?.ELASTICITYPOLICIES?.ADJUST || - ' ' + Tr(T.RoleAdjustmentTypePositiveNegative)} - - - - ) -} - -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 diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesColumn.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesColumn.js deleted file mode 100644 index ea352def75..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesColumn.js +++ /dev/null @@ -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 ( - - - {!disableModify && ( - - )} - - - {Array.isArray(roles) && - roles.length > 0 && - roles.map((role, index) => ( - 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 && ( - { - event.stopPropagation() - handleRemoveRole(index) - }} - data-cy={`delete-role-${index}`} - sx={{ mr: 1.5 }} - > - - - )} -
    - {role?.NAME || 'New Role'} -
    -
    - ))} -
    -
    -
    -
    - ) -} - -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 diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesPanel.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesPanel.js deleted file mode 100644 index 3238a660bb..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesPanel.js +++ /dev/null @@ -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 ( - - - {Tr(T.RoleDetails)} - - - - - - handleBlur(event, true)} - disabled={isDisabled} - InputProps={{ - inputProps: { - min: 0, - 'data-cy': `role-cardinality-${selectedRoleIndex}`, - }, - }} - fullWidth - /> - - - {roles?.length >= 2 && ( - - idx !== selectedRoleIndex)} - disableCloseOnSelect - getOptionLabel={(option) => option?.NAME} - value={selectedParentRoles} - onChange={handleAutocompleteChange} - renderOption={(props, option, { selected }) => ( -
  • - - {option?.NAME} -
  • - )} - renderInput={(params) => ( - - )} - /> -
    - )} -
    -
    - ) -} - -RoleVmVmPanel.propTypes = { - roles: PropTypes.arrayOf( - PropTypes.shape({ - NAME: PropTypes.string, - CARDINALITY: PropTypes.number, - }) - ), - onChange: PropTypes.func.isRequired, - selectedRoleIndex: PropTypes.number, -} - -export default RoleVmVmPanel diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schema.js deleted file mode 100644 index 1bf0ece842..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schema.js +++ /dev/null @@ -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] diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schemas/advancedParameters.js similarity index 64% rename from src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters/schema.js rename to src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schemas/advancedParameters.js index c400cb3241..1431876ff1 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters/schema.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schemas/advancedParameters.js @@ -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 ) diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schemas/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schemas/index.js new file mode 100644 index 0000000000..b1aa0315d9 --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schemas/index.js @@ -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 } diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schemas/roleDefinition.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schemas/roleDefinition.js new file mode 100644 index 0000000000..d2fa98c1cd --- /dev/null +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schemas/roleDefinition.js @@ -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, +]) diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/vmTemplatesPanel.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/vmTemplatesPanel.js deleted file mode 100644 index a75ee78b46..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/vmTemplatesPanel.js +++ /dev/null @@ -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 ( - - - {Tr(T.VMTemplates)} - - - - - - - {Tr(T.ID)} - {Tr(T.Name)} - {Tr(T.Owner)} - {Tr(T.Group)} - {Tr(T.RegistrationTime)} - - - - {templates - ?.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) - ?.map((vmTemplate) => ( - - 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} - > - - - - {vmTemplate.ID} - {vmTemplate.NAME} - {vmTemplate.UNAME} - {vmTemplate.GNAME} - - {convertTimestampToDate(vmTemplate.REGTIME)} - - - ))} - -
    -
    - -
    - ) -} - -VmTemplatesPanel.propTypes = { - roles: PropTypes.array, - selectedRoleIndex: PropTypes.number, - onChange: PropTypes.func.isRequired, - vmTemplates: PropTypes.array, - error: PropTypes.object, - templateID: PropTypes.array, -} - -export default VmTemplatesPanel diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/index.js index c508a6b42f..d71916edfa 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/index.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/CreateForm/Steps/index.js @@ -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 }, }) diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/General/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/General/index.js index 5e405839d2..3489687038 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/General/index.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/General/index.js @@ -20,40 +20,30 @@ import { SCHEMA, NAME_FIELD, INSTANCE_FIELD } from './schema' export const STEP_ID = 'general' -const Content = ({ isUpdate }) => ( +const Content = () => ( ) /** * 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 diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/General/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/General/schema.js index 4de5b8783a..3318dbeb63 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/General/schema.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/General/schema.js @@ -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 }, } diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/Network/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/Network/index.js deleted file mode 100644 index 6a2aafb1d2..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/Network/index.js +++ /dev/null @@ -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 ( - - - - { - setTableIndex(e.target.selectedIndex) - }} - fullWidth - InputProps={{ - inputProps: { 'data-cy': `select-${STEP_ID}-id` }, - }} - variant="outlined" - > - {Object.keys(networkInfo).map((key) => ( - - ))} - - - - - setTableType(e.target.value)} - fullWidth - InputProps={{ - inputProps: { 'data-cy': `select-${STEP_ID}-type` }, - }} - variant="outlined" - > - {Object.entries(NETWORK_TYPES).map(([key, value]) => ( - - ))} - - - - - - ) -} - -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 diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/Network/schema.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/Network/schema.js deleted file mode 100644 index b0316ca3b8..0000000000 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/Network/schema.js +++ /dev/null @@ -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))), - }) -} diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/UserInputs/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/UserInputs/index.js index b8fcb27f24..92607380ab 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/UserInputs/index.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/UserInputs/index.js @@ -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 diff --git a/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/index.js b/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/index.js index 2d598428a2..089f1cb3fd 100644 --- a/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/index.js +++ b/src/fireedge/src/modules/components/Forms/ServiceTemplate/InstantiateForm/Steps/index.js @@ -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 diff --git a/src/fireedge/src/modules/components/Forms/UserInputs.js b/src/fireedge/src/modules/components/Forms/UserInputs.js index 6a93f79761..682ca765eb 100644 --- a/src/fireedge/src/modules/components/Forms/UserInputs.js +++ b/src/fireedge/src/modules/components/Forms/UserInputs.js @@ -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___. @@ -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 diff --git a/src/fireedge/src/modules/components/Forms/VNetwork/AddRangeForm/schema.js b/src/fireedge/src/modules/components/Forms/VNetwork/AddRangeForm/schema.js index 7007e1ebac..ec8ef761eb 100644 --- a/src/fireedge/src/modules/components/Forms/VNetwork/AddRangeForm/schema.js +++ b/src/fireedge/src/modules/components/Forms/VNetwork/AddRangeForm/schema.js @@ -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) diff --git a/src/fireedge/src/modules/components/Forms/VNetwork/ReserveForm/index.js b/src/fireedge/src/modules/components/Forms/VNetwork/ReserveForm/index.js index ef9c820ae3..1ad05591ef 100644 --- a/src/fireedge/src/modules/components/Forms/VNetwork/ReserveForm/index.js +++ b/src/fireedge/src/modules/components/Forms/VNetwork/ReserveForm/index.js @@ -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 diff --git a/src/fireedge/src/modules/components/Forms/Vm/CreateSchedActionForm/fields.js b/src/fireedge/src/modules/components/Forms/Vm/CreateSchedActionForm/fields.js index 62fce44830..07084161c3 100644 --- a/src/fireedge/src/modules/components/Forms/Vm/CreateSchedActionForm/fields.js +++ b/src/fireedge/src/modules/components/Forms/Vm/CreateSchedActionForm/fields.js @@ -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() }, } diff --git a/src/fireedge/src/modules/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/userInputsSchema.js b/src/fireedge/src/modules/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/userInputsSchema.js index 79d17f538e..3d2a9f0030 100644 --- a/src/fireedge/src/modules/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/userInputsSchema.js +++ b/src/fireedge/src/modules/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/userInputsSchema.js @@ -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, diff --git a/src/fireedge/src/modules/components/Forms/VrTemplate/InstantiateForm/Steps/index.js b/src/fireedge/src/modules/components/Forms/VrTemplate/InstantiateForm/Steps/index.js index 6db0934718..264bd86518 100644 --- a/src/fireedge/src/modules/components/Forms/VrTemplate/InstantiateForm/Steps/index.js +++ b/src/fireedge/src/modules/components/Forms/VrTemplate/InstantiateForm/Steps/index.js @@ -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 ?? {} diff --git a/src/fireedge/src/modules/components/Tables/Services/actions.js b/src/fireedge/src/modules/components/Tables/Services/actions.js index 412b65d591..a670e5572f 100644 --- a/src/fireedge/src/modules/components/Tables/Services/actions.js +++ b/src/fireedge/src/modules/components/Tables/Services/actions.js @@ -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, diff --git a/src/fireedge/src/modules/components/Tables/styles.js b/src/fireedge/src/modules/components/Tables/styles.js index 0cccd26a16..dd7557c415 100644 --- a/src/fireedge/src/modules/components/Tables/styles.js +++ b/src/fireedge/src/modules/components/Tables/styles.js @@ -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', diff --git a/src/fireedge/src/modules/components/Tabs/Service/Roles.js b/src/fireedge/src/modules/components/Tabs/Service/Roles.js index 3de3c6c1af..a658bff3ab 100644 --- a/src/fireedge/src/modules/components/Tabs/Service/Roles.js +++ b/src/fireedge/src/modules/components/Tabs/Service/Roles.js @@ -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 ( + + + + + + + + + ) +} +/* 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 }) => ( - - { - handleAddRole(params) - onClose() - }} - fetchedVmTemplates={{ vmTemplates: data, error: error }} - /> - - ) - /* 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 ? : , + icon: , }} options={{ singleButton: { - disabled: !!isFetching, sx: { fontSize: '0.95rem', padding: '6px 8px', @@ -202,7 +227,11 @@ const RolesTab = ({ id }) => { }, }} /> - + { minHeight="500px" onClick={(event) => event.stopPropagation()} > - { - const { name, cardinality, vm_template: templateId } = role + const { name, cardinality, template_id: templateId } = role return ( { return ( @@ -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 }) => { {name} + + {type} + {cardinality} diff --git a/src/fireedge/src/modules/components/Tabs/Tab.js b/src/fireedge/src/modules/components/Tabs/Tab.js index 2e1e165a2c..c60f11e04f 100644 --- a/src/fireedge/src/modules/components/Tabs/Tab.js +++ b/src/fireedge/src/modules/components/Tabs/Tab.js @@ -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`, }), })) diff --git a/src/fireedge/src/modules/components/Tabs/Vn/Security.js b/src/fireedge/src/modules/components/Tabs/Vn/Security.js index 19150e7426..cc73f32b99 100644 --- a/src/fireedge/src/modules/components/Tabs/Vn/Security.js +++ b/src/fireedge/src/modules/components/Tabs/Vn/Security.js @@ -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, diff --git a/src/fireedge/src/modules/constants/translates.js b/src/fireedge/src/modules/constants/translates.js index 210d9c6a21..a13857a6a5 100644 --- a/src/fireedge/src/modules/constants/translates.js +++ b/src/fireedge/src/modules/constants/translates.js @@ -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', diff --git a/src/fireedge/src/modules/containers/ServiceTemplates/Instantiate.js b/src/fireedge/src/modules/containers/ServiceTemplates/Instantiate.js index cc5c5d0340..6f08d6ae46 100644 --- a/src/fireedge/src/modules/containers/ServiceTemplates/Instantiate.js +++ b/src/fireedge/src/modules/containers/ServiceTemplates/Instantiate.js @@ -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() ) ) diff --git a/src/fireedge/src/modules/containers/Settings/Tfa/Tfa.js b/src/fireedge/src/modules/containers/Settings/Tfa/Tfa.js index bc9394f319..e3847983ec 100644 --- a/src/fireedge/src/modules/containers/Settings/Tfa/Tfa.js +++ b/src/fireedge/src/modules/containers/Settings/Tfa/Tfa.js @@ -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', diff --git a/src/fireedge/src/modules/features/OneApi/serviceTemplate.js b/src/fireedge/src/modules/features/OneApi/serviceTemplate.js index de7f083c8b..ee36a2794a 100644 --- a/src/fireedge/src/modules/features/OneApi/serviceTemplate.js +++ b/src/fireedge/src/modules/features/OneApi/serviceTemplate.js @@ -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() diff --git a/src/fireedge/src/modules/models/Helper.js b/src/fireedge/src/modules/models/Helper.js index 8459b8e00d..9ef262e855 100644 --- a/src/fireedge/src/modules/models/Helper.js +++ b/src/fireedge/src/modules/models/Helper.js @@ -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?.() diff --git a/src/fireedge/src/modules/providers/theme/defaults.js b/src/fireedge/src/modules/providers/theme/defaults.js index 75a7ec2080..c9368466e7 100644 --- a/src/fireedge/src/modules/providers/theme/defaults.js +++ b/src/fireedge/src/modules/providers/theme/defaults.js @@ -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%', diff --git a/src/fireedge/src/modules/utils/helpers.js b/src/fireedge/src/modules/utils/helpers.js index d04a24897c..d38a8dd410 100644 --- a/src/fireedge/src/modules/utils/helpers.js +++ b/src/fireedge/src/modules/utils/helpers.js @@ -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 +} diff --git a/src/fireedge/src/modules/utils/parser/index.js b/src/fireedge/src/modules/utils/parser/index.js index 4336f46142..606559c5f0 100644 --- a/src/fireedge/src/modules/utils/parser/index.js +++ b/src/fireedge/src/modules/utils/parser/index.js @@ -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, diff --git a/src/fireedge/src/modules/utils/parser/parseServiceTemplate.js b/src/fireedge/src/modules/utils/parser/parseServiceTemplate.js index 7ac04c8dc3..b6e332d636 100644 --- a/src/fireedge/src/modules/utils/parser/parseServiceTemplate.js +++ b/src/fireedge/src/modules/utils/parser/parseServiceTemplate.js @@ -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, + } } /** diff --git a/src/fireedge/src/modules/utils/translation.js b/src/fireedge/src/modules/utils/translation.js index 3fc53ca50f..cf32616d7b 100644 --- a/src/fireedge/src/modules/utils/translation.js +++ b/src/fireedge/src/modules/utils/translation.js @@ -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', diff --git a/src/fireedge/src/server/routes/api/oneflow/schemas.js b/src/fireedge/src/server/routes/api/oneflow/schemas.js index 2c9efec79f..7cb4f62ab4 100644 --- a/src/fireedge/src/server/routes/api/oneflow/schemas.js +++ b/src/fireedge/src/server/routes/api/oneflow/schemas.js @@ -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, diff --git a/src/flow/lib/LifeCycleManager.rb b/src/flow/lib/LifeCycleManager.rb index fbface5719..a814dfc2da 100644 --- a/src/flow/lib/LifeCycleManager.rb +++ b/src/flow/lib/LifeCycleManager.rb @@ -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 diff --git a/src/flow/lib/models.rb b/src/flow/lib/models.rb index df9b1a34e2..27c20275ff 100644 --- a/src/flow/lib/models.rb +++ b/src/flow/lib/models.rb @@ -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' diff --git a/src/flow/lib/models/role.rb b/src/flow/lib/models/role.rb index bec8370a6e..5d1505dd6d 100644 --- a/src/flow/lib/models/role.rb +++ b/src/flow/lib/models/role.rb @@ -26,34 +26,12 @@ end module OpenNebula - # Service Role class + # Service Role Class (Generic Role type) class Role + LOG_COMP = 'ROL' attr_reader :service - # Actions that can be performed on the VMs of a given Role - SCHEDULE_ACTIONS = [ - 'terminate', - 'terminate-hard', - 'undeploy', - 'undeploy-hard', - 'hold', - 'release', - 'stop', - 'suspend', - 'resume', - 'reboot', - 'reboot-hard', - 'poweroff', - 'poweroff-hard', - 'snapshot-create', - 'snapshot-revert', - 'snapshot-delete', - 'disk-snapshot-create', - 'disk-snapshot-revert', - 'disk-snapshot-delete' - ] - STATE = { 'PENDING' => 0, 'DEPLOYING' => 1, @@ -84,23 +62,7 @@ module OpenNebula 'HOLD' ] - RECOVER_DEPLOY_STATES = [ - 'FAILED_DEPLOYING', - 'DEPLOYING', - 'PENDING' - ] - - RECOVER_UNDEPLOY_STATES = [ - 'FAILED_UNDEPLOYING', - 'UNDEPLOYING' - ] - - RECOVER_SCALE_STATES = [ - 'FAILED_SCALING', - 'SCALING' - ] - - VM_FAILURE_STATES = [ + FAILURE_STATES = [ 'BOOT_FAILURE', 'BOOT_MIGRATE_FAILURE', 'PROLOG_MIGRATE_FAILURE', @@ -117,38 +79,133 @@ module OpenNebula 'PROLOG_UNDEPLOY_FAILURE' ] + RECOVER_DEPLOY_STATES = [ + 'FAILED_DEPLOYING', + 'DEPLOYING', + 'PENDING' + ] + + RECOVER_UNDEPLOY_STATES = [ + 'FAILED_UNDEPLOYING', + 'UNDEPLOYING' + ] + + RECOVER_SCALE_STATES = [ + 'FAILED_SCALING', + 'SCALING' + ] + SCALE_WAYS = { 'UP' => 0, 'DOWN' => 1 } + # Actions that can be performed on the VMs of a given Role + SCHEDULE_ACTIONS = [ + 'terminate', + 'terminate-hard', + 'undeploy', + 'undeploy-hard', + 'hold', + 'release', + 'stop', + 'suspend', + 'resume', + 'reboot', + 'reboot-hard', + 'poweroff', + 'poweroff-hard', + 'snapshot-create', + 'snapshot-revert', + 'snapshot-delete', + 'disk-snapshot-create', + 'disk-snapshot-revert', + 'disk-snapshot-delete' + ] + + # Information to save in document + VM_INFO = ['ID', 'UID', 'GID', 'UNAME', 'GNAME', 'NAME'] + # List of attributes that can't be changed in update operation - # cardinality: this is internal information managed by OneFlow server # last_vmname: this is internal information managed by OneFlow server # nodes: this is internal information managed by OneFlow server # parents: this has only sense in deploy operation # state: this is internal information managed by OneFlow server - # vm_template: this will affect scale operation + # template_id: this will affect scale operation + # cardinality: this is internal information managed by OneFlow server IMMUTABLE_ATTRS = [ 'cardinality', 'last_vmname', 'nodes', 'parents', 'state', - 'vm_template' + 'template_id' ] - # VM information to save in document - VM_INFO = ['ID', 'UID', 'GID', 'UNAME', 'GNAME', 'NAME'] + class << self - LOG_COMP = 'ROL' + # Return a role object based on type attribute of the role template + # @param [Hash] Role template in Hash format + # @return [Role] Role object type + def for(body, service) + role_type = body.fetch('type', 'vm') + # role_type = template['type'].downcase + + case role_type + when 'vm' + VMRole.new(body, service) + when 'vr' + VRRole.new(body, service) + else + raise "Unsupported role type: #{role_type}" + end + end + + # Returns true if the VM state is failure + # @param [Integer] vm_state VM state + # @param [Integer] lcm_state VM LCM state + # @return [true,false] True if the lcm state is one of *_FAILURE + def vm_failure?(vm_state, lcm_state) + vm_state_str = VirtualMachine::VM_STATE[vm_state.to_i] + lcm_state_str = VirtualMachine::LCM_STATE[lcm_state.to_i] + + if vm_state_str == 'ACTIVE' && + FAILURE_STATES.include?(lcm_state_str) + return true + end + + false + end + + # rubocop:disable Style/ClassVars + def init_default_cooldown(default_cooldown) + @@default_cooldown = default_cooldown + end + + def init_default_shutdown(shutdown_action) + @@default_shutdown = shutdown_action + end + + def init_force_deletion(force_deletion) + @@force_deletion = force_deletion + end + + def init_default_vm_name_template(vm_name_template) + @@vm_name_template = vm_name_template + end + + def init_default_vr_name_template(vr_name_template) + @@vr_name_template = vr_name_template + end + # rubocop:enable Style/ClassVars + + end def initialize(body, service) - @body = body - @service = service + @body = body + @service = service - @body['cooldown'] = @@default_cooldown if @body['cooldown'].nil? - @body['nodes'] ||= [] + @body['nodes'] ||= [] @body['on_hold'] = false if @body['on_hold'].nil? end @@ -156,112 +213,22 @@ module OpenNebula @body['name'] end - # Returns the role state - # @return [Integer] the role state def state - @body['state'].to_i + @body['state'] end - def can_recover_deploy? - if state != STATE['PENDING'] - return RECOVER_DEPLOY_STATES.include? STATE_STR[state] - end + # Sets a new state + # @param [Integer] the new state + def state=(state) + return if state < 0 || state > STATE_STR.size - parents.each do |parent| - next unless @service.roles[parent] + @body['state'] = state.to_i - return false if @service.roles[parent].state != STATE['RUNNING'] - end - - true - end - - def can_recover_undeploy? - if !RECOVER_UNDEPLOY_STATES.include? STATE_STR[state] - # TODO, check childs if !empty? check if can be undeployed - @service.roles.each do |role_name, role| - next if role_name == name - - if role.parents.include?(name) && - role.state != STATE['DONE'] - return false - end - end - end - - true - end - - def can_recover_scale? - return false unless RECOVER_SCALE_STATES.include? STATE_STR[state] - - true - end - - def can_release? - state == STATE['HOLD'] - end - - # Returns the role parents - # @return [Array] the role parents - def parents - @body['parents'] || [] - end - - def any_parent_on_hold? - parents.each do |parent| - next unless @service.roles[parent] - - return true if @service.roles[parent].on_hold? - end - false - end - - # Returns the role cardinality - # @return [Integer] the role cardinality - def cardinality - @body['cardinality'].to_i - end - - # Sets a new cardinality for this role - # @param [Integer] the new cardinality - # rubocop:disable Naming/AccessorMethodName - def set_cardinality(target_cardinality) - # rubocop:enable Naming/AccessorMethodName - if target_cardinality > cardinality - dir = 'up' - else - dir = 'down' - end - - msg = "Role #{name} scaling #{dir} from #{cardinality} to " \ - "#{target_cardinality} nodes" - - Log.info LOG_COMP, msg, @service.id - - @service.log_info(msg) - - @body['cardinality'] = target_cardinality.to_i - end - - # 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 + Log.info( + LOG_COMP, + "Role #{name} new state: #{STATE_STR[state]}", + @service.id + ) end # Returns the string representation of the service state @@ -276,6 +243,10 @@ module OpenNebula @body['nodes'] end + def nodes_ids + @body['nodes'].map {|node| node['deploy_id'] } + end + def info_nodes(vm_pool) ret = [] @@ -307,298 +278,68 @@ module OpenNebula ret end - def nodes_ids - @body['nodes'].map {|node| node['deploy_id'] } + # Returns the role parents + # @return [Array] the role parents + def parents + @body['parents'] || [] end - def elasticity_policies - @body['elasticity_policies'] + # Returns the role cardinality + # @return [Integer] the role cardinality + def cardinality + @body['cardinality'].to_i end - def update_elasticity_policies(new_policies) - @body['elasticity_policies'] = new_policies - end - - def scheduled_policies - @body['scheduled_policies'] - end - - def update_scheduled_policies(new_policies) - @body['scheduled_policies'] = new_policies - end - - def cooldown - @body['cooldown'] - end - - def update_cooldown(new_cooldown) - @body['cooldown'] = new_cooldown unless new_cooldown.nil? - end - - # 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 + # Sets a new cardinality for this role + # @param [Integer] the new cardinality + def cardinality=(target_cardinality) + if target_cardinality > cardinality + dir = 'up' + else + dir = 'down' end - @body['state'] = state.to_i + msg = "Role #{name} scaling #{dir} from #{cardinality} to " \ + "#{target_cardinality} nodes" - if state == STATE['SCALING'] + Log.info(LOG_COMP, msg, @service.id) + @service.log_info(msg) - elasticity_pol = @body['elasticity_policies'] - - if !elasticity_pol.nil? - elasticity_pol.each do |policy| - policy.delete('true_evals') - end - end - end - - Log.info LOG_COMP, - "Role #{name} new state: #{STATE_STR[state]}", - @service.id - - true + @body['cardinality'] = target_cardinality.to_i end - def scale_way(way) - @body['scale_way'] = SCALE_WAYS[way] + # Change the `on_hold` option value + def on_hold=(on_hold) + @body['on_hold'] = on_hold end - def clean_scale_way - @body.delete('scale_way') - end - - # Returns the on_hold role option - # @return [true, false] true if the on_hold option is enabled + # Returns the `on_hold` role option + # @return [true, false] `true` if the `on_hold` option is enabled def on_hold? @body['on_hold'] end - # Returns the on_hold service option - # @return [true, false] true if the on_hold option is enabled + # Returns the `on_hold` service option + # @return [true, false] `true` if the `on_hold` option is enabled def service_on_hold? @service.on_hold? end - # Set the on_hold vm option to true - def hold(hold) - @body['on_hold'] = hold - end + # Checks if any parent role is currently on hold. + # @return [Boolean] Returns `true` if any parent role is in an + # `on_hold` state, `false` otherwise. + def any_parent_on_hold? + parents.each do |parent| + next unless @service.roles[parent] - # Retrieves the VM information for each Node in this Role. If a Node - # is to be disposed and it is found in DONE, it will be cleaned - # - # @return [nil, OpenNebula::Error] nil in case of success, Error - # otherwise - def info - raise 'role.info is not defined' - end - - # Deploys all the nodes in this role - # @return [Array, Array] 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 - - @body['last_vmname'] ||= 0 - - template_id = @body['vm_template'] - template = OpenNebula::Template.new_with_id(template_id, - @service.client) - - if @body['vm_template_contents'] - extra_template = @body['vm_template_contents'].dup - - # If the extra_template contains APPEND=",", it - # will add the attributes that already exist in the template, - # instead of replacing them. - append = extra_template - .match(/^\s*APPEND=\"?(.*?)\"?\s*$/)[1] - .split(',') rescue nil - - if append && !append.empty? - rc = template.info - - if OpenNebula.is_error?(rc) - msg = "Role #{name} : Info template #{template_id};" \ - " #{rc.message}" - - Log.error LOG_COMP, msg, @service.id - @service.log_error(msg) - - return [false, 'Error fetching Info to instantiate' \ - " VM Template #{template_id} in Role " \ - "#{name}: #{rc.message}"] - end - - et = template.template_like_str('TEMPLATE', - true, - append.join('|')) - - et = et << "\n" << extra_template - - extra_template = et - end - else - extra_template = '' + return true if @service.roles[parent].on_hold? end - - extra_template << "\nSERVICE_ID = #{@service.id}" - extra_template << "\nROLE_NAME = \"#{@body['name']}\"" - - # Evaluate attributes with parent roles - evaluate(extra_template) - - 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 - - vm_id = template.instantiate(vm_name, on_hold?, extra_template) - - deployed_nodes << vm_id - - 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 - - node = { 'deploy_id' => vm_id } - vm = OpenNebula::VirtualMachine.new_with_id(vm_id, @service.client) - - tries = 0 - loop do - break if tries == 3 - - tries += 1 - - rc = vm.info - - break unless OpenNebula.is_error?(rc) - - sleep(tries) - end - - if tries == 3 - node['vm_info'] = nil - - msg = "Role #{name} : Cannot get info for VM #{vm_id}" - - Log.error LOG_COMP, msg, @service.id - - @service.log_error(msg) - - return [false, - "Error getting VM #{vm_id} info in Role #{name}: #{vm_id.message}"] - end - - hash_vm = vm.to_hash['VM'] - vm_info = {} - vm_info['VM'] = hash_vm.select {|v| VM_INFO.include?(v) } - - node['vm_info'] = vm_info - - @body['nodes'] << node - end - - [deployed_nodes, nil] + false end - # Terminate all the nodes in this role - # - # @param scale_down [true, false] true to terminate and dispose the - # number of VMs needed to get down to cardinality nodes - # @return [Array, Array] true if all the VMs - # were terminated, false and the error reason if there was a problem - # shutting down the VMs - def shutdown(recover) - if nodes.size != cardinality - n_nodes = nodes.size - cardinality - else - n_nodes = nodes.size - end - - rc = shutdown_nodes(nodes, n_nodes, recover) - - unless rc[0] - return [false, "Error undeploying nodes for role `#{name}`"] - end - - [rc[1], nil] - end - - # Delete all the nodes in this role - # @return [Array] All the VMs are deleted, and the return - # ignored - def delete - raise 'role.delete is not defined' - end - - # 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, Array] 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 + ######################################################################## + # Operations + ######################################################################## # Release all the nodes in this role # @return [Array, Bool] true if all the VMs @@ -638,136 +379,23 @@ module OpenNebula [release_nodes, success] end - # 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')] + # Checks if the current role is in a state where it can be released. + # @return [Boolean] Returns `true` if the current state is `HOLD`, + # `false` otherwise. + def can_release? + state == STATE['HOLD'] end - # Returns true if the VM state is failure - # @param [Integer] vm_state VM state - # @param [Integer] lcm_state VM LCM state - # @return [true,false] True if the lcm state is one of *_FAILURE - def self.vm_failure?(vm_state, lcm_state) - vm_state_str = VirtualMachine::VM_STATE[vm_state.to_i] - lcm_state_str = VirtualMachine::LCM_STATE[lcm_state.to_i] - - if vm_state_str == 'ACTIVE' && - VM_FAILURE_STATES.include?(lcm_state_str) - return true - end - - false + def chown(uid, gid) + raise NotImplementedError end - # rubocop:disable Style/ClassVars - def self.init_default_cooldown(default_cooldown) - @@default_cooldown = default_cooldown - end - - def self.init_default_shutdown(shutdown_action) - @@default_shutdown = shutdown_action - end - - def self.init_force_deletion(force_deletion) - @@force_deletion = force_deletion - end - - def self.init_default_vm_name_template(vm_name_template) - @@vm_name_template = vm_name_template - end - # rubocop:enable Style/ClassVars - - ######################################################################## - # Scalability - ######################################################################## - # 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 - - set_cardinality(new_cardinality) - - nil + raise NotImplementedError end # Check that changes values are correct @@ -786,10 +414,149 @@ module OpenNebula [true, nil] end + ######################################################################## + # Scheduler + ######################################################################## + + def scheduled_policies + @body['scheduled_policies'] + end + + def update_scheduled_policies(new_policies) + @body['scheduled_policies'] = new_policies + end + + def batch_action(action, period, vms_per_period, args) + raise NotImplementedError + end + + ######################################################################## + # Scalability + ######################################################################## + + # Returns the role max cardinality + # @return [Integer,nil] the role cardinality or nil if it isn't defined + def max_cardinality + raise NotImplementedError + end + + # Returns the role min cardinality + # @return [Integer,nil] the role cardinality or nil if it isn't defined + def min_cardinality + raise NotImplementedError + end + + # Returns a positive, 0, or negative number of nodes to adjust, + # according to the elasticity and scheduled policies + # @return [Array] positive, 0, or negative number of nodes to + # adjust, plus the cooldown period duration + def scale?(vm_pool) + raise NotImplementedError + end + + def elasticity_policies + raise NotImplementedError + end + + def update_elasticity_policies(new_policies) + raise NotImplementedError + end + + def cooldown + raise NotImplementedError + end + + def update_cooldown(new_cooldown) + raise NotImplementedError + end + + def scale_way(_) + return NotImplementedError + end + + def clean_scale_way + return NotImplementedError + end + + ######################################################################## + # Deployment + ######################################################################## + + def deploy + raise NotImplementedError + end + + # Terminate all the nodes in this role + # + # @param scale_down [true, false] true to terminate and dispose the + # number of VMs needed to get down to cardinality nodes + # @return [Array, Array] true if all the VMs + # were terminated, false and the error reason if there was a problem + # shutting down the VMs + def shutdown(recover) + if nodes.size != cardinality + n_nodes = nodes.size - cardinality + else + n_nodes = nodes.size + end + + rc = shutdown_nodes(nodes, n_nodes, recover) + + unless rc[0] + return [false, "Error undeploying nodes for role `#{name}`"] + end + + [rc[1], nil] + end + ######################################################################## # Recover ######################################################################## + # Determines whether the current deployment can be recovered + # based on its state and the states of its parent roles. + # @return [Boolean] Returns `true` if the deployment + # can be recovered, `false` otherwise. + def can_recover_deploy? + if state != STATE['PENDING'] + return RECOVER_DEPLOY_STATES.include? STATE_STR[state] + end + + parents.each do |parent| + next unless @service.roles[parent] + + return false if @service.roles[parent].state != STATE['RUNNING'] + end + + true + end + + # Determines if the current deployment can be recovered and + # undeployed based on its state and the states of its child roles. + # @return [Boolean] Returns `true` if the deployment can be + # recovered and undeployed, `false` otherwise. + def can_recover_undeploy? + if !RECOVER_UNDEPLOY_STATES.include? STATE_STR[state] + # TODO, check childs if !empty? check if can be undeployed + @service.roles.each do |role_name, role| + next if role_name == name + + if role.parents.include?(name) && + role.state != STATE['DONE'] + return false + end + end + end + + true + end + + def can_recover_scale? + return false unless RECOVER_SCALE_STATES.include? STATE_STR[state] + + true + end + def recover_deploy(report) nodes = @body['nodes'] deployed_nodes = [] @@ -858,144 +625,95 @@ module OpenNebula undeployed_nodes end - # def recover_warning - # 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 + def recover_scale + raise NotImplementedError end - # Returns a positive, 0, or negative number of nodes to adjust, - # according to the elasticity and scheduled policies - # @return [Array] 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'] + ######################################################################## + # Helpers + ######################################################################## - elasticity_pol ||= [] - scheduled_pol ||= [] + protected - scheduled_pol.each do |policy| - diff, cooldown_duration = scale_time?(policy) + def init_template_attributes + @body['last_vmname'] ||= 0 - return [diff, cooldown_duration] if diff != 0 - end + template_id = @body['template_id'] + template = OpenNebula::Template.new_with_id(template_id, @service.client) + extra_template = @body.fetch('template_contents', {}).dup - 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 - - 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 + # Since the OpenNebula core does not apply a deep merge, we replace + # here the values to avoid the entire CONTEXT replacement. + if !extra_template.empty? + rc = template.info if OpenNebula.is_error?(rc) - msg = "Role #{name} : Terminate failed for VM #{vm_id}, " \ - "will perform a Delete; #{rc.message}" + msg = "Role #{name} : Info template #{template_id}; #{rc.message}" - Log.error LOG_COMP, msg, @service.id + 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 + return [ + false, + "Error fetching Info to instantiate template #{template_id} " \ + "in Role #{name}: #{rc.message}" + ] end + + vm_template = template.to_hash['VMTEMPLATE']['TEMPLATE'] + + if vm_template.key?('NIC') && !vm_template['NIC'].is_a?(Array) + vm_template['NIC'] = [vm_template['NIC']] + end + + extra_template = vm_template.deep_merge(extra_template) end - [success, undeployed_nodes] + extra_template['SERVICE_ID'] = @service.id + extra_template['ROLE_NAME'] = @body['name'] + + extra_template = Hash.to_raw(extra_template) + + # Evaluate attributes with parent roles + evaluate(extra_template) + + [template_id, template, extra_template] + end + + def fill_node_info(vm_id) + node = { 'deploy_id' => vm_id } + vm = OpenNebula::VirtualMachine.new_with_id(vm_id, @service.client) + + max_retries = 3 + attemps = 0 + + begin + attemps += 1 + rc = vm.info + + if OpenNebula.is_error?(rc) + sleep(attemps) + raise "Error retrieving info for VM #{vm_id}" + end + + hash_vm = vm.to_hash['VM'] + node['vm_info'] = { 'VM' => hash_vm.select {|k, _| VM_INFO.include?(k) } } + + @body['nodes'] << node + rescue StandardError => e + retry if attemps < max_retries + + node['vm_info'] = nil + + msg = "Role #{name} : Cannot get info for VM #{vm_id}: #{e.message}" + Log.error LOG_COMP, msg, @service.id + + @service.log_error(msg) + + return [false, + "Error getting VM #{vm_id} info in " \ + "Role #{name}: #{e.message}"] + end end def vm_failure?(node) @@ -1007,192 +725,6 @@ module OpenNebula false 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] 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 - # TODO: error message - 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 - # Evaluate rules that references to parent roles # # @param template [String] Role template with $ to replace diff --git a/src/flow/lib/models/service.rb b/src/flow/lib/models/service.rb index 0076a1cb9f..46184aa0ca 100644 --- a/src/flow/lib/models/service.rb +++ b/src/flow/lib/models/service.rb @@ -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] 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] 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] 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 diff --git a/src/flow/lib/models/vmrole.rb b/src/flow/lib/models/vmrole.rb new file mode 100644 index 0000000000..cd9d1e780f --- /dev/null +++ b/src/flow/lib/models/vmrole.rb @@ -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, Array] 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] 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, Array] 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] 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 diff --git a/src/flow/lib/models/vrrole.rb b/src/flow/lib/models/vrrole.rb new file mode 100644 index 0000000000..0729036be4 --- /dev/null +++ b/src/flow/lib/models/vrrole.rb @@ -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, Array] 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 diff --git a/src/flow/oneflow-server.rb b/src/flow/oneflow-server.rb index 66ae40055c..ff0eadef7d 100644 --- a/src/flow/oneflow-server.rb +++ b/src/flow/oneflow-server.rb @@ -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) diff --git a/src/oca/ruby/opennebula/flow/service_template.rb b/src/oca/ruby/opennebula/flow/service_template.rb index 52f10859d8..2098c7a3fd 100644 --- a/src/oca/ruby/opennebula/flow/service_template.rb +++ b/src/oca/ruby/opennebula/flow/service_template.rb @@ -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 diff --git a/src/oca/ruby/opennebula/flow/validator.rb b/src/oca/ruby/opennebula/flow/validator.rb index 3db3d97655..5f6d02b7bb 100644 --- a/src/oca/ruby/opennebula/flow/validator.rb +++ b/src/oca/ruby/opennebula/flow/validator.rb @@ -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