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

F #6680,#6682: Improve OneFlow data model and add support to VR

This commits adds the following improvements:

* Improving Usability & Consistency in flow documents:
  - vm_template -> template_id, generic as also VR templates are
    considered
  - vm_template_contents -> template_contents, same as above
  - custom_attrs -> user_inputs, use same name convention as VM
    Templates
  - custom_attrs_values -> user_inputs_values, same as above

* Easier OneFlow operations
  - template_contents (old vm_template_contents) is now in JSON format
    instead of a String.
  - better error reporting when merging template contents

* Role can use Virtual Routers as main element, supporting all role
  features (user inputs, dependencies and network creation). Related
  changes:
  - Role data model structure has been totally refactored to accomodate
    the new type (vmrole.rb and vrrole.rb)
  - VR roles use VR OpenNebula API to create associated VMs.
  - VR HA uses the cardinaility of the role

  New attributes:
  - type, new attribute to identify the role type VM or VR
  - vrouter_ips, floating IP of each network managed
  - vrouter_id, of the associated VR

* Improved Sunstone interface for flows, including support for the new
  features.

co-authored-by: Victor Palma <vpalma@opennebula.io>
co-authored-by: Miguel E. Ruiz <mruiz@opennebula.io>
co-authored-by: Victor Hansson <vhansson@opennebula.io>
This commit is contained in:
Ruben S. Montero 2025-03-19 12:17:29 +01:00
parent bd0d225b7f
commit e2539c32c8
No known key found for this signature in database
GPG Key ID: A0CEA6FA880A1D87
98 changed files with 6001 additions and 5713 deletions

View File

@ -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"
#-----------------------------------------------------------------------------

View File

@ -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

View File

@ -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'])

View File

@ -98,14 +98,7 @@ const AddressRangeCard = memo(
].filter(Boolean)
return (
<Box
data-cy="ar"
className={classes.root}
sx={{
'&:hover': { bgcolor: 'action.hover' },
border: `1px solid ${theme.palette.divider}`,
}}
>
<Box data-cy="ar" className={classes.root}>
<div className={classes.main}>
<div className={classes.title}>
<Typography noWrap component="span" data-cy="id">

View File

@ -96,7 +96,7 @@ const SecurityGroupCard = memo(
)
return (
<div {...rootProps} data-cy={`secgroup-${ID}`}>
<div {...rootProps} className={classes.root} data-cy={`secgroup-${ID}`}>
<div className={clsx(classes.main, internalClasses.internalContainer)}>
<div>
<div className={classes.title}>

View File

@ -35,6 +35,7 @@ const AutocompleteController = memo(
fieldProps: { separators, ...fieldProps } = {},
readOnly = false,
optionsOnly = false,
clearInvalid = false,
onConditionChange,
watcher,
dependencies,
@ -100,6 +101,27 @@ const AutocompleteController = memo(
watcherValue !== undefined && onChange(watcherValue)
}, [watch, watcher, dependencies])
useEffect(() => {
if (clearInvalid && optionsOnly) {
if (multiple) {
if (renderValue?.length) {
const filteredValues =
renderValue?.filter((val) =>
values.some((option) => option.value === val)
) || []
if (filteredValues?.length !== renderValue?.length) {
onChange(filteredValues)
}
}
} else {
const isValid = values.some((option) => option.value === renderValue)
if (!isValid) {
onChange('')
}
}
}
}, [clearInvalid, optionsOnly, renderValue, values, multiple, onChange])
return (
<Autocomplete
fullWidth
@ -216,6 +238,7 @@ AutocompleteController.propTypes = {
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
optionsOnly: PropTypes.bool,
clearInvalid: PropTypes.bool,
onConditionChange: PropTypes.func,
watcher: PropTypes.func,
dependencies: PropTypes.oneOfType([

View File

@ -48,9 +48,14 @@ const TableController = memo(
onConditionChange,
zoneId,
dependOf,
fieldProps: { initialState, preserveState, ...fieldProps } = {},
fieldProps: {
initialState,
preserveState,
onRowsChange,
...fieldProps
} = {},
}) => {
const { clearErrors } = useFormContext()
const formContext = useFormContext()
const {
field: { value, onChange },
@ -84,15 +89,19 @@ const TableController = memo(
const rowValues = rows?.map(({ original }) => getRowId(original))
onChange(singleSelect ? rowValues?.[0] : rowValues)
clearErrors(name)
formContext.clearErrors(name)
if (typeof onConditionChange === 'function') {
onConditionChange(singleSelect ? rowValues?.[0] : rowValues)
}
if (typeof onRowsChange === 'function') {
onRowsChange(rows, { name, formContext })
}
},
[
onChange,
clearErrors,
formContext.clearErrors,
name,
onConditionChange,
readOnly,

View File

@ -54,6 +54,7 @@ const StepperStyled = styled(Stepper)(({ theme }) => ({
position: 'sticky',
top: -15,
minHeight: 100,
borderRadius: '8px 8px 0 0',
zIndex: theme.zIndex.mobileStepper,
backgroundColor: theme.palette.action.hover,
}))
@ -234,25 +235,21 @@ const CustomStepper = ({
minHeight: '40px',
}}
>
{
enableShowMandatoryOnly && (
// <Box sx={{ display: 'flex', alignItems: 'center' }}>
<FormControlLabel
control={
<Switch
onChange={handleShowMandatoryOnly}
name={'aaaaaa'}
checked={showMandatoryOnly}
color="secondary"
inputProps={{ 'data-cy': 'switch-mandatory' }}
/>
}
label={<Label>{T.MandatoryUserInputs}</Label>}
labelPlacement="end"
/>
)
// </Box>
}
{enableShowMandatoryOnly && (
<FormControlLabel
control={
<Switch
onChange={handleShowMandatoryOnly}
name={'aaaaaa'}
checked={showMandatoryOnly}
color="secondary"
inputProps={{ 'data-cy': 'switch-mandatory' }}
/>
}
label={<Label>{T.MandatoryUserInputs}</Label>}
labelPlacement="end"
/>
)}
<Box
sx={{ display: 'flex', alignItems: 'center', marginLeft: 'auto' }}
>

View File

@ -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)
}
}

View File

@ -21,29 +21,30 @@ import { FIELDS } from '@modules/components/Forms/Commons/VNetwork/Tabs/configur
import FormWithSchema from '@modules/components/Forms/FormWithSchema'
import { T } from '@ConstantsModule'
const ConfigurationContent =
(stepId) =>
({ oneConfig, adminGroup, isUpdate, isVnet }) => {
const InnerComponent = (
<>
<FormWithSchema
id={stepId}
cy="configuration"
fields={FIELDS(oneConfig, adminGroup, isUpdate, isVnet)}
/>
</>
)
const ConfigurationContent = (stepId) => {
const ConfigurationComponent = ({
oneConfig,
adminGroup,
isUpdate,
isVnet,
}) => (
<FormWithSchema
id={stepId}
cy="configuration"
fields={FIELDS(oneConfig, adminGroup, isUpdate, isVnet)}
/>
)
InnerComponent.displayName = `InnerComponent`
ConfigurationComponent.displayName = 'ConfigurationComponent'
return InnerComponent
ConfigurationComponent.propTypes = {
oneConfig: PropTypes.object,
adminGroup: PropTypes.bool,
isUpdate: PropTypes.bool,
isVnet: PropTypes.bool,
}
ConfigurationContent.displayName = 'ConfigurationContent'
ConfigurationContent.propTypes = {
oneConfig: PropTypes.object,
adminGroup: PropTypes.bool,
return ConfigurationComponent
}
/**

View File

@ -24,6 +24,7 @@ import {
useEffect,
useMemo,
} from 'react'
import { v4 as uuidv4 } from 'uuid'
import { Accordion, AccordionSummary, FormControl, Grid } from '@mui/material'
import { useFormContext, useFormState, useWatch } from 'react-hook-form'
@ -309,13 +310,14 @@ const FieldComponent = memo(
const currentState = useSelector((state) => state)
// Potentially prefixes form ID + split ID
const addIdToName = useCallback(
(n) => {
// removes character '$' and returns
if (n?.startsWith('$')) return n.slice(1)
(fieldName, formId, split = 0) => {
if (fieldName?.startsWith('$')) return fieldName.slice(1)
// concat form ID if exists
return id ? `${id}.${n}` : n
return `${formId ? `${formId}.` : ''}${fieldName}${
split > 0 ? `_${split}` : ''
}`
},
[id]
)
@ -324,8 +326,8 @@ const FieldComponent = memo(
if (!dependOf) return null
return Array.isArray(dependOf)
? dependOf.map(addIdToName)
: addIdToName(dependOf)
? dependOf.map((fieldName) => addIdToName(fieldName, id))
: addIdToName(dependOf, id)
}, [dependOf, addIdToName])
const valueOfDependField = useWatch({
@ -335,12 +337,7 @@ const FieldComponent = memo(
const handleConditionChange = useCallback(
(value) => {
// Ensure step control as an array
const ensureStepControl = Array.isArray(stepControl)
? stepControl
: stepControl
? [stepControl]
: []
const ensureStepControl = [].concat(stepControl)
// Iterate over each step control to evaluate it
ensureStepControl.forEach((stepControlItem) => {
@ -360,62 +357,70 @@ const FieldComponent = memo(
[stepControl, disableSteps, currentState]
)
const { name, type, htmlType, grid, condition, ...fieldProps } =
Object.entries(attributes).reduce((field, attribute) => {
const [attrKey, value] = attribute
const isNotDependAttribute = NOT_DEPEND_ATTRIBUTES.includes(attrKey)
const {
name,
type,
htmlType,
grid,
condition,
splits = 1,
...fieldProps
} = Object.entries(attributes).reduce((field, [attrKey, attrValue]) => {
const isNotDependAttribute = NOT_DEPEND_ATTRIBUTES.includes(attrKey)
const finalValue =
typeof value === 'function' &&
!isNotDependAttribute &&
!isValidElement(value())
? value(valueOfDependField, formContext)
: value
const finalValue =
typeof attrValue === 'function' &&
!isNotDependAttribute &&
!isValidElement(attrValue())
? attrValue(valueOfDependField, formContext)
: attrValue
return { ...field, [attrKey]: finalValue }
}, {})
return { ...field, [attrKey]: finalValue }
}, {})
const dataCy = useMemo(
() => `${cy}-${name ?? ''}`.replaceAll('.', '-'),
[cy]
)
const inputName = useMemo(() => addIdToName(name), [addIdToName, name])
const isHidden = useMemo(() => htmlType === INPUT_TYPES.HIDDEN, [htmlType])
// Key is computed in first hand based on it's type, meaning we re-render if type changes.
const key = useMemo(
() =>
fieldProps
? `${name}-${simpleHash(
deepStringify(
fieldProps?.type ??
fieldProps?.values ??
Object.values(fieldProps),
3 // Max object depth
)
)}`
: undefined,
[fieldProps]
`${name}_${simpleHash(
deepStringify(
fieldProps?.type ??
fieldProps?.identifier ??
fieldProps?.values ??
Object.values(fieldProps),
3 // Max object depth
)
)}` || uuidv4(),
[(name, type, htmlType, condition, fieldProps)]
)
if (isHidden) return null
return (
INPUT_CONTROLLER[type] && (
<Grid item xs={12} md={6} {...grid}>
{createElement(INPUT_CONTROLLER[type], {
key,
control: formContext.control,
cy: dataCy,
dependencies: nameOfDependField,
name: inputName,
type: htmlType === false ? undefined : htmlType,
dependOf,
onConditionChange: handleConditionChange,
...fieldProps,
})}
</Grid>
)
)
function* generateInputs() {
for (let i = 0; i < splits; i++) {
yield (
<Grid key={`split-${i}`} item xs={12} md={6} {...grid}>
{createElement(INPUT_CONTROLLER[type], {
key: `${key}-${i}`,
control: formContext.control,
cy: dataCy,
dependencies: nameOfDependField,
name: addIdToName(name, id, i),
type: htmlType === false ? undefined : htmlType,
dependOf,
onConditionChange: handleConditionChange,
...fieldProps,
})}
</Grid>
)
}
}
return INPUT_CONTROLLER[type] && <>{[...generateInputs()]}</>
}
)

View File

@ -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

View File

@ -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))

View File

@ -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 */

View File

@ -13,52 +13,50 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useForm, FormProvider } from 'react-hook-form'
import { useMemo, memo } from 'react'
import { yupResolver } from '@hookform/resolvers/yup'
import {
ADVANCED_PARAMS_FIELDS,
ADVANCED_PARAMS_SCHEMA,
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/schema'
import { FormWithSchema, Legend } from '@modules/components/Forms'
import { Stack, Divider, FormControl } from '@mui/material'
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import { ADVANCED_PARAMS_FIELDS } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/schema'
import { FormWithSchema } from '@modules/components/Forms'
import { Box, Stack, Divider } from '@mui/material'
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
import { ServerConnection as NetworkIcon } from 'iconoir-react'
import { T } from '@ConstantsModule'
export const SECTION_ID = 'ADVANCED'
export const TAB_ID = 'advanced'
const AdvancedParamsSection = () => {
const fields = useMemo(() => ADVANCED_PARAMS_FIELDS, [])
const { handleSubmit, ...methods } = useForm({
defaultValues: ADVANCED_PARAMS_SCHEMA?.default(),
mode: 'all',
resolver: yupResolver(ADVANCED_PARAMS_SCHEMA),
})
return (
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
const Content = () => (
<Box sx={{ width: '100%', height: '100%' }}>
<Stack
key={`inputs-${TAB_ID}`}
direction="column"
alignItems="flex-start"
gap="0.5rem"
component="form"
width="100%"
>
<Legend title={T.AdvancedParams} />
<FormProvider {...methods}>
<Stack
direction="row"
alignItems="flex-start"
gap="0.5rem"
component="form"
>
<FormWithSchema
cy={SECTION_ID}
fields={fields}
rootProps={{ sx: { m: 0 } }}
/>
</Stack>
</FormProvider>
<Divider />
</FormControl>
)
<FormWithSchema
cy={TAB_ID}
legend={T.AdvancedParams}
id={`${EXTRA_ID}.${TAB_ID}`}
fields={ADVANCED_PARAMS_FIELDS}
/>
</Stack>
<Divider />
</Box>
)
Content.propTypes = {
stepId: PropTypes.string,
}
export default memo(AdvancedParamsSection)
const TAB = {
id: TAB_ID,
name: T.AdvancedOptions,
icon: NetworkIcon,
Content,
getError: (error) => !!error?.[TAB_ID],
}
export default TAB

View File

@ -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 = [

View File

@ -1,152 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { Component, useMemo } from 'react'
import PropTypes from 'prop-types'
import { useFieldArray, useForm, FormProvider } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import {
CUSTOM_ATTRIBUTES_FIELDS,
CUSTOM_ATTRIBUTES_SCHEMA,
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes/schema'
import { FormWithSchema, Legend } from '@modules/components/Forms'
import { Translate, Tr } from '@modules/components/HOC'
import { DeleteCircledOutline, AddCircledOutline } from 'iconoir-react'
import { Stack, FormControl, Divider, Button, Box } from '@mui/material'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import ListItemText from '@mui/material/ListItemText'
import SubmitButton from '@modules/components/FormControl/SubmitButton'
import { T } from '@ConstantsModule'
import { sentenceCase } from '@UtilsModule'
export const SECTION_ID = 'CUSTOM_ATTRIBUTES'
/**
* @param {object} root0 - Params
* @param {string} root0.stepId - Step identifier
* @returns {Component} - Custom Attributes sub-step
*/
const CustomAttributesSection = ({ stepId }) => {
const fields = CUSTOM_ATTRIBUTES_FIELDS
const {
fields: customattributes,
append,
remove,
} = useFieldArray({
name: useMemo(
() => [stepId, SECTION_ID].filter(Boolean).join('.'),
[stepId]
),
})
const methods = useForm({
defaultValues: CUSTOM_ATTRIBUTES_SCHEMA.default(),
resolver: yupResolver(CUSTOM_ATTRIBUTES_SCHEMA),
})
const onSubmit = (newcustomAttribute) => {
append(newcustomAttribute)
methods.reset()
}
if (fields.length === 0) {
return null
}
return (
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
>
<Legend title={T.UserInputs} />
<FormProvider {...methods}>
<Stack
direction="row"
alignItems="flex-start"
gap="0.5rem"
component="form"
onSubmit={methods.handleSubmit(onSubmit)}
>
<FormWithSchema
cy={'extra-customAttributes'}
fields={fields}
rootProps={{ sx: { m: 0 } }}
/>
<Button
variant="contained"
type="submit"
color="secondary"
startIcon={<AddCircledOutline />}
data-cy={'extra-customAttributes'}
sx={{ mt: '1em' }}
>
<Translate word={T.Add} />
</Button>
</Stack>
</FormProvider>
<Divider />
<List>
{customattributes?.map(
({ id, name, defaultvalue, description, mandatory, type }, index) => {
const secondaryFields = [
description && `${Tr(T.Description)}: ${description}`,
defaultvalue && `${Tr(T.DefaultValue)}: ${defaultvalue}`,
type && `${Tr(T.Type)}: ${Tr(sentenceCase(type))}`,
mandatory &&
`${Tr(T.Mandatory)}: ${
mandatory ? `${Tr(T.Yes)}` : `${Tr(T.No)}`
}`,
].filter(Boolean)
return (
<Box
key={id}
sx={{
border: '1px solid #ccc',
borderRadius: '4px',
marginBottom: '8px',
}}
>
<ListItem
secondaryAction={
<SubmitButton
onClick={() => remove(index)}
icon={<DeleteCircledOutline />}
/>
}
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
>
<ListItemText
primary={name}
primaryTypographyProps={{ variant: 'body1' }}
secondary={secondaryFields.join(' | ')}
/>
</ListItem>
</Box>
)
}
)}
</List>
</FormControl>
)
}
CustomAttributesSection.propTypes = {
stepId: PropTypes.string,
}
export default CustomAttributesSection

View File

@ -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
)

View File

@ -14,50 +14,47 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { Component, useMemo } from 'react'
import { useFormContext } from 'react-hook-form'
import PropTypes from 'prop-types'
import { SCHEMA } from './schema'
import { Stack, FormControl, Divider } from '@mui/material'
import NetworkingSection from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking'
import { SCHEMA } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/schema'
import CustomAttributesSection from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes'
import Networking from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking'
import ScheduleActionsSection from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/scheduledActions'
import UserInputs from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/userInputs'
import { FormWithSchema } from '@modules/components/Forms'
import ScheduledActions from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/scheduledActions'
import AdvancedOptions from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams'
import { T } from '@ConstantsModule'
import { ADVANCED_PARAMS_FIELDS } from './advancedParams/schema'
import { BaseTab as Tabs } from '@modules/components/Tabs'
export const STEP_ID = 'extra'
const Content = () =>
useMemo(
() => (
<Stack
display="grid"
gap="1em"
sx={{ gridTemplateColumns: { sm: '1fr', md: '1fr 1fr' } }}
>
<NetworkingSection stepId={STEP_ID} />
<CustomAttributesSection stepId={STEP_ID} />
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
>
<FormWithSchema
id={STEP_ID}
legend={T.AdvancedParams}
fields={ADVANCED_PARAMS_FIELDS}
rootProps={{ sx: { m: 0 } }}
export const TABS = [Networking, UserInputs, ScheduledActions, AdvancedOptions]
const Content = () => {
const { control } = useFormContext()
const tabs = useMemo(
() =>
TABS.map(({ Content: TabContent, name, getError, ...section } = {}) => ({
...section,
name,
label: name,
renderContent: () => (
<TabContent
{...{
control,
}}
/>
<Divider />
</FormControl>
<ScheduleActionsSection />
</Stack>
),
),
})),
[STEP_ID]
)
return <Tabs tabs={tabs} />
}
Content.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,

View File

@ -0,0 +1,65 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
/* eslint-disable react/prop-types */
import { Legend } from '@modules/components/Forms'
import {
Stack,
Grid,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material'
import { T } from '@ConstantsModule'
import {
AR,
SG,
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections'
export const SECTION_ID = 'networks_values'
export const ExtraDropdown = ({ networksValues, selectedNetwork }) => (
<Accordion
variant="transparent"
defaultExpanded={
!!Object.values(networksValues?.[selectedNetwork] ?? {})?.flat()?.length
}
TransitionProps={{ unmountOnExit: false }}
sx={{
width: '100%',
}}
>
<AccordionSummary sx={{ width: '100%' }}>
<Legend disableGutters title={T.Extra} />
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={4}>
{[AR, SG].map(({ Section }, idx) => (
<Grid key={`section-${idx}`} item md={6}>
<Stack direction="column" spacing={2}>
<Section selectedNetwork={selectedNetwork} />
</Stack>
</Grid>
))}
</Grid>
</AccordionDetails>
</Accordion>
)
export default ExtraDropdown

View File

@ -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
})

View File

@ -0,0 +1,77 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
/* eslint-disable react/prop-types */
import { useFieldArray } from 'react-hook-form'
import { Stack } from '@mui/material'
import AddressRangeCard from '@modules/components/Cards/AddressRangeCard'
import {
AddAddressRangeAction,
UpdateAddressRangeAction,
DeleteAddressRangeAction,
} from '@modules/components/Buttons'
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
import { SECTION_ID as EXTRA_SECTION_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown'
const SECTION_ID = 'AR'
const AddressRanges = ({ selectedNetwork }) => {
const {
fields: addressRanges,
remove,
append,
update,
} = useFieldArray({
name: `${EXTRA_ID}.${EXTRA_SECTION_ID}.${selectedNetwork}.${SECTION_ID}`,
})
const handleSubmit = (data) => append(data)
const handleUpdate = (idx, data) => update(idx, data)
const handleRemove = (idx) => remove(idx)
return (
<>
<AddAddressRangeAction onSubmit={handleSubmit} />
<Stack direction="column" spacing={1}>
{addressRanges?.map((ar, idx) => (
<AddressRangeCard
key={`ar-${idx}`}
ar={ar}
actions={
<>
<UpdateAddressRangeAction
ar={{ ...ar, AR_ID: idx }}
onSubmit={(updatedAr) => handleUpdate(idx, updatedAr)}
/>
<DeleteAddressRangeAction
ar={{ ...ar, AR_ID: idx }}
onSubmit={handleRemove}
/>
</>
}
/>
))}
</Stack>
</>
)
}
export const AR = {
Section: AddressRanges,
id: SECTION_ID,
}

View File

@ -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'

View File

@ -0,0 +1,113 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
/* eslint-disable react/prop-types */
import { useEffect, useRef } from 'react'
import { useFieldArray } from 'react-hook-form'
import { Stack } from '@mui/material'
import { SecurityGroupCard } from '@modules/components/Cards'
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
import { SECTION_ID as EXTRA_SECTION_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown'
import ButtonToTriggerForm from '@modules/components/Forms/ButtonToTriggerForm'
import { ChangeForm as AddSgForm } from '@modules/components/Forms/SecurityGroups'
import { Plus as AddIcon } from 'iconoir-react/dist'
import { SecurityGroupAPI } from '@FeaturesModule'
import { T } from '@ConstantsModule'
const SECTION_ID = 'SECURITY_GROUPS'
const SecurityGroups = ({ selectedNetwork }) => {
const loadedInitial = useRef(false)
const { data: fetchedGroups, isSuccess: fetchedSecGroups } =
SecurityGroupAPI.useGetSecGroupsQuery()
const {
fields: secGroups,
append,
replace,
} = useFieldArray({
name: `${EXTRA_ID}.${EXTRA_SECTION_ID}.${selectedNetwork}.${SECTION_ID}`,
})
const handleAdd = ({ secgroups }) =>
secgroups.forEach(async (group) => {
const foundGroup = fetchedGroups?.find(({ ID }) => ID === group)
foundGroup && append(foundGroup)
})
useEffect(() => {
if (loadedInitial.current) return
if (!fetchedSecGroups) return
if (!secGroups) return
const validateKeys = ['NAME', 'GNAME', 'UNAME']
const invalidGroups = secGroups?.filter(
(group) => !validateKeys?.some((key) => Object.hasOwn(group, key))
)
const patchedGroups = invalidGroups?.map(({ ID }) =>
fetchedGroups?.find((group) => group?.ID === ID)
)
if (patchedGroups?.length) {
replace(patchedGroups)
}
loadedInitial.current = true
}, [secGroups, fetchedGroups])
return (
<>
<ButtonToTriggerForm
buttonProps={{
color: 'secondary',
'data-cy': 'add-sg',
startIcon: <AddIcon />,
label: T.SecurityGroup,
variant: 'outlined',
}}
options={[
{
dialogProps: {
title: T.SecurityGroup,
dataCy: 'modal-add-sg',
},
form: () => AddSgForm(),
onSubmit: handleAdd,
},
]}
/>
<Stack direction="column" spacing={1}>
{secGroups.map((sg, idx) => (
<SecurityGroupCard key={`sg-${idx}`} securityGroup={sg} />
))}
</Stack>
</>
)
}
export const SG = {
Section: SecurityGroups,
id: SECTION_ID,
}

View File

@ -14,139 +14,230 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { Component, useMemo } from 'react'
import { useFieldArray, useForm, FormProvider } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import { useEffect, useState } from 'react'
import { useFormContext, useFieldArray } from 'react-hook-form'
import { ServerConnection as NetworkIcon, Cancel } from 'iconoir-react'
import {
NETWORK_INPUT_FIELDS,
NETWORK_INPUT_SCHEMA,
NETWORK_SELECTION,
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/schema'
import { FormWithSchema, Legend } from '@modules/components/Forms'
import { Translate, Tr } from '@modules/components/HOC'
import { DeleteCircledOutline, AddCircledOutline } from 'iconoir-react'
import { Stack, FormControl, Divider, Button, Box } from '@mui/material'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import ListItemText from '@mui/material/ListItemText'
import SubmitButton from '@modules/components/FormControl/SubmitButton'
import {
ExtraDropdown,
SECTION_ID as NETWORKS_VALUES_ID,
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown'
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
import { FormWithSchema } from '@modules/components/Forms'
import { Tr } from '@modules/components/HOC'
import { Stack, Button, Grid, List, ListItem, IconButton } from '@mui/material'
import { T } from '@ConstantsModule'
import { sentenceCase } from '@UtilsModule'
export const SECTION_ID = 'NETWORKING'
import {
AR,
SG,
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/extraDropdown/sections'
/**
* @param {object} root0 - Params
* @param {string} root0.stepId - Step identifier
* @returns {Component} - Networking sub-section
*/
const NetworkingSection = ({ stepId }) => {
const fields = useMemo(() => NETWORK_INPUT_FIELDS)
export const TAB_ID = 'networks'
const Content = () => {
const { watch } = useFormContext()
// Updates in real-time compared to the snapshot from the fieldArray hook
const wNetworks = watch(`${EXTRA_ID}.${TAB_ID}`)
const wNetworksValues = watch(`${EXTRA_ID}.${NETWORKS_VALUES_ID}`)
const {
fields: networks,
append,
remove,
append: appendNet,
remove: rmNet,
} = useFieldArray({
name: useMemo(
() => [stepId, SECTION_ID].filter(Boolean).join('.'),
[stepId]
),
name: `${EXTRA_ID}.${TAB_ID}`,
})
const methods = useForm({
defaultValues: NETWORK_INPUT_SCHEMA.default(),
resolver: yupResolver(NETWORK_INPUT_SCHEMA),
const { append: appendNetv, remove: rmNetv } = useFieldArray({
name: `${EXTRA_ID}.${NETWORKS_VALUES_ID}`,
})
const onSubmit = async (newNetwork) => {
const isValid = await methods.trigger()
if (isValid) {
append(newNetwork)
methods.reset()
const [selectedNetwork, setSelectedNetwork] = useState(0)
const [shift, setShift] = useState(0)
const handleRemove = (event, idx) => {
event.stopPropagation()
// Calculates shift & releases current reference in case it goes oob
setSelectedNetwork((prev) => {
setShift(prev + (networks?.length === 2 ? -+prev : idx < prev ? -1 : 0))
return null
})
// Remove corresponding entry from networks_values array
rmNetv(idx)
rmNet(idx)
}
// Very important, define all fields or else RHF uses previous input data
const handleAppend = (event) => {
event?.stopPropagation?.()
setSelectedNetwork(() => {
setShift(null)
return null
})
appendNet({
name: '',
description: '',
network: null,
size: null,
type: null,
})
appendNetv({
[AR.id]: [],
[SG.id]: [],
})
}
// Shifts selected index after networks array has been updated
useEffect(() => {
if (selectedNetwork === null) {
if (shift === null) {
setSelectedNetwork(networks?.length - 1)
} else {
setSelectedNetwork(shift)
}
}
}
if (fields.length === 0) {
return null
}
}, [networks])
return (
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
>
<Legend title={T.Networks} />
<FormProvider {...methods}>
<Stack
direction="row"
alignItems="flex-start"
gap="0.5rem"
component="form"
onSubmit={methods.handleSubmit(onSubmit)}
>
<FormWithSchema
cy={'extra-networking'}
fields={fields}
rootProps={{ sx: { m: 0 } }}
/>
<>
<Grid
container
direction="row"
columnSpacing={1}
rowSpacing={2}
sx={{
justifyContent: 'flex-start',
alignItems: 'stretch',
height: '100%',
}}
>
<Grid item md={3} sx={{ borderRight: 1, padding: 1 }}>
<Button
variant="contained"
variant="outlined"
color="primary"
type="submit"
color="secondary"
startIcon={<AddCircledOutline />}
data-cy={'extra-networking'}
sx={{ mt: '1em' }}
size="large"
data-cy={'extra-add-network'}
onClick={handleAppend}
>
<Translate word={T.Add} />
{Tr(T.AddNetwork)}
</Button>
</Stack>
</FormProvider>
<Divider />
<List>
{networks?.map(
({ name, description, netextra, id, network, type }, index) => {
const secondaryFields = [
description && `${Tr(T.Description)}: ${description}`,
type && `${Tr(T.Type)}: ${Tr(sentenceCase(type))}`,
network && `${Tr(T.Network)}: ${network}`,
netextra && `${Tr(T.Extra)}: ${netextra}`,
].filter(Boolean)
<List
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{networks?.map((network, idx) => {
const networkName = watch(`${EXTRA_ID}.${TAB_ID}.${idx}.name`)
return (
<Box
key={id}
sx={{
border: '1px solid #ccc',
borderRadius: '4px',
marginBottom: '8px',
}}
>
return (
<ListItem
secondaryAction={
<SubmitButton
onClick={() => remove(index)}
icon={<DeleteCircledOutline />}
/>
}
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
key={`item-${idx}-${network.id}`}
onClick={() => setSelectedNetwork(idx)}
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: '4px',
minHeight: '70px',
my: 0.5,
overflowX: 'hidden',
padding: 2,
bgcolor:
idx === selectedNetwork ? 'action.selected' : 'inherit',
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<ListItemText
primary={name}
primaryTypographyProps={{ variant: 'body1' }}
secondary={secondaryFields.join(' | ')}
/>
<IconButton
aria-label="delete"
onClick={(event) => handleRemove(event, idx)}
sx={{ mr: 1.5, size: 'small' }}
>
<Cancel />
</IconButton>
<div
style={{
display: 'inline-block',
maxWidth: '100%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontSize: '1em',
}}
>
{networkName || T.NewNetwork}
</div>
</ListItem>
</Box>
)
}
)}
</List>
</FormControl>
)
})}
</List>
</Grid>
<Grid item md={9}>
{selectedNetwork != null && wNetworks?.length > 0 && (
<>
<Stack
key={`inputs-${networks?.[selectedNetwork]?.id}`}
direction="column"
alignItems="flex-start"
gap="0.5rem"
component="form"
width="100%"
>
<FormWithSchema
legend={T.Type}
id={`${EXTRA_ID}.${TAB_ID}.${selectedNetwork}`}
cy={`${TAB_ID}`}
fields={NETWORK_INPUT_FIELDS}
/>
</Stack>
<ExtraDropdown
networksValues={wNetworksValues}
key={`extra-${networks?.[selectedNetwork]?.id}`}
selectedNetwork={selectedNetwork}
/>
<FormWithSchema
key={`network-table-${networks?.[selectedNetwork]?.id}`}
cy={`${TAB_ID}-${NETWORK_SELECTION?.name}`}
id={`${EXTRA_ID}.${TAB_ID}.${selectedNetwork}`}
fields={[NETWORK_SELECTION]}
/>
</>
)}
</Grid>
</Grid>
</>
)
}
NetworkingSection.propTypes = {
Content.propTypes = {
stepId: PropTypes.string,
}
export default NetworkingSection
const TAB = {
id: TAB_ID,
name: T.Networks,
icon: NetworkIcon,
Content,
getError: (error) => !!error?.[TAB_ID],
}
export default TAB

View File

@ -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 }

View File

@ -13,135 +13,213 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { Box, Stack, Divider } from '@mui/material'
import { useFieldArray } from 'react-hook-form'
import { array, object } from 'yup'
import { ScheduleActionCard } from '@modules/components/Cards'
import {
CreateSchedButton,
CharterButton,
UpdateSchedButton,
DeleteSchedButton,
} from '@modules/components/Buttons/ScheduleAction'
import PropTypes from 'prop-types'
import { T } from '@ConstantsModule'
import { Legend } from '@modules/components/Forms'
import { useState, useEffect } from 'react'
import { useFormContext, useFieldArray } from 'react-hook-form'
import { ServerConnection as NetworkIcon, Cancel } from 'iconoir-react'
import { mapNameByIndex } from '@modules/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema'
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
import { Component } from 'react'
import { FormWithSchema } from '@modules/components/Forms'
export const TAB_ID = 'SCHED_ACTION'
import { VM_SCHED_FIELDS } from '@modules/components/Forms/Vm/CreateSchedActionForm/schema'
const mapNameFunction = mapNameByIndex('SCHED_ACTION')
import { Tr } from '@modules/components/HOC'
import { Stack, Button, Grid, List, ListItem, IconButton } from '@mui/material'
import { T } from '@ConstantsModule'
export const SCHED_ACTION_SCHEMA = object({
SCHED_ACTION: array()
.ensure()
.transform((actions) => actions.map(mapNameByIndex('SCHED_ACTION'))),
})
export const TAB_ID = 'sched_actions'
const Content = () => {
const [selectedSchedAction, setSelectedSchedAction] = useState(0)
const [shift, setShift] = useState(0)
const { watch } = useFormContext()
const wSchedActions = watch(`${EXTRA_ID}.${TAB_ID}`)
/**
* @param {object} root0 - Props
* @param {object} root0.oneConfig - One config
* @param {object} root0.adminGroup - oneadmin group
* @returns {Component} - Scheduled actions component
*/
const ScheduleActionsSection = ({ oneConfig, adminGroup }) => {
const {
fields: scheduleActions,
fields: schedActions,
remove,
update,
append,
} = useFieldArray({
name: `${EXTRA_ID}.${TAB_ID}`,
keyName: 'ID',
})
const handleCreateAction = (action) => {
append(mapNameFunction(action, scheduleActions.length))
const handleRemove = (event, idx) => {
event.stopPropagation()
// Calculates shift & releases current reference in case it goes oob
setSelectedSchedAction((prev) => {
setShift(
prev + (schedActions?.length === 2 ? -+prev : idx < prev ? -1 : 0)
)
return null
})
remove(idx)
}
const handleCreateCharter = (actions) => {
const mappedActions = actions?.map((action, idx) =>
mapNameFunction(action, scheduleActions.length + idx)
)
const handleAppend = (event) => {
event?.stopPropagation?.()
append(mappedActions)
setSelectedSchedAction(() => {
setShift(null)
return null
})
append({
ACTION: '',
PERIODIC: '',
TIME: '',
})
}
const handleUpdate = (action, index) => {
update(index, mapNameFunction(action, index))
}
const handleRemove = (index) => {
remove(index)
}
useEffect(() => {
if (selectedSchedAction === null) {
if (shift === null) {
setSelectedSchedAction(schedActions?.length - 1)
} else {
setSelectedSchedAction(shift)
}
}
}, [schedActions])
return (
<>
<Legend title={T.ScheduledActions} />
<Box sx={{ width: '100%', gridColumn: '1 / -1' }}>
<Stack flexDirection="row" gap="1em">
<CreateSchedButton
relative
onSubmit={handleCreateAction}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
<CharterButton relative onSubmit={handleCreateCharter} />
</Stack>
<Grid
container
direction="row"
columnSpacing={1}
rowSpacing={2}
sx={{
justifyContent: 'flex-start',
alignItems: 'stretch',
height: '100%',
}}
>
<Grid item md={3} sx={{ borderRight: 1, padding: 1 }}>
<Button
variant="outlined"
color="primary"
type="submit"
size="large"
data-cy={'extra-add-userinput'}
onClick={handleAppend}
>
{Tr(T.AddScheduleAction)}
</Button>
<List
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{schedActions?.map((userinput, idx) => {
const schedActionType = watch(
`${EXTRA_ID}.${TAB_ID}.${idx}.ACTION`
)
<Stack
pb="1em"
display="grid"
gridTemplateColumns="repeat(auto-fit, minmax(300px, 0.5fr))"
gap="1em"
mt="1em"
>
{scheduleActions?.map((schedule, index) => {
const { ID, NAME } = schedule
const fakeValues = { ...schedule, ID: index }
return (
<ListItem
key={`${idx}-${userinput?.id}-${userinput?.name}`}
onClick={() => setSelectedSchedAction(idx)}
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: '4px',
minHeight: '70px',
my: 0.5,
overflowX: 'hidden',
padding: 2,
return (
<ScheduleActionCard
key={ID ?? NAME}
schedule={fakeValues}
actions={
<>
<UpdateSchedButton
relative
vm={{}}
schedule={fakeValues}
onSubmit={(newAction) => handleUpdate(newAction, index)}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
<DeleteSchedButton
schedule={fakeValues}
onSubmit={() => handleRemove(index)}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
</>
}
bgcolor:
idx === selectedSchedAction
? 'action.selected'
: 'inherit',
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<IconButton
aria-label="delete"
onClick={(event) => handleRemove(event, idx)}
sx={{ mr: 1.5, size: 'small' }}
>
<Cancel />
</IconButton>
<Stack
direction="column"
alignItems="flex-start"
gap="0.2rem"
width="100%"
>
<div
style={{
display: 'inline-block',
maxWidth: '100%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontSize: '1em',
}}
>
{`${T.ScheduleAction}#${idx}`}
</div>
<div
style={{
display: 'inline-block',
maxWidth: '100%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontSize: '0.85em',
opacity: '0.8',
}}
>
{schedActionType || `${T.No} ${T.Type}`}
</div>
</Stack>
</ListItem>
)
})}
</List>
</Grid>
<Grid item md={9}>
{selectedSchedAction != null && wSchedActions?.length > 0 && (
<Stack
direction="column"
alignItems="flex-start"
gap="0.5rem"
component="form"
width="100%"
>
<FormWithSchema
id={`${EXTRA_ID}.${TAB_ID}.${selectedSchedAction}`}
key={`inputs-${selectedSchedAction}`}
fields={VM_SCHED_FIELDS({ vm: {} })}
/>
)
})}
</Stack>
<Divider />
</Box>
</Stack>
)}
</Grid>
</Grid>
</>
)
}
ScheduleActionsSection.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
oneConfig: PropTypes.object,
adminGroup: PropTypes.bool,
Content.propTypes = {
stepId: PropTypes.string,
}
export default ScheduleActionsSection
const TAB = {
id: TAB_ID,
name: T.ScheduledActions,
icon: NetworkIcon,
Content,
getError: (error) => !!error?.[TAB_ID],
}
export default TAB

View File

@ -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)
)

View File

@ -0,0 +1,203 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { useState, useEffect } from 'react'
import { useFormContext, useFieldArray } from 'react-hook-form'
import { ServerConnection as NetworkIcon, Cancel } from 'iconoir-react'
import { USER_INPUTS_FIELDS } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/userInputs/schema'
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
import { FormWithSchema } from '@modules/components/Forms'
import { Tr } from '@modules/components/HOC'
import { Stack, Button, Grid, List, ListItem, IconButton } from '@mui/material'
import { T } from '@ConstantsModule'
export const TAB_ID = 'user_inputs'
const Content = () => {
const [selectedUInput, setSelectedUInput] = useState(0)
const [shift, setShift] = useState(0)
const { watch } = useFormContext()
const wUserInputs = watch(`${EXTRA_ID}.${TAB_ID}`)
const {
fields: userinputs,
remove,
append,
} = useFieldArray({
name: `${EXTRA_ID}.${TAB_ID}`,
})
const handleRemove = (event, idx) => {
event.stopPropagation()
// Calculates shift & releases current reference in case it goes oob
setSelectedUInput((prev) => {
setShift(prev + (userinputs?.length === 2 ? -+prev : idx < prev ? -1 : 0))
return null
})
remove(idx)
}
const handleAppend = (event) => {
event?.stopPropagation?.()
setSelectedUInput(() => {
setShift(null)
return null
})
append({
type: '',
mandatory: false,
name: '',
description: '',
options: '',
default: '',
})
}
useEffect(() => {
if (selectedUInput === null) {
if (shift === null) {
setSelectedUInput(userinputs?.length - 1)
} else {
setSelectedUInput(shift)
}
}
}, [userinputs])
return (
<>
<Grid
container
direction="row"
columnSpacing={1}
rowSpacing={2}
sx={{
justifyContent: 'flex-start',
alignItems: 'stretch',
height: '100%',
}}
>
<Grid item md={3} sx={{ borderRight: 1, padding: 1 }}>
<Button
variant="outlined"
color="primary"
type="submit"
size="large"
data-cy={'extra-add-userinput'}
onClick={handleAppend}
>
{Tr(T.AddUserInput)}
</Button>
<List
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{userinputs?.map((userinput, idx) => {
const userinputName = watch(`${EXTRA_ID}.${TAB_ID}.${idx}.name`)
return (
<ListItem
key={`${idx}-${userinput?.id}-${userinput?.name}`}
onClick={() => setSelectedUInput(idx)}
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: '4px',
minHeight: '70px',
my: 0.5,
overflowX: 'hidden',
padding: 2,
bgcolor:
idx === selectedUInput ? 'action.selected' : 'inherit',
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
{
<IconButton
aria-label="delete"
onClick={(event) => handleRemove(event, idx)}
sx={{ mr: 1.5, size: 'small' }}
>
<Cancel />
</IconButton>
}
<div
style={{
display: 'inline-block',
maxWidth: '100%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontSize: '1em',
}}
>
{userinputName || T.NewUserInput}
</div>
</ListItem>
)
})}
</List>
</Grid>
<Grid item md={9}>
{selectedUInput != null && wUserInputs?.length > 0 && (
<Stack
key={`inputs-${userinputs?.[selectedUInput]?.id}`}
direction="column"
alignItems="flex-start"
gap="0.5rem"
component="form"
width="100%"
>
<FormWithSchema
id={`${EXTRA_ID}.${TAB_ID}.${selectedUInput}`}
legend={T.Type}
fields={USER_INPUTS_FIELDS}
/>
</Stack>
)}
</Grid>
</Grid>
</>
)
}
Content.propTypes = {
stepId: PropTypes.string,
}
const TAB = {
id: TAB_ID,
name: T.UserInputs,
icon: NetworkIcon,
Content,
getError: (error) => !!error?.[TAB_ID],
}
export default TAB

View File

@ -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
])

View File

@ -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,

View File

@ -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',

View File

@ -1,112 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { useMemo, useEffect, Component } from 'react'
import { useFormContext } from 'react-hook-form'
import { ADVANCED_PARAMS_FIELDS } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters/schema'
import { FormWithSchema } from '@modules/components/Forms'
import {
Accordion,
AccordionSummary,
AccordionDetails,
FormControl,
Box,
Typography,
useTheme,
} from '@mui/material'
import { T } from '@ConstantsModule'
import { Tr } from '@modules/components/HOC'
export const SECTION_ID = 'ADVANCEDPARAMS'
/**
* @param {object} root0 - props
* @param {string} root0.stepId - Main step ID
* @param {object} root0.roleConfigs - Roles config
* @param {Function} root0.onChange - Callback handler
* @returns {Component} - component
*/
const AdvancedParametersSection = ({ stepId, roleConfigs, onChange }) => {
const { watch, setValue } = useFormContext()
const { palette } = useTheme()
const fields = useMemo(() => ADVANCED_PARAMS_FIELDS, [stepId])
useEffect(() => {
setValue(
`${stepId}.${SECTION_ID}.SHUTDOWNTYPE`,
roleConfigs?.[SECTION_ID]?.[0] ?? ''
)
}, [roleConfigs])
const shutdownTypeValue = watch(`${stepId}.${SECTION_ID}.SHUTDOWNTYPE`)
useEffect(() => {
if (shutdownTypeValue) {
onChange('update', { [SECTION_ID]: shutdownTypeValue }, false)
}
}, [shutdownTypeValue])
if (fields.length === 0) {
return null
}
return (
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
>
<Accordion>
<AccordionSummary
aria-controls="panel-content"
id="panel-header"
sx={{
backgroundColor: palette?.background?.paper,
filter: 'brightness(90%)',
}}
>
<Typography variant="body1">{Tr(T.AdvancedParams)}</Typography>
</AccordionSummary>
<AccordionDetails>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
width: '100%',
}}
>
<Box component="form" sx={{ flexGrow: 1, display: 'flex' }}>
<FormWithSchema
id={stepId}
cy={'extra-networking'}
fields={fields}
rootProps={{ sx: { m: 0 } }}
/>
</Box>
</Box>
</AccordionDetails>
</Accordion>
</FormControl>
)
}
AdvancedParametersSection.propTypes = {
selectedRoleIndex: PropTypes.number,
stepId: PropTypes.string,
roleConfigs: PropTypes.object,
onChange: PropTypes.func,
}
export default AdvancedParametersSection

View File

@ -1,203 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { yupResolver } from '@hookform/resolvers/yup'
import { useMemo, Component } from 'react'
import {
useForm,
useFieldArray,
FormProvider,
useFormContext,
} from 'react-hook-form'
import {
createElasticityPolicyFields,
createElasticityPoliciesSchema,
ELASTICITY_TYPES,
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies/schema'
import { FormWithSchema } from '@modules/components/Forms'
import { Translate, Tr } from '@modules/components/HOC'
import { DeleteCircledOutline, AddCircledOutline } from 'iconoir-react'
import {
Accordion,
AccordionSummary,
AccordionDetails,
FormControl,
Button,
Box,
Typography,
useTheme,
} from '@mui/material'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import ListItemText from '@mui/material/ListItemText'
import SubmitButton from '@modules/components/FormControl/SubmitButton'
import { T } from '@ConstantsModule'
export const SECTION_ID = 'ELASTICITYPOLICIES'
/**
* @param {object} root0 - props
* @param {string} root0.stepId - Main step ID
* @param {number} root0.selectedRoleIndex - Active role index
* @returns {Component} - component
*/
const ElasticityPoliciesSection = ({ stepId, selectedRoleIndex }) => {
const { palette } = useTheme()
const fields = createElasticityPolicyFields()
const schema = createElasticityPoliciesSchema()
const { getValues } = useFormContext()
const { append, remove } = useFieldArray({
name: useMemo(
() => `${stepId}.${SECTION_ID}.${selectedRoleIndex}`,
[stepId, selectedRoleIndex]
),
})
const methods = useForm({
defaultValues: schema.default(),
resolver: yupResolver(schema),
})
const onSubmit = async (newPolicy) => {
const isValid = await methods.trigger(`${stepId}.${SECTION_ID}`)
if (isValid) {
append(newPolicy)
methods.reset()
}
}
const currentPolicies =
getValues(`${stepId}.${SECTION_ID}.${selectedRoleIndex}`) ?? []
if (fields.length === 0) {
return null
}
return (
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
>
<Accordion>
<AccordionSummary
aria-controls="panel-content"
id="panel-header"
data-cy="roleconfig-elasticitypolicies-accordion"
sx={{
backgroundColor: palette?.background?.paper,
filter: 'brightness(90%)',
}}
>
<Typography variant="body1">{Tr(T.ElasticityPolicies)}</Typography>
</AccordionSummary>
<AccordionDetails>
<FormProvider {...methods}>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
width: '100%',
}}
>
<Box
component="form"
onSubmit={methods.handleSubmit(onSubmit)}
sx={{
width: '100%',
}}
>
<FormWithSchema fields={fields} rootProps={{ sx: { m: 0 } }} />
<Button
variant="contained"
size="large"
type="submit"
color="secondary"
startIcon={<AddCircledOutline />}
data-cy={'roleconfig-elasticitypolicies'}
sx={{ width: '100%', mt: 2 }}
>
<Translate word={T.Add} />
</Button>
</Box>
</Box>
<List sx={{ mt: 2 }}>
{currentPolicies.map(
(
{
TYPE,
ADJUST,
MIN,
COOLDOWN,
PERIOD_NUMBER,
PERIOD,
EXPRESSION,
},
index
) => {
const secondaryFields = [
EXPRESSION && `${Tr(T.Expression)}: ${EXPRESSION}`,
ADJUST && `${Tr(T.Adjust)}: ${ADJUST}`,
COOLDOWN && `${Tr(T.Cooldown)}: ${COOLDOWN}`,
PERIOD && `${Tr(T.Period)}: ${PERIOD}`,
PERIOD_NUMBER && `#: ${PERIOD_NUMBER}`,
].filter(Boolean)
if (MIN !== undefined && TYPE === 'PERCENTAGE_CHANGE') {
secondaryFields.push(`${Tr(T.Min)}: ${MIN}`)
}
return (
<Box
key={index}
sx={{
border: '1px solid #ccc',
borderRadius: '4px',
marginBottom: '6px',
}}
>
<ListItem
secondaryAction={
<SubmitButton
onClick={() => remove(index)}
icon={<DeleteCircledOutline />}
/>
}
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
>
<ListItemText
primary={Tr(ELASTICITY_TYPES?.[TYPE])}
primaryTypographyProps={{ variant: 'body1' }}
secondary={secondaryFields.join(' | ')}
/>
</ListItem>
</Box>
)
}
)}
</List>
</FormProvider>
</AccordionDetails>
</Accordion>
</FormControl>
)
}
ElasticityPoliciesSection.propTypes = {
stepId: PropTypes.string,
selectedRoleIndex: PropTypes.number,
}
export default ElasticityPoliciesSection

View File

@ -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))
}

View File

@ -1,64 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { Component, useMemo } from 'react'
import { Box, FormControl } from '@mui/material'
import { createMinMaxVmsFields } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms/schema'
import { FormWithSchema } from '@modules/components/Forms'
import { useFieldArray, useFormContext } from 'react-hook-form'
export const SECTION_ID = 'MINMAXVMS'
/**
* @param {object} root0 - props
* @param {string} root0.stepId - Main step ID
* @param {number} root0.selectedRoleIndex - Active role index
* @returns {Component} - component
*/
const MinMaxVms = ({ stepId, selectedRoleIndex }) => {
const { control } = useFormContext()
const fields = createMinMaxVmsFields(
`${stepId}.${SECTION_ID}.${selectedRoleIndex}`
)
useFieldArray({
name: useMemo(() => `${stepId}.${SECTION_ID}`, [stepId, selectedRoleIndex]),
control: control,
})
if (fields.length === 0) {
return null
}
return (
<Box>
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
>
<FormWithSchema fields={fields} rootProps={{ sx: { m: 0 } }} />
</FormControl>
</Box>
)
}
MinMaxVms.propTypes = {
stepId: PropTypes.string,
selectedRoleIndex: PropTypes.number,
}
export default MinMaxVms

View File

@ -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))
}

View File

@ -1,197 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { Component, useMemo } from 'react'
import { yupResolver } from '@hookform/resolvers/yup'
import {
useFormContext,
useForm,
useFieldArray,
FormProvider,
} from 'react-hook-form'
import {
createScheduledPolicyFields,
createScheduledPoliciesSchema,
SCHED_TYPES,
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies/schema'
import { FormWithSchema } from '@modules/components/Forms'
import { Translate, Tr } from '@modules/components/HOC'
import { DeleteCircledOutline, AddCircledOutline } from 'iconoir-react'
import {
Accordion,
AccordionSummary,
AccordionDetails,
FormControl,
Button,
Box,
Typography,
useTheme,
} from '@mui/material'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import ListItemText from '@mui/material/ListItemText'
import SubmitButton from '@modules/components/FormControl/SubmitButton'
import { T } from '@ConstantsModule'
export const SECTION_ID = 'SCHEDULEDPOLICIES'
/**
* @param {object} root0 - props
* @param {string} root0.stepId - Main step ID
* @param {number} root0.selectedRoleIndex - Active role index
* @returns {Component} - component
*/
const ScheduledPoliciesSection = ({ stepId, selectedRoleIndex }) => {
const { palette } = useTheme()
const fields = createScheduledPolicyFields()
const schema = createScheduledPoliciesSchema()
const { getValues } = useFormContext()
const { append, remove } = useFieldArray({
name: useMemo(
() => `${stepId}.${SECTION_ID}.${selectedRoleIndex}`,
[stepId, selectedRoleIndex]
),
})
const methods = useForm({
defaultValues: schema.default(),
resolver: yupResolver(schema),
})
const onSubmit = async (newPolicy) => {
const isValid = await methods.trigger(`${stepId}.${SECTION_ID}`)
if (isValid) {
append(newPolicy)
methods.reset()
}
}
const currentPolicies =
getValues(`${stepId}.${SECTION_ID}.${selectedRoleIndex}`) ?? []
if (fields.length === 0) {
return null
}
return (
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
>
<Accordion>
<AccordionSummary
aria-controls="panel-content"
id="panel-header"
data-cy="roleconfig-scheduledpolicies-accordion"
sx={{
backgroundColor: palette?.background?.paper,
filter: 'brightness(90%)',
}}
>
<Typography variant="body1">{Tr(T.ScheduledPolicies)}</Typography>
</AccordionSummary>
<AccordionDetails>
<FormProvider {...methods}>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
width: '100%',
}}
>
<Box
component="form"
onSubmit={methods.handleSubmit(onSubmit)}
sx={{ width: '100%' }}
>
<FormWithSchema fields={fields} rootProps={{ sx: { m: 0 } }} />
<Button
variant="contained"
size="large"
type="submit"
color="secondary"
startIcon={<AddCircledOutline />}
data-cy={'roleconfig-scheduledpolicies'}
sx={{ width: '100%', mt: 2 }}
>
<Translate word={T.Add} />
</Button>
</Box>
</Box>
<List>
{currentPolicies.map(
(
{ TIMEFORMAT, SCHEDTYPE, ADJUST, MIN, TIMEEXPRESSION },
index
) => {
const timeFormatTrans = Tr(TIMEFORMAT)
const secondaryFields = [
TIMEEXPRESSION &&
`${Tr(T.TimeExpression)}: ${TIMEEXPRESSION}`,
ADJUST && `${Tr(T.Adjust)}: ${ADJUST}`,
timeFormatTrans &&
`${Tr(T.TimeFormat)}: ${timeFormatTrans}`,
].filter(Boolean)
if (MIN !== undefined) {
secondaryFields?.push(`${Tr(T.Min)}: ${MIN}`)
}
return (
<Box
key={index}
sx={{
border: '1px solid #ccc',
borderRadius: '4px',
marginBottom: '6px',
}}
>
<ListItem
secondaryAction={
<SubmitButton
onClick={() => remove(index)}
icon={<DeleteCircledOutline />}
/>
}
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
>
<ListItemText
primary={Tr(SCHED_TYPES?.[SCHEDTYPE])}
primaryTypographyProps={{ variant: 'body1' }}
secondary={secondaryFields.join(' | ')}
/>
</ListItem>
</Box>
)
}
)}
</List>
</FormProvider>
</AccordionDetails>
</Accordion>
</FormControl>
)
}
ScheduledPoliciesSection.propTypes = {
stepId: PropTypes.string,
selectedRoleIndex: PropTypes.number,
}
export default ScheduledPoliciesSection

View File

@ -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))
}

View File

@ -1,308 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { useState, useRef, Component } from 'react'
import _ from 'lodash'
import { useFormContext, useForm, FormProvider } from 'react-hook-form'
import { Box, Button, Grid } from '@mui/material'
import { SCHEMA } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/schema'
import RoleColumn from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesColumn'
import RoleSummary from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/roleSummary'
import RoleNetwork from './roleNetwork'
import { STEP_ID as ROLE_DEFINITION_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles'
import ElasticityPoliciesSection from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies'
import ScheduledPoliciesSection from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies'
import AdvancedParametersSection from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters'
import VmTemplatesPanel from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/vmTemplatesPanel'
import MinMaxSection from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms'
import { Translate } from '@modules/components/HOC'
import FormWithSchema from '@modules/components/Forms/FormWithSchema'
import { Legend } from '@modules/components/Forms'
import { yupResolver } from '@hookform/resolvers/yup'
import { INPUT_TYPES, T } from '@ConstantsModule'
import { AddCircledOutline } from 'iconoir-react'
import { Field, getObjectSchemaFromFields } from '@UtilsModule'
import { string, number } from 'yup'
export const STEP_ID = 'roleconfig'
/** @type {Field} STANDALONE Name field */
const STANDALONE_NAME_FIELD = {
name: `${STEP_ID}.name`,
label: T.Name,
cy: 'role',
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.min(3, 'Role name cannot be less than 3 characters')
.max(128, 'Role name cannot be over 128 characters')
.required('Role name cannot be empty')
.default(() => undefined),
grid: { md: 8 },
}
/** @type {Field} STANDALONE Cardinality field */
const STANDALONE_CARDINALITY_FIELD = {
name: `${STEP_ID}.cardinality`,
label: T.NumberOfVms,
cy: 'role',
type: INPUT_TYPES.TEXT,
fieldProps: {
type: 'number',
},
validation: number()
.positive('Number of VMs must be positive')
.default(() => 1),
grid: { md: 4 },
}
const STANDALONE_SCHEMA = getObjectSchemaFromFields([
STANDALONE_NAME_FIELD,
STANDALONE_CARDINALITY_FIELD,
])
/**
* @param {object} root0 - Props
* @param {boolean} root0.standaloneModal - Run as standalone modal
* @param {Function} root0.standaloneModalCallback - API callback function
* @param {object} root0.fetchedVmTemplates - Fetched VM templates
* @returns {Component} - Role configuration component
*/
export const Content = ({
standaloneModal = false,
standaloneModalCallback = () => {},
fetchedVmTemplates = {},
}) => {
const { vmTemplates, error } = fetchedVmTemplates
const [standaloneRole, setStandaloneRole] = useState([
{ SELECTED_VM_TEMPLATE_ID: [] },
])
const HANDLE_VM_SELECT_STANDALONE_ROLE = (updatedRole) => {
setStandaloneRole(updatedRole)
}
const formMethods = standaloneModal
? useForm({
defaultValues: SCHEMA.concat(STANDALONE_SCHEMA).default(),
resolver: yupResolver(STANDALONE_SCHEMA),
mode: 'onChange',
})
: useFormContext()
const { getValues, setValue } = formMethods
const handleAddRoleClick = async () => {
const role = getValues(STEP_ID)
const formatRole = {
name: role?.name,
cardinality: role?.cardinality,
vm_template: standaloneRole?.SELECTED_VM_TEMPLATE_ID?.[0],
...(role?.ADVANCEDPARAMS?.SHUTDOWNTYPE && {
shutdown_type: role.ADVANCEDPARAMS.SHUTDOWNTYPE,
}),
min_vms: +role?.MINMAXVMS?.[0]?.min_vms,
max_vms: +role?.MINMAXVMS?.[0]?.max_vms,
cooldown: role?.MINMAXVMS?.[0]?.cooldown,
...(role?.ELASTICITYPOLICIES && {
elasticity_policies: role?.ELASTICITYPOLICIES?.[0],
}),
...(role?.SCHEDULEDPOLICIES && {
scheduled_policies: role?.SCHEDULEDPOLICIES?.[0],
}),
}
standaloneModalCallback({ role: formatRole })
}
const definedConfigs = getValues(`${STEP_ID}.ROLES`)
const roleConfigs = useRef(definedConfigs ?? [])
/**
*
*/
const syncFormState = () => {
setValue(`${STEP_ID}.ROLES`, roleConfigs.current)
}
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0)
const roles = getValues(ROLE_DEFINITION_ID)
const handleConfigChange = (operationType, config, shouldReset = false) => {
const configKey = Object.keys(config)[0]
const configValue = Object.values(config)[0]
_.defaultsDeep(roleConfigs.current, {
[selectedRoleIndex]: { [configKey]: [] },
})
switch (operationType) {
case 'add':
_.get(roleConfigs.current, [selectedRoleIndex, configKey]).push(
configValue
)
break
case 'remove':
_.remove(
_.get(roleConfigs.current, [selectedRoleIndex, configKey]),
(_v, index) => index === configValue
)
break
case 'update':
_.set(
roleConfigs.current,
[selectedRoleIndex, configKey, 0],
configValue
)
break
}
syncFormState()
}
const ComponentContent = (
<Grid mt={2} container>
{!standaloneModal && (
<Grid item xs={2.2}>
<RoleColumn
roles={roles}
selectedRoleIndex={selectedRoleIndex}
setSelectedRoleIndex={setSelectedRoleIndex}
disableModify={true}
onChange={handleConfigChange}
/>
</Grid>
)}
<Grid item xs={standaloneModal ? 12 : 7}>
<Box
margin={1}
sx={{
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
}}
>
{!standaloneModal && (
<RoleNetwork
stepId={STEP_ID}
selectedRoleIndex={selectedRoleIndex}
totalRoles={roles?.length}
/>
)}
<Box mt={2}>
<Legend title={T.RoleElasticity} />
{standaloneModal && (
<Box>
<FormWithSchema
fields={[STANDALONE_NAME_FIELD, STANDALONE_CARDINALITY_FIELD]}
/>
</Box>
)}
{standaloneModal && (
<Box>
<VmTemplatesPanel
roles={[standaloneRole]}
selectedRoleIndex={selectedRoleIndex}
onChange={HANDLE_VM_SELECT_STANDALONE_ROLE}
vmTemplates={vmTemplates}
error={error}
/>
</Box>
)}
<MinMaxSection
stepId={STEP_ID}
selectedRoleIndex={selectedRoleIndex}
/>
<ElasticityPoliciesSection
stepId={STEP_ID}
selectedRoleIndex={selectedRoleIndex}
/>
<ScheduledPoliciesSection
stepId={STEP_ID}
selectedRoleIndex={selectedRoleIndex}
/>
</Box>
<Box mt={2}>
<Legend title={T.Extra} />
<AdvancedParametersSection
stepId={STEP_ID}
roleConfigs={roleConfigs.current?.[selectedRoleIndex]}
onChange={handleConfigChange}
/>
</Box>
{standaloneModal && (
<Box>
<Button
variant="contained"
size="large"
type="submit"
color="secondary"
startIcon={<AddCircledOutline />}
data-cy={'roleconfig-addrole'}
onClick={handleAddRoleClick}
sx={{ width: '100%', mt: 2 }}
>
<Translate word={T.AddRole} />
</Button>
</Box>
)}
</Box>
</Grid>
{!standaloneModal && (
<Grid item xs={2.8}>
<RoleSummary
role={roles?.[selectedRoleIndex] ?? []}
selectedRoleIndex={selectedRoleIndex}
/>
</Grid>
)}
</Grid>
)
return standaloneModal ? (
<FormProvider {...formMethods}>{ComponentContent}</FormProvider>
) : (
ComponentContent
)
}
Content.propTypes = {
standaloneModal: PropTypes.bool,
standaloneModalCallback: PropTypes.func,
fetchedVmTemplates: PropTypes.object,
}
/**
* Role definition configuration.
*
* @returns {object} Roles definition configuration step
*/
const RoleConfig = () => ({
id: STEP_ID,
label: 'Role Configuration',
resolver: SCHEMA,
optionsValidate: { abortEarly: false },
content: Content,
})
RoleConfig.propTypes = {
data: PropTypes.array,
setFormData: PropTypes.func,
}
export default RoleConfig

View File

@ -1,352 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { useTheme, Box, Checkbox, TextField, Autocomplete } from '@mui/material'
import { css } from '@emotion/css'
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'
import { useEffect, useState, useRef, useMemo, Component } from 'react'
import { DataGrid } from '@mui/x-data-grid'
import { T } from '@ConstantsModule'
import { Tr } from '@modules/components/HOC'
import { Legend } from '@modules/components/Forms'
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
import _ from 'lodash'
const useStyles = () => ({
root: css({
'& .MuiDataGrid-columnHeader:focus, & .MuiDataGrid-cell:focus': {
outline: 'none !important',
},
'& .MuiDataGrid-columnHeader:focus-within, & .MuiDataGrid-cell:focus-within':
{
outline: 'none !important',
},
'& .MuiDataGrid-overlay': {
top: '50% !important',
left: '50% !important',
transform: 'translate(-50%, -50%)',
width: 'auto !important',
height: 'auto !important',
},
}),
})
const SECTION_ID = 'NETWORKS'
/**
* @param {object} root0 - Props
* @param {string} root0.stepId - Step ID
* @param {number} root0.selectedRoleIndex - Active role index
* @returns {Component} - Component
*/
const RoleNetwork = ({ stepId, selectedRoleIndex }) => {
const theme = useTheme()
// Using a local state to keep track of the loading of initial row data
// will overwrite modifications if stepId changes
const loadInitialRowData = useRef({})
const [networks, setNetworks] = useState([])
const [fieldArrayLocation, setFieldArrayLocation] = useState('')
useEffect(() => {
setFieldArrayLocation(`${stepId}.${SECTION_ID}.${selectedRoleIndex}`)
}, [selectedRoleIndex, SECTION_ID, stepId])
const classes = useMemo(() => useStyles(theme), [theme])
const { control, getValues, setValue } = useFormContext()
const { fields, update } = useFieldArray({
name: fieldArrayLocation,
})
const watchedRdpConfig = useWatch({
control,
name: `${stepId}.RDP`,
defaultValue: {},
})
useEffect(() => {
const networkDefinitions = getValues(EXTRA_ID)?.NETWORKING ?? []
const networkMap = networkDefinitions.map((network) => ({
name: network?.name,
description: network?.description,
id: network?.network,
extra: network?.netextra,
type: network?.type,
}))
setNetworks(networkMap)
}, [getValues])
const rows = useMemo(
() =>
networks.map((config, index) => ({
...config,
id: index,
idx: index, // RHF overwrites the ID prop with a UUID
rowSelected: false,
aliasSelected: false,
aliasIdx: -1,
network: config.name,
})),
[networks]
)
useEffect(() => {
const existingRows = getValues(fieldArrayLocation)
const mergedRows = rows?.map((row) => {
const existingRow = existingRows?.find((er) => er?.name === row?.name)
return existingRow ? _.merge({}, row, existingRow) : row
})
setValue(fieldArrayLocation, mergedRows)
if (!loadInitialRowData.current?.[selectedRoleIndex] && rows?.length) {
const reversedNetworkDefs = getValues(stepId)?.NETWORKDEFS ?? []
if (reversedNetworkDefs?.[selectedRoleIndex]) {
reversedNetworkDefs?.[selectedRoleIndex]?.forEach((def) => {
const rowName = def.NETWORK_ID.slice(1).toLowerCase()
const rowToSelect = rows.find(
(row) => row?.name?.toLowerCase() === rowName
)
if (rowToSelect) {
handleSelectRow(rowToSelect, true)
if (def.PARENT) {
handleSelectAlias(rowToSelect)
const parentNetwork = reversedNetworkDefs[
selectedRoleIndex
]?.find((network) => network?.NAME === def.PARENT)
if (parentNetwork) {
const parentNetworkName =
parentNetwork.NETWORK_ID.slice(1).toLowerCase()
const parentRow = rows.find(
(row) => row?.name?.toLowerCase() === parentNetworkName
)
handleSetAlias(rowToSelect, parentRow?.name)
}
}
}
})
}
loadInitialRowData.current[selectedRoleIndex] = true
}
}, [fieldArrayLocation])
const handleSetRdp = (row) => {
const existing = getValues(`${stepId}.RDP`) || {}
const updatedRdp = {
...existing,
[selectedRoleIndex]:
typeof row === 'object' && row !== null ? row?.name : '',
}
setValue(`${stepId}.RDP`, updatedRdp)
}
const handleSelectRow = (row, forceSelect = false) => {
const fieldArray = getValues(fieldArrayLocation)
const fieldArrayIndex = fieldArray?.findIndex((f) => f?.idx === row?.idx)
const rowToggle = forceSelect
? true
: !fieldArray?.[fieldArrayIndex]?.rowSelected
if (
// if rowSelected === true, its being deselected
row?.rowSelected &&
getValues(`${stepId}.RDP`)?.[selectedRoleIndex] === row?.name
) {
handleSetRdp(null) // Deselect
}
const updatedFieldArray = fieldArray?.map((f, index) => {
if (index === fieldArrayIndex) {
return { ...f, rowSelected: rowToggle, aliasSelected: false }
}
return f
})
setValue(fieldArrayLocation, updatedFieldArray)
}
const handleSelectAlias = (row) => {
const fieldArray = getValues(fieldArrayLocation)
const fieldArrayIndex = fieldArray?.findIndex((f) => f?.idx === row?.idx)
const aliasToggle = !fieldArray?.[fieldArrayIndex]?.aliasSelected
const aliasIdx = !fieldArray?.[fieldArrayIndex]?.aliasIdx
update(fieldArrayIndex, {
...fieldArray?.[fieldArrayIndex],
aliasSelected: aliasToggle,
aliasIdx: !aliasToggle ? -1 : aliasIdx,
})
}
const handleSetAlias = (row, aliasName) => {
const fieldArray = getValues(fieldArrayLocation)
const aliasIndex = fieldArray?.findIndex((f) => f?.network === aliasName)
const fieldArrayIndex = fieldArray?.findIndex((f) => f?.idx === row?.idx)
update(fieldArrayIndex, {
...fieldArray?.[fieldArrayIndex],
aliasIdx: aliasIndex,
})
}
// Transalte before useMemo because Tr could not be inside useMemo
const columnTranslations = {
select: Tr(T.Select),
network: Tr(T.Network),
nicAlias: Tr(T.NICAlias),
alias: Tr(T.Alias),
}
const columns = useMemo(
() => [
{
field: 'select',
disableColumnMenu: true,
sortable: false,
headerName: columnTranslations.select,
width: 100,
renderCell: (params) => (
<Checkbox
checked={params?.row?.rowSelected || false}
onChange={() => handleSelectRow(params?.row)}
inputProps={{
'data-cy': `role-config-network-${params?.row?.idx}`,
}}
/>
),
},
{
field: 'network',
disableColumnMenu: true,
flex: 1,
headerName: columnTranslations.network,
width: 150,
},
{
field: 'aliasToggle',
disableColumnMenu: true,
sortable: false,
headerName: columnTranslations.nicAlias,
width: 110,
renderCell: (params) =>
params?.row?.rowSelected && (
<Checkbox
checked={params?.row?.aliasSelected || false}
onChange={() => handleSelectAlias(params?.row)}
inputProps={{
'data-cy': `role-config-network-alias-${params?.row?.idx}`,
}}
/>
),
},
{
field: 'alias',
disableColumnMenu: true,
flex: 1,
headerName: columnTranslations.alias,
width: 200,
renderCell: (params) =>
params?.row?.aliasSelected && (
<Autocomplete
id={`role-config-network-alias-name-${params?.row?.id}`}
options={networks
?.filter((net, index) => {
const fieldArray = getValues(fieldArrayLocation)?.[index]
return (
net?.name !== params?.row?.network &&
fieldArray?.rowSelected &&
fieldArray?.aliasIdx === -1
)
})
?.map((net) => net?.name)}
renderOption={(props, option) => (
<li
{...props}
data-cy={`role-config-network-aliasname-option-${option}`}
>
{option}
</li>
)}
renderInput={(props) => <TextField {...props} label="NIC" />}
onChange={(_event, value) => handleSetAlias(params?.row, value)}
value={
getValues(fieldArrayLocation)?.[params?.row?.aliasIdx]?.name ??
null
}
data-cy={`role-config-network-aliasname-${params?.row?.idx}`}
sx={{ width: '100%', height: '100%' }}
/>
),
},
],
[networks, fieldArrayLocation]
)
return (
<Box>
<Legend title={T.RoleNetworks} />
<DataGrid
className={classes.root}
rows={fields}
columns={columns}
localeText={{
noRowsLabel: 'No networks have been defined',
MuiTablePagination: {
labelRowsPerPage: Tr(T.RowsPerPage),
},
}}
autoHeight
rowsPerPageOptions={[5, 10, 25, 50, 100]}
disableSelectionOnClick
/>
{networks?.length > 0 && (
<Box sx={{ mb: 2, mt: 4 }}>
<Autocomplete
options={(getValues(fieldArrayLocation) || [])?.filter(
(row) => row?.rowSelected
)}
value={watchedRdpConfig?.[selectedRoleIndex] ?? ''}
getOptionLabel={(option) => option?.name || option || ''}
onChange={(_event, value) => handleSetRdp(value)}
renderInput={(params) => (
<TextField {...params} name="RDP" placeholder={Tr(T.Rdp)} />
)}
/>
</Box>
)}
</Box>
)
}
RoleNetwork.propTypes = {
networks: PropTypes.array,
roleConfigs: PropTypes.object,
stepId: PropTypes.string,
selectedRoleIndex: PropTypes.number,
}
export default RoleNetwork

View File

@ -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'

View File

@ -0,0 +1,251 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
/* eslint-disable react/prop-types */
import { useFormContext, useFieldArray } from 'react-hook-form'
import { useEffect } from 'react'
import { DataGrid } from '@mui/x-data-grid'
import {
Accordion,
AccordionSummary,
AccordionDetails,
Autocomplete,
TextField,
Checkbox,
} from '@mui/material'
import { Legend } from '@modules/components/Forms'
import { STEP_ID as ROLES_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles'
import { STEP_ID as EXTRA_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra'
import { TAB_ID as NETWORKS_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking'
const SECTION_ID = 'NIC'
export const NetworksDropdown = ({ roles, selectedRole }) => {
const { getValues } = useFormContext()
const isVr = roles?.[selectedRole]?.type === 'vr'
const {
fields: NICs,
replace,
update,
} = useFieldArray({
name: `${ROLES_ID}.${selectedRole}.template_contents.${SECTION_ID}`,
})
const networks = getValues(`${EXTRA_ID}.${NETWORKS_ID}`).map((network) => ({
...network,
id: `$${network?.name}`,
}))
const handleSelect = (selectedRows) => {
const existingSelections = NICs || []
replace(
selectedRows?.map((row, idx) => {
const { NIC_ALIAS, ...nic } = existingSelections?.find(
(existing) => existing?.NETWORK_ID === row
) || { NETWORK_ID: row }
if (NIC_ALIAS && !selectedRows?.includes(NIC_ALIAS)) {
return { nic, NAME: `NIC_${idx}` }
}
return { ...nic, ...(NIC_ALIAS || {}), NAME: `NIC_${idx}` }
})
)
}
const handleAlias = (rowId, newAlias) => {
const nicIndex = NICs?.findIndex((nic) => nic?.NETWORK_ID === rowId)
if (nicIndex === -1) return
const updatedNIC = { ...NICs[nicIndex] }
if (newAlias == null) {
delete updatedNIC.NIC_ALIAS
} else {
updatedNIC.NIC_ALIAS = newAlias
}
update(nicIndex, updatedNIC)
}
const handleFloatingIp = (rowId, toggle, type) => {
const nicIndex = NICs?.findIndex((nic) => nic?.NETWORK_ID === rowId)
if (nicIndex === -1) return
const updatedNIC = { ...NICs[nicIndex] }
if (!toggle) {
delete updatedNIC[type]
} else {
updatedNIC[type] = 'yes'
}
update(nicIndex, updatedNIC)
}
// Clears floating IP fields
useEffect(() => {
if (!isVr) {
replace(
NICs?.map(({ FLOATING_IP, FLOATING_ONLY, ...nic }) => ({ ...nic }))
)
}
}, [isVr])
const columns = [
{
field: 'name',
headerName: 'Network',
flex: isVr ? 8 / 24 : 12 / 24,
},
...(isVr
? [
{
field: 'floating_ip',
headerName: 'Floating IP',
flex: 4 / 24,
renderCell: (params) => {
if (!isVr) return null
return (
<Checkbox
onChange={(_, value) =>
handleFloatingIp(params.row.id, value, 'FLOATING_IP')
}
checked={
NICs?.find((nic) => nic?.NETWORK_ID === params?.row?.id)
?.FLOATING_IP === 'yes'
}
/>
)
},
},
{
field: 'floating_only',
headerName: 'Floating Only',
flex: 4 / 24,
renderCell: (params) => {
if (!isVr) return null
return (
<Checkbox
onChange={(_, value) =>
handleFloatingIp(params.row.id, value, 'FLOATING_ONLY')
}
checked={
NICs?.find((nic) => nic?.NETWORK_ID === params?.row?.id)
?.FLOATING_ONLY === 'yes'
}
/>
)
},
},
]
: []),
{
field: 'NIC_ALIAS',
headerName: 'As NIC Alias',
flex: isVr ? 8 / 24 : 12 / 24,
renderCell: (params) => {
const isSelected = NICs?.find(
(NIC) => NIC?.NETWORK_ID === params?.row.id
)
const availableAliases = NICs.filter(
(NIC) =>
NIC.NETWORK_ID !== params.row.id &&
!NICs?.some((nic) => nic?.NIC_ALIAS?.NETWORK_ID === params.row.id)
)
if (!isSelected || !availableAliases?.length) return null
return (
<Autocomplete
options={availableAliases}
getOptionLabel={(option) => option?.NETWORK_ID?.replace('$', '')}
value={
NICs.find((NIC) => NIC.NETWORK_ID === params.row.id)?.NIC_ALIAS ||
null
}
onChange={(_, newValue) => handleAlias(params.row.id, newValue)}
renderInput={(args) => (
<TextField
{...args}
label="Select alias"
variant="standard"
size="small"
sx={{
'& .MuiInputBase-root': {
'&:hover:before': {
borderBottom: 'none !important',
},
},
'& .MuiInput-underline:before': {
borderBottom: 'none',
},
'& .MuiInput-underline:after': {
borderBottom: 'none',
},
}}
/>
)}
fullWidth
/>
)
},
},
]
return (
<Accordion
key={`networks`}
variant="transparent"
defaultExpanded={networks?.length}
TransitionProps={{ unmountOnExit: false }}
sx={{
width: '100%',
}}
>
<AccordionSummary sx={{ width: '100%' }}>
<Legend disableGutters title={'Networks'} />
</AccordionSummary>
<AccordionDetails>
<DataGrid
key={isVr}
checkboxSelection
disableSelectionOnClick
onSelectionModelChange={handleSelect}
selectionModel={NICs?.map((NIC) => NIC?.NETWORK_ID)}
rows={networks}
columns={columns}
disableColumnMenu
autoHeight
/>
</AccordionDetails>
</Accordion>
)
}

View File

@ -0,0 +1,73 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
/* eslint-disable react/prop-types */
import { STEP_ID as ROLES_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles'
import { Legend, FormWithSchema } from '@modules/components/Forms'
import {
Stack,
Grid,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material'
import { T } from '@ConstantsModule'
import { MIN_MAX_FIELDS } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/minMax'
import {
ELASTICITY,
SCHEDULED,
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections'
export const PoliciesDropdown = ({ roles, selectedRole }) => (
<Accordion
key={`policies-${roles?.[selectedRole]?.id}`}
variant="transparent"
defaultExpanded={false}
TransitionProps={{ unmountOnExit: false }}
sx={{
width: '100%',
}}
>
<AccordionSummary sx={{ width: '100%' }}>
<Legend disableGutters title={T.RoleElasticity} />
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={4}>
<Grid item md={12}>
<FormWithSchema
id={`${ROLES_ID}.${selectedRole}`}
cy={`${ROLES_ID}`}
fields={MIN_MAX_FIELDS}
/>
</Grid>
{[ELASTICITY, SCHEDULED].map(({ Section }, idx) => (
<Grid key={`policies-section-${idx}`} item md={12}>
<Stack direction="row" spacing={1}>
<Section roles={roles} selectedRole={selectedRole} />
</Stack>
</Grid>
))}
</Grid>
</AccordionDetails>
</Accordion>
)
export default PoliciesDropdown

View File

@ -0,0 +1,241 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
/* eslint-disable react/prop-types */
import { useFieldArray, useFormContext } from 'react-hook-form'
import {
Stack,
Grid,
Accordion,
AccordionSummary,
AccordionDetails,
Button,
IconButton,
List,
ListItem,
ListItemText,
} from '@mui/material'
import { STEP_ID as ROLES_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles'
import { Tr } from '@modules/components/HOC'
import { Cancel } from 'iconoir-react'
import { Legend, FormWithSchema } from '@modules/components/Forms'
import { useEffect, useState } from 'react'
import { T } from '@ConstantsModule'
import {
ELASTICITY_POLICY_FIELDS,
ELASTICITY_TYPES,
SECTION_ID,
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/elasticity/schema'
const ElasticityPolicies = ({ roles, selectedRole }) => {
const { watch } = useFormContext()
const wPolicies = watch(`${ROLES_ID}.${selectedRole}.${SECTION_ID}`)
const [selectedPolicy, setSelectedPolicy] = useState(0)
const [shift, setShift] = useState(0)
const {
fields: policies,
append,
remove,
} = useFieldArray({
name: `${ROLES_ID}.${selectedRole}.${SECTION_ID}`,
})
const handleRemove = (event, idx) => {
event.stopPropagation()
// Calculates shift & releases current reference in case it goes oob
setSelectedPolicy((prev) => {
setShift(prev + (policies?.length === 2 ? -+prev : idx < prev ? -1 : 0))
return null
})
remove(idx)
}
const handleAppend = (event) => {
event?.stopPropagation?.()
setSelectedPolicy(() => {
setShift(null)
return null
})
append({
adjust: '',
cooldown: '',
expression: '',
min: '',
period: '',
period_number: '',
type: '',
})
}
useEffect(() => {
if (selectedPolicy === null) {
if (shift === null) {
setSelectedPolicy(policies?.length - 1)
} else {
setSelectedPolicy(shift)
}
}
}, [policies])
return (
<Accordion
key={`policies-${roles?.[selectedRole]?.id}`}
variant="transparent"
defaultExpanded
TransitionProps={{ unmountOnExit: false }}
sx={{
width: '100%',
}}
>
<AccordionSummary sx={{ width: '100%' }}>
<Legend disableGutters title={T.ElasticityPolicies} />
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={4}>
<Grid key={`elasticity-section`} item md={12}>
{selectedPolicy != null && (
<>
<Stack
key={`epolicy-${policies?.[selectedPolicy]?.id}`}
direction="column"
spacing={1}
>
{wPolicies?.length > 0 && (
<FormWithSchema
id={`${ROLES_ID}.${selectedRole}.${SECTION_ID}.${selectedPolicy}`}
cy={`${ROLES_ID}`}
fields={ELASTICITY_POLICY_FIELDS}
/>
)}
<Button
variant="contained"
color="secondary"
type="submit"
size="large"
data-cy={'roles-add-elastic-policy'}
onClick={handleAppend}
>
{T.AddPolicy}
</Button>
<List>
{policies?.map((policy, idx) => {
const {
expression,
adjust,
cooldown,
period,
// eslint-disable-next-line camelcase
period_number,
min,
type,
} = wPolicies?.[idx] ?? policy
const secondaryFields = [
type &&
`${Tr(T.Type)}: ${Tr(ELASTICITY_TYPES?.[type])}`,
adjust && `${Tr(T.Adjust)}: ${adjust}`,
min && `${Tr(T.Min)}: ${min}`,
cooldown && `${Tr(T.Cooldown)}: ${cooldown}`,
period && `${Tr(T.Period)}: ${period}`,
// eslint-disable-next-line camelcase
period_number && `#: ${period_number}`,
expression && `${Tr(T.Expression)}: ${expression}`,
].filter(Boolean)
return (
<ListItem
key={`epolicy-${idx}-${policy.id}`}
onClick={() => setSelectedPolicy(idx)}
sx={{
display: 'flex',
alignitems: 'center',
border: '1px solid',
borderColor: 'divider',
borderRadius: '4px',
minHeight: '92px',
my: 0.5,
overflowX: 'hidden',
padding: 2,
bgcolor:
idx === selectedPolicy
? 'action.selected'
: 'inherit',
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<ListItemText
sx={{
flex: '1 1 auto',
maxWidth: '100%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontSize: '1em',
}}
primary={`Policy #${idx}`}
primaryTypographyProps={{ variant: 'body1' }}
secondary={secondaryFields.join(' | ')}
/>
<IconButton
aria-label="delete"
onClick={(event) => handleRemove(event, idx)}
sx={{
mr: 1.5,
size: 'small',
}}
>
<Cancel />
</IconButton>
</ListItem>
)
})}
</List>
</Stack>
</>
)}
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
)
}
export const ELASTICITY = {
Section: ElasticityPolicies,
id: SECTION_ID,
}

View File

@ -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
)

View File

@ -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'

View File

@ -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)

View File

@ -0,0 +1,231 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
/* eslint-disable react/prop-types */
import { useFieldArray, useFormContext } from 'react-hook-form'
import {
Stack,
Grid,
Accordion,
AccordionSummary,
AccordionDetails,
Button,
IconButton,
List,
ListItem,
ListItemText,
} from '@mui/material'
import { STEP_ID as ROLES_ID } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles'
import { Tr } from '@modules/components/HOC'
import { Cancel } from 'iconoir-react'
import { Legend, FormWithSchema } from '@modules/components/Forms'
import { useEffect, useState } from 'react'
import { T } from '@ConstantsModule'
import {
SCHEDULED_POLICY_FIELDS,
SCHED_TYPES,
SECTION_ID,
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns/sections/scheduled/schema'
const ScheduledPolicies = ({ roles, selectedRole }) => {
const { watch } = useFormContext()
const wPolicies = watch(`${ROLES_ID}.${selectedRole}.${SECTION_ID}`)
const [selectedPolicy, setSelectedPolicy] = useState(-1)
const [shift, setShift] = useState(0)
const {
fields: policies,
append,
remove,
} = useFieldArray({
name: `${ROLES_ID}.${selectedRole}.${SECTION_ID}`,
})
const handleRemove = (event, idx) => {
event.stopPropagation()
// Calculates shift & releases current reference in case it goes oob
setSelectedPolicy((prev) => {
setShift(prev + (policies?.length === 2 ? -+prev : idx < prev ? -1 : 0))
return null
})
remove(idx)
}
const handleAppend = (event) => {
event?.stopPropagation?.()
setSelectedPolicy(() => {
setShift(null)
return null
})
append({
adjust: '',
cooldown: '',
expression: '',
min: '',
period: '',
period_number: '',
type: '',
})
}
useEffect(() => {
if (selectedPolicy === null) {
if (shift === null) {
setSelectedPolicy(policies?.length - 1)
} else {
setSelectedPolicy(shift)
}
}
}, [policies])
return (
<Accordion
key={`policies-${roles?.[selectedRole]?.id}`}
variant="transparent"
defaultExpanded
TransitionProps={{ unmountOnExit: false }}
sx={{
width: '100%',
}}
>
<AccordionSummary sx={{ width: '100%' }}>
<Legend disableGutters title={T.ScheduledPolicies} />
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={4}>
<Grid key={`scheduled-section`} item md={12}>
{selectedPolicy != null && (
<>
<Stack
key={`epolicy-${policies?.[selectedPolicy]?.id}`}
direction="column"
spacing={1}
>
{wPolicies?.length > 0 && (
<FormWithSchema
id={`${ROLES_ID}.${selectedRole}.${SECTION_ID}.${selectedPolicy}`}
cy={`${ROLES_ID}`}
fields={SCHEDULED_POLICY_FIELDS}
/>
)}
<Button
variant="contained"
color="secondary"
type="submit"
size="large"
data-cy={'roles-add-scheduled-policy'}
onClick={handleAppend}
>
{T.AddPolicy}
</Button>
<List>
{policies?.map((policy, idx) => {
const { type, adjust, min, format, expression } =
wPolicies?.[idx] ?? policy
const timeFormatTrans = Tr(format)
const secondaryFields = [
type && `${Tr(T.Type)}: ${Tr(SCHED_TYPES?.[type])}`,
adjust && `${Tr(T.Adjust)}: ${adjust}`,
min && `${Tr(T.Min)}: ${min}`,
timeFormatTrans &&
`${Tr(T.TimeFormat)}: ${timeFormatTrans}`,
expression && `${Tr(T.TimeExpression)}: ${expression}`,
].filter(Boolean)
return (
<ListItem
key={`epolicy-${idx}-${policy.id}`}
onClick={() => setSelectedPolicy(idx)}
sx={{
display: 'flex',
alignitems: 'center',
border: '1px solid',
borderColor: 'divider',
borderRadius: '4px',
minHeight: '92px',
my: 0.5,
overflowX: 'hidden',
padding: 2,
bgcolor:
idx === selectedPolicy
? 'action.selected'
: 'inherit',
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<ListItemText
sx={{
flex: '1 1 auto',
maxWidth: '100%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontSize: '1em',
}}
primary={`Policy #${idx}`}
primaryTypographyProps={{ variant: 'body1' }}
secondary={secondaryFields.join(' | ')}
/>
<IconButton
aria-label="delete"
onClick={(event) => handleRemove(event, idx)}
sx={{
mr: 1.5,
size: 'small',
}}
>
<Cancel />
</IconButton>
</ListItem>
)
})}
</List>
</Stack>
</>
)}
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
)
}
export const SCHEDULED = {
Section: ScheduledPolicies,
id: SECTION_ID,
}

View File

@ -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
)

View File

@ -14,119 +14,245 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { useState, useCallback, useEffect } from 'react'
import { useFormContext, useWatch } from 'react-hook-form'
import { Box, Grid } from '@mui/material'
import { SCHEMA } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schema'
import RoleVmVmPanel from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesPanel'
import RoleColumn from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesColumn'
import VmTemplatesPanel from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/vmTemplatesPanel'
import RoleSummary from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/roleSummary'
import { Component, useState, useEffect } from 'react'
import { useFormContext, useFieldArray } from 'react-hook-form'
import { Cancel } from 'iconoir-react'
import {
FIELDS,
SCHEMA,
TEMPLATE_ID_FIELD,
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schemas'
import {
PoliciesDropdown,
NetworksDropdown,
} from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/dropdowns'
import { FormWithSchema } from '@modules/components/Forms'
import { Tr } from '@modules/components/HOC'
import {
Skeleton,
Stack,
Button,
Grid,
List,
ListItem,
IconButton,
} from '@mui/material'
import { T } from '@ConstantsModule'
export const STEP_ID = 'roledefinition'
export const STEP_ID = 'roles'
const Content = () => {
const { getValues, setValue, reset } = useFormContext()
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0)
const standaloneExcludedFields = ['parents']
const defaultRole = [
{ NAME: '', SELECTED_VM_TEMPLATE_ID: [], CARDINALITY: 0 },
]
/**
* @param {object} root0 - Props
* @param {boolean} root0.standaloneModal - Render as AddRoleDialog
*@returns {Component} - Role definitions step
*/
const Content = ({ standaloneModal = false }) => {
const [selectedRole, setSelectedRole] = useState(0)
const [shift, setShift] = useState(0)
const watchedRoles = useWatch({
name: STEP_ID,
defaultValue: defaultRole,
const { watch } = useFormContext()
const wRoles = watch(`${STEP_ID}`)
const isVr = wRoles?.[selectedRole]?.type === 'vr'
const {
fields: roles,
remove,
append,
} = useFieldArray({
name: `${STEP_ID}`,
})
const definedRoles = getValues(STEP_ID)
useEffect(() => {
if (definedRoles) {
reset({ [STEP_ID]: definedRoles ?? defaultRole })
}
}, [])
const handleRemove = (event, idx) => {
event.stopPropagation()
const [roles, setRoles] = useState(getValues(STEP_ID))
// Calculates shift & releases current reference in case it goes oob
setSelectedRole((prev) => {
setShift(prev + (roles?.length === 2 ? -+prev : idx < prev ? -1 : 0))
useEffect(() => {
setRoles(watchedRoles)
}, [definedRoles, watchedRoles])
return null
})
const handleChangeRoles = (updatedRoles) => {
setValue(STEP_ID, updatedRoles)
remove(idx)
}
const handleRoleChange = useCallback(
(updatedRole) => {
const updatedRoles = [...roles]
const handleAppend = (event) => {
event?.stopPropagation?.()
if (selectedRoleIndex >= 0 && selectedRoleIndex < roles.length) {
updatedRoles[selectedRoleIndex] = updatedRole
setSelectedRole(() => {
setShift(null)
return null
})
append({
name: '',
cardinality: 1,
parents: [],
template_id: '',
type: '',
})
}
useEffect(() => {
if (selectedRole === null) {
if (shift === null) {
setSelectedRole(roles?.length - 1)
} else {
updatedRoles.push(updatedRole)
setSelectedRole(shift)
}
}
}, [roles])
handleChangeRoles(updatedRoles)
},
[roles, selectedRoleIndex]
)
if (!wRoles?.length) {
handleAppend()
return (
<Stack direction="row" spacing={2}>
<Skeleton variant="rectangular" width="25%" height={300} />
<Skeleton variant="rectangular" width="75%" height={300} />
</Stack>
)
}
return (
<Grid mt={2} container>
<Grid item xs={2.2}>
<RoleColumn
roles={roles}
onChange={handleChangeRoles}
selectedRoleIndex={selectedRoleIndex}
setSelectedRoleIndex={setSelectedRoleIndex}
/>
</Grid>
<Grid item xs={7}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '1em',
overflow: 'auto',
}}
>
<RoleVmVmPanel
roles={roles}
onChange={handleRoleChange}
selectedRoleIndex={selectedRoleIndex}
/>
<VmTemplatesPanel
roles={roles}
selectedRoleIndex={selectedRoleIndex}
onChange={handleRoleChange}
/>
</Box>
</Grid>
<Grid item xs={2.8}>
<RoleSummary
role={roles?.[selectedRoleIndex] ?? []}
selectedRoleIndex={selectedRoleIndex}
/>
<Grid
mt={1}
container
direction="row"
columnSpacing={1}
rowSpacing={2}
sx={{
justifyContent: 'flex-start',
alignItems: 'stretch',
height: '100%',
}}
>
{!standaloneModal && (
<Grid item md={3} sx={{ borderRight: 1, padding: 1 }}>
<Button
variant="outlined"
color="primary"
type="submit"
size="large"
data-cy={'extra-add-role'}
onClick={handleAppend}
>
{Tr(T.AddRole)}
</Button>
<List
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{roles?.map((role, idx) => {
const roleName = watch(`${STEP_ID}.${idx}.name`)
return (
<ListItem
key={`${idx}-${role?.id}-${role?.name}`}
onClick={() => setSelectedRole(idx)}
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: '4px',
minHeight: '70px',
my: 0.5,
overflowX: 'hidden',
padding: 2,
bgcolor:
idx === selectedRole ? 'action.selected' : 'inherit',
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
{roles?.length > 1 && idx !== selectedRole && (
<IconButton
aria-label="delete"
onClick={(event) => handleRemove(event, idx)}
sx={{ mr: 1.5, size: 'small' }}
>
<Cancel />
</IconButton>
)}
<div
style={{
display: 'inline-block',
maxWidth: '100%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontSize: '1em',
}}
>
{roleName || T.NewRole}
</div>
</ListItem>
)
})}
</List>
</Grid>
)}
<Grid item md={standaloneModal ? 12 : 9}>
{selectedRole != null && (
<Stack
key={`roles-${roles?.[selectedRole]?.id}`}
direction="column"
alignItems="flex-start"
gap="0.5rem"
width="100%"
sx={{ padding: standaloneModal ? 1 : 0 }}
>
<FormWithSchema
id={`${STEP_ID}.${selectedRole}`}
legend={T.Type}
fields={FIELDS?.filter(
({ name }) =>
!standaloneModal || !standaloneExcludedFields?.includes(name)
)}
/>
{!standaloneModal && (
<NetworksDropdown roles={wRoles} selectedRole={selectedRole} />
)}
{!isVr && (
<PoliciesDropdown roles={roles} selectedRole={selectedRole} />
)}
<FormWithSchema
id={`${STEP_ID}.${selectedRole}`}
fields={[TEMPLATE_ID_FIELD]}
/>
</Stack>
)}
</Grid>
</Grid>
)
}
/**
* Role definition configuration.
*
* @returns {object} Roles definition configuration step
*/
const RoleDefinition = () => ({
id: STEP_ID,
label: T.RoleDefinition,
resolver: SCHEMA,
optionsValidate: { abortEarly: false },
content: Content,
})
RoleDefinition.propTypes = {
data: PropTypes.array,
setFormData: PropTypes.func,
Content.propTypes = {
stepId: PropTypes.string,
standaloneModal: PropTypes.bool,
}
export default RoleDefinition
/**
*@returns {Component} - Roles definition step
*/
const Step = () => ({
id: STEP_ID,
content: (props) => Content(props),
label: T.Roles,
resolver: SCHEMA,
optionsValidate: { abortEarly: false },
})
export default Step

View File

@ -1,188 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { Card, CardContent, Typography, Divider } from '@mui/material'
import PropTypes from 'prop-types'
import { T } from '@ConstantsModule'
import { Tr } from '@modules/components/HOC'
import { Component } from 'react'
/**
* RoleSummary displays detailed information about a VM role, including its configuration and affinity settings.
*
* @param {object} props - The props that control the RoleSummary component.
* @param {object} props.role - The role object containing the role's configuration.
* @param {number} props.selectedRoleIndex - The index of the selected role.
* @returns {Component} - Role summary component.
*/
const RoleSummary = ({ role, selectedRoleIndex }) => {
const translations = {
template: Tr(T.VMTemplate) + ' ' + Tr(T.ID),
selectTemplate: Tr(T.SelectVmTemplate),
}
return (
<Card
elevation={2}
sx={{
height: '100%',
maxHeight: '630px',
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
}}
>
<CardContent
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
gap: '1em',
}}
>
<Typography variant="h6" component="div" gutterBottom>
#{selectedRoleIndex + 1 ?? 0} {Tr(T.RoleConfiguration)}
</Typography>
<Typography
variant="body2"
color={role?.NAME ? 'text.primary' : 'text.disabled'}
gutterBottom
>
{Tr(T.Name)}: {role?.NAME || Tr(T.RoleEnterName)}
</Typography>
<Typography
variant="body2"
color={
role?.CARDINALITY === undefined ||
role?.CARDINALITY === 'None' ||
+role?.CARDINALITY < 1
? 'text.disabled'
: 'text.primary'
}
gutterBottom
>
{Tr(T.NumberOfVms)}: {role?.CARDINALITY}
</Typography>
{role?.SELECTED_VM_TEMPLATE_ID ? (
<>
<Typography
variant="body2"
color={
role?.SELECTED_VM_TEMPLATE_ID === undefined ||
role?.SELECTED_VM_TEMPLATE_ID === 'None' ||
role?.SELECTED_VM_TEMPLATE_ID?.length < 1
? 'text.disabled'
: 'text.primary'
}
gutterBottom
>
{translations.template}: {role?.SELECTED_VM_TEMPLATE_ID}
</Typography>
</>
) : (
<Typography variant="body2" color="text.disabled" gutterBottom>
{translations.selectTemplate}
</Typography>
)}
<Divider />
<Typography
variant="body2"
color={role?.NETWORKS ? 'text.primary' : 'text.disabled'}
gutterBottom
>
{Tr(T.Networks)}: {role?.NETWORKS || ' ' + Tr(T.RoleSelectNetwork)}
</Typography>
<Typography color={'text.primary'} sx={{ fontSize: 16 }} gutterBottom>
{Tr(T.RoleElasticity)}
</Typography>
<Typography
variant="body2"
color={role?.MINVMS ? 'text.primary' : 'text.disabled'}
gutterBottom
>
{Tr(T.RolesMinVms)}:{role?.MINVMS || ' ' + Tr(T.RoleMinElasticity)}
</Typography>
<Typography
variant="body2"
color={role?.MAXVMS ? 'text.primary' : 'text.disabled'}
gutterBottom
>
{Tr(T.RolesMaxVms)}:{role?.MAXVMS || ' ' + Tr(T.RoleMaxElasticity)}
</Typography>
<Typography
variant="body2"
color={role?.MAXVMS ? 'text.primary' : 'text.disabled'}
gutterBottom
>
{Tr(T.Cooldown)}:{role?.COOLDOWN || ' ' + Tr(T.RoleDurationScale)}
</Typography>
<Typography
color={role?.ELASTICITYPOLICIES ? 'text.primary' : 'text.disabled'}
sx={{ fontSize: 14 }}
gutterBottom
>
{Tr(T.ElasticityPolicies)}
</Typography>
<Typography
variant="body2"
color={
role?.ELASTICITYPOLICIES?.TYPE ? 'text.primary' : 'text.disabled'
}
gutterBottom
>
{Tr(T.Type)}:
{role?.ELASTICITYPOLICIES?.TYPE || ' ' + Tr(T.RoleAdjustmentType)}
</Typography>
<Typography
variant="body2"
color={
role?.ELASTICITYPOLICIES?.ADJUST ? 'text.primary' : 'text.disabled'
}
gutterBottom
>
{Tr(T.Adjust)}:
{role?.ELASTICITYPOLICIES?.ADJUST ||
' ' + Tr(T.RoleAdjustmentTypePositiveNegative)}
</Typography>
</CardContent>
</Card>
)
}
RoleSummary.propTypes = {
role: PropTypes.oneOfType([
PropTypes.shape({
NAME: PropTypes.string,
POLICY: PropTypes.oneOf(['AFFINED', 'ANTI_AFFINED', 'None', undefined]),
HOST_AFFINED: PropTypes.arrayOf(PropTypes.number),
HOST_ANTI_AFFINED: PropTypes.arrayOf(PropTypes.number),
}),
PropTypes.array,
PropTypes.object,
]),
selectedRoleIndex: PropTypes.number,
onRemoveAffinity: PropTypes.func,
}
export default RoleSummary

View File

@ -1,170 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useCallback, Component } from 'react'
import PropTypes from 'prop-types'
import { Box, Button, List, ListItem, IconButton } from '@mui/material'
import { Cancel } from 'iconoir-react'
import { Tr } from '@modules/components/HOC'
import { T } from '@ConstantsModule'
/**
* RoleColumn component for displaying and managing roles.
*
* @param {object} props - The properties passed to the component.
* @param {Array} props.roles - The list of roles.
* @param {Function} props.onChange - Callback function when roles are changed.
* @param {number|null} props.selectedRoleIndex - The index of the currently selected role.
* @param {Function} props.setSelectedRoleIndex - Function to set the selected role index.
* @param {boolean} props.disableModify - Disables the modification of roles.
* @returns {Component} - Role columns component
*/
const RoleColumn = ({
roles,
onChange,
selectedRoleIndex,
setSelectedRoleIndex,
disableModify = false,
}) => {
const newRole = { NAME: '', SELECTED_VM_TEMPLATE_ID: [], CARDINALITY: 0 }
const handleAddRole = useCallback(() => {
const updatedRoles = [...roles, newRole]
onChange(updatedRoles)
setSelectedRoleIndex(roles?.length)
}, [roles, onChange, selectedRoleIndex])
const handleRemoveRole = useCallback(
(indexToRemove) => {
const updatedRoles = [
...roles.slice(0, indexToRemove),
...roles.slice(indexToRemove + 1),
]
onChange(updatedRoles)
if (selectedRoleIndex === indexToRemove) {
setSelectedRoleIndex(null)
}
},
[roles, selectedRoleIndex]
)
return (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
pt: 2,
height: '100%',
maxHeight: '630px',
}}
>
<Box
sx={{
borderRight: 1,
pr: 2,
width: '100%',
}}
>
{!disableModify && (
<Button
variant="contained"
color="primary"
onClick={handleAddRole}
size="large"
data-cy="add-role"
>
{Tr(T.AddRole)}
</Button>
)}
<Box
sx={{
maxHeight: '90%',
overflowY: 'auto',
}}
>
<List>
{Array.isArray(roles) &&
roles.length > 0 &&
roles.map((role, index) => (
<ListItem
button
selected={index === selectedRoleIndex}
onClick={() => setSelectedRoleIndex(index)}
key={index}
sx={{
my: 0.5,
minHeight: '43.5px',
border: '1px solid',
borderColor: 'divider',
borderRadius: '4px',
overflowX: 'hidden',
bgcolor:
index === selectedRoleIndex
? 'action.selected'
: 'inherit',
'&.Mui-selected, &.Mui-selected:hover': {
bgcolor: 'action.selected',
},
'&:hover': {
bgcolor: 'action.hover',
},
}}
data-cy={`role-column-${index}`}
>
{!disableModify && (
<IconButton
aria-label="delete"
onClick={(event) => {
event.stopPropagation()
handleRemoveRole(index)
}}
data-cy={`delete-role-${index}`}
sx={{ mr: 1.5 }}
>
<Cancel />
</IconButton>
)}
<div
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{role?.NAME || 'New Role'}
</div>
</ListItem>
))}
</List>
</Box>
</Box>
</Box>
)
}
RoleColumn.propTypes = {
roles: PropTypes.arrayOf(PropTypes.object).isRequired,
onChange: PropTypes.func.isRequired,
selectedRoleIndex: PropTypes.number,
setSelectedRoleIndex: PropTypes.func.isRequired,
disableModify: PropTypes.bool,
}
RoleColumn.defaultProps = {
roles: [],
}
export default RoleColumn

View File

@ -1,161 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { Component, useState } from 'react'
import {
Box,
TextField,
Typography,
Checkbox,
Autocomplete,
} from '@mui/material'
import PropTypes from 'prop-types'
import { T } from '@ConstantsModule'
import { Tr } from '@modules/components/HOC'
/**
* Role Panel component for managing roles.
*
* @param {object} props - Component properties.
* @param {Array} props.roles - List of roles.
* @param {Function} props.onChange - Callback for when roles change.
* @param {number} props.selectedRoleIndex - Currently selected role index.
* @returns {Component} The rendered component.
*/
const RoleVmVmPanel = ({ roles, onChange, selectedRoleIndex }) => {
const [inputBuffers, setInputBuffers] = useState({})
const handleInputChange = (name, value) => {
const updatedRole = { ...roles[selectedRoleIndex], [name]: value }
onChange(updatedRole)
}
const handleTextFieldChange = (event) => {
const { name, value } = event.target
setInputBuffers((prev) => ({ ...prev, [name]: value }))
}
const handleBlur = (event, number = false) => {
const { name } = event.target
if (inputBuffers[name] !== undefined) {
const value = inputBuffers[name]
handleInputChange(name, number ? parseInt(value, 10) || 0 : value || '')
}
setInputBuffers((prev) => ({ ...prev, [name]: null }))
}
const handleAutocompleteChange = (_, value) => {
const parentNames = value.map((option) => option.NAME)
handleInputChange('PARENTS', parentNames)
}
const isDisabled = !roles?.[selectedRoleIndex] || roles?.length <= 0
const selectedRole = roles?.[selectedRoleIndex] || {}
const selectedParentRoles = roles?.filter((role) =>
selectedRole?.PARENTS?.includes(role?.NAME)
)
const getValue = (fieldName) => {
if (
inputBuffers[fieldName] !== undefined &&
inputBuffers[fieldName] !== null
) {
return inputBuffers[fieldName]
}
return selectedRole?.[fieldName] || ''
}
return (
<Box p={2}>
<Box sx={{ flex: 1 }}>
<Typography variant="h6">{Tr(T.RoleDetails)}</Typography>
<Box sx={{ mb: 2 }}>
<TextField
label={Tr(T.RoleName)}
name="NAME"
value={getValue('NAME')}
onChange={handleTextFieldChange}
onBlur={handleBlur}
disabled={isDisabled}
inputProps={{ 'data-cy': `role-name-${selectedRoleIndex}` }}
fullWidth
/>
</Box>
<Box sx={{ mb: 2 }}>
<TextField
type="number"
label={Tr(T.NumberOfVms)}
name="CARDINALITY"
value={getValue('CARDINALITY')}
onChange={handleTextFieldChange}
onBlur={(event) => handleBlur(event, true)}
disabled={isDisabled}
InputProps={{
inputProps: {
min: 0,
'data-cy': `role-cardinality-${selectedRoleIndex}`,
},
}}
fullWidth
/>
</Box>
{roles?.length >= 2 && (
<Box sx={{ mb: 2 }}>
<Autocomplete
multiple
options={roles?.filter((_, idx) => idx !== selectedRoleIndex)}
disableCloseOnSelect
getOptionLabel={(option) => option?.NAME}
value={selectedParentRoles}
onChange={handleAutocompleteChange}
renderOption={(props, option, { selected }) => (
<li {...props}>
<Checkbox style={{ marginRight: 8 }} checked={selected} />
{option?.NAME}
</li>
)}
renderInput={(params) => (
<TextField
{...params}
name="PARENTS"
placeholder={Tr(T.ParentRoles)}
/>
)}
/>
</Box>
)}
</Box>
</Box>
)
}
RoleVmVmPanel.propTypes = {
roles: PropTypes.arrayOf(
PropTypes.shape({
NAME: PropTypes.string,
CARDINALITY: PropTypes.number,
})
),
onChange: PropTypes.func.isRequired,
selectedRoleIndex: PropTypes.number,
}
export default RoleVmVmPanel

View File

@ -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]

View File

@ -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
)

View File

@ -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 }

View File

@ -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,
])

View File

@ -1,177 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useState, useEffect, Component } from 'react'
import PropTypes from 'prop-types'
import {
Box,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TablePagination,
Checkbox,
Typography,
Paper,
useTheme,
} from '@mui/material'
import { VmTemplateAPI, useGeneralApi } from '@FeaturesModule'
import { DateTime } from 'luxon'
import { Tr } from '@modules/components/HOC'
import { T } from '@ConstantsModule'
const convertTimestampToDate = (timestamp) =>
DateTime.fromSeconds(parseInt(timestamp)).toFormat('dd/MM/yyyy HH:mm:ss')
/**
* VmTemplatesPanel component.
*
* @param {object} props - The props that are passed to this component.
* @param {Array} props.roles - The roles available for selection.
* @param {number} props.selectedRoleIndex - The index of the currently selected role.
* @param {Function} props.onChange - Callback to be called when affinity settings are changed.
* @param {Array} props.vmTemplates - VM Templates array
* @param {object} props.error - Error object
* @returns {Component} The VmTemplatesPanel component.
*/
const VmTemplatesPanel = ({
roles,
selectedRoleIndex,
onChange,
vmTemplates,
error,
}) => {
const theme = useTheme()
const { enqueueError } = useGeneralApi()
const templateID = roles?.[selectedRoleIndex]?.SELECTED_VM_TEMPLATE_ID ?? []
const templates =
vmTemplates || (VmTemplateAPI.useGetTemplatesQuery()?.data ?? [])
useEffect(() => {
if (error) {
enqueueError(T.ErrorVmTemplateFetching, error?.message ?? error)
}
}, [error, enqueueError])
const [page, setPage] = useState(0)
const [rowsPerPage, setRowsPerPage] = useState(10)
const handleChangePage = (_event, newPage) => {
setPage(newPage)
}
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10))
setPage(0)
}
const isDisabled = !roles?.[selectedRoleIndex] || roles?.length <= 0
return (
<Box
sx={{
my: 2,
p: 2,
borderRadius: 2,
maxHeight: '40%',
pointerEvents: isDisabled ? 'none' : 'auto',
opacity: isDisabled ? '50%' : '100%',
}}
>
<Typography variant="h6" gutterBottom>
{Tr(T.VMTemplates)}
</Typography>
<Paper sx={{ overflow: 'auto', marginBottom: 2 }}>
<Table>
<TableHead>
<TableRow>
<TableCell padding="checkbox"></TableCell>
<TableCell>{Tr(T.ID)}</TableCell>
<TableCell>{Tr(T.Name)}</TableCell>
<TableCell>{Tr(T.Owner)}</TableCell>
<TableCell>{Tr(T.Group)}</TableCell>
<TableCell>{Tr(T.RegistrationTime)}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{templates
?.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
?.map((vmTemplate) => (
<TableRow
key={vmTemplate.ID}
hover
sx={{
'&:hover': {
filter: 'brightness(85%)',
cursor: 'pointer',
},
}}
onClick={() =>
onChange({
...roles[selectedRoleIndex],
SELECTED_VM_TEMPLATE_ID: [vmTemplate.ID],
})
}
name="SELECTED_VM_TEMPLATE_ID"
role="checkbox"
aria-checked={templateID.includes(vmTemplate.ID)}
style={{
backgroundColor: templateID?.includes(vmTemplate.ID)
? theme?.palette?.action?.selected
: theme?.palette.action?.disabledBackground,
}}
data-cy={`role-vmtemplate-${vmTemplate.ID}`}
tabIndex={-1}
>
<TableCell padding="checkbox">
<Checkbox checked={templateID.includes(vmTemplate.ID)} />
</TableCell>
<TableCell>{vmTemplate.ID}</TableCell>
<TableCell>{vmTemplate.NAME}</TableCell>
<TableCell>{vmTemplate.UNAME}</TableCell>
<TableCell>{vmTemplate.GNAME}</TableCell>
<TableCell>
{convertTimestampToDate(vmTemplate.REGTIME)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
<TablePagination
rowsPerPageOptions={[10, 25, 50]}
component="div"
count={templates?.length ?? 0}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
labelRowsPerPage={Tr(T.RowsPerPage)}
/>
</Box>
)
}
VmTemplatesPanel.propTypes = {
roles: PropTypes.array,
selectedRoleIndex: PropTypes.number,
onChange: PropTypes.func.isRequired,
vmTemplates: PropTypes.array,
error: PropTypes.object,
templateID: PropTypes.array,
}
export default VmTemplatesPanel

View File

@ -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
},
})

View File

@ -20,40 +20,30 @@ import { SCHEMA, NAME_FIELD, INSTANCE_FIELD } from './schema'
export const STEP_ID = 'general'
const Content = ({ isUpdate }) => (
const Content = () => (
<FormWithSchema
id={STEP_ID}
cy={`${STEP_ID}`}
fields={[
{ ...NAME_FIELD, fieldProps: { disabled: !!isUpdate } },
INSTANCE_FIELD,
]}
fields={[NAME_FIELD, INSTANCE_FIELD]}
/>
)
/**
* General Service Template configuration.
*
* @param {object} data - Service Template data
* @returns {object} General configuration step
*/
const General = (data) => {
const isUpdate = data?.ID
return {
id: STEP_ID,
label: T.General,
resolver: SCHEMA,
optionsValidate: { abortEarly: false },
content: () => Content({ isUpdate }),
}
}
const General = () => ({
id: STEP_ID,
label: T.General,
resolver: SCHEMA,
optionsValidate: { abortEarly: false },
content: () => Content(),
})
General.propTypes = {
data: PropTypes.object,
setFormData: PropTypes.func,
}
Content.propTypes = { isUpdate: PropTypes.bool }
export default General

View File

@ -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 },
}

View File

@ -1,144 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import FormWithSchema from '@modules/components/Forms/FormWithSchema'
import { NETWORK_TYPES } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/schema'
import { T } from '@ConstantsModule'
import { Box, Grid, TextField } from '@mui/material'
import { useState, useMemo, useEffect } from 'react'
import { createNetworkFields, createNetworkSchema } from './schema'
import { useFieldArray, useFormContext } from 'react-hook-form'
export const STEP_ID = 'network'
export const FIELD_ARRAY = 'NETWORKS'
const Content = (props) => {
const { control, setValue } = useFormContext()
const templatePath = props?.dataTemplate?.TEMPLATE?.BODY?.networks
const networkInfo = Object.entries(templatePath || {}).reduce(
(acc, [key, value]) => {
const extraPart = value.split('::')?.[1]
acc[key] = extraPart
return acc
},
{}
)
const [tableIndex, setTableIndex] = useState(0)
const [tableType, setTableType] = useState(
Object.keys(NETWORK_TYPES)?.[0] ?? ''
)
const fields = useMemo(
() =>
createNetworkFields(`${STEP_ID}.${FIELD_ARRAY}.${tableIndex}`, tableType),
[tableIndex, tableType, STEP_ID]
)
useFieldArray({
name: useMemo(() => `${STEP_ID}.${FIELD_ARRAY}`, [STEP_ID, tableIndex]),
control: control,
})
useEffect(() => {
setValue(`${STEP_ID}.${FIELD_ARRAY}.${tableIndex}.tableType`, tableType)
}, [tableType, tableIndex, STEP_ID])
if (fields?.length === 0) {
return null
}
return (
<Box>
<Grid container spacing={2}>
<Grid item xs={6}>
<TextField
select
label="Network ID"
onChange={(e) => {
setTableIndex(e.target.selectedIndex)
}}
fullWidth
InputProps={{
inputProps: { 'data-cy': `select-${STEP_ID}-id` },
}}
variant="outlined"
>
{Object.keys(networkInfo).map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</TextField>
</Grid>
<Grid item xs={6}>
<TextField
select
label="Network Type"
value={tableType}
onChange={(e) => setTableType(e.target.value)}
fullWidth
InputProps={{
inputProps: { 'data-cy': `select-${STEP_ID}-type` },
}}
variant="outlined"
>
{Object.entries(NETWORK_TYPES).map(([key, value]) => (
<option key={key} value={key}>
{value}
</option>
))}
</TextField>
</Grid>
</Grid>
<FormWithSchema cy={`${STEP_ID}`} fields={fields} />
</Box>
)
}
Content.propTypes = {
dataTemplate: PropTypes.object,
isUpdate: PropTypes.bool,
}
/**
* @param {object} template - Service Template
* @returns {object} - Step
*/
const Network = (template) => ({
id: STEP_ID,
label: T.Network,
resolver: createNetworkSchema(),
optionsValidate: { abortEarly: false },
defaultDisabled: {
condition: () => {
const disableStep = !template?.dataTemplate?.TEMPLATE?.BODY?.networks
return disableStep
},
},
content: () => Content(template),
})
Network.propTypes = {
data: PropTypes.object,
setFormData: PropTypes.func,
}
export default Network

View File

@ -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))),
})
}

View File

@ -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

View File

@ -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

View File

@ -417,15 +417,11 @@ const generateTabs = (userInputsLayout, STEP_ID, FIELDS, showMandatoryOnly) => {
key={`user-inputs`}
cy={`user-inputs`}
id={STEP_ID}
fields={
showMandatoryOnly
? FIELDS(
userInputsLayout[0].groups[0].userInputs.filter(
(userInput) => userInput.mandatory
)
)
: FIELDS(userInputsLayout[0].groups[0].userInputs)
}
fields={FIELDS(
userInputsLayout[0].groups[0].userInputs.filter(
(userInput) => !showMandatoryOnly || userInput.mandatory
)
)}
/>
)
}
@ -477,13 +473,18 @@ const generateTabs = (userInputsLayout, STEP_ID, FIELDS, showMandatoryOnly) => {
* @param {Array} userInputs - List of user inputs.
* @returns {Array} - List of fields.
*/
const createFieldsFromUserInputs = (userInputs = []) =>
userInputs.map(({ name, description, label, ...restOfUserInput }) => ({
name,
label: label || name,
...(description && { tooltip: description }),
...schemaUserInput(restOfUserInput),
}))
const createFieldsFromUserInputs = (userInputs = []) => {
const res = userInputs.map(
({ name, description, label, ...restOfUserInput }) => ({
name,
label: label || name,
...(description && { tooltip: description }),
...schemaUserInput(restOfUserInput),
})
)
return res
}
/**
* Groups the user inputs by app and group using the following convetion: ONEAPP_<APP_NAME>_<GROUP_NAME>_<FIELD_NAME>.
@ -610,13 +611,14 @@ const groupUserInputs = (userInputs, userInputsMetadata, prefix) => {
const groupServiceUserInputs = (service) => {
// Get and order service user inputs
const serviceUserInputs = userInputsToArray(
service?.TEMPLATE?.BODY?.custom_attrs
service?.TEMPLATE?.BODY?.user_inputs
)
// Get service user inputs metadata
const serviceUserInputsMetadata = responseDataToArray(
service?.TEMPLATE?.BODY?.custom_attrs_metadata
service?.TEMPLATE?.BODY?.user_inputs_metadata
)
const serviceUserInputsLayout = groupUserInputs(
serviceUserInputs,
serviceUserInputsMetadata

View File

@ -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)

View File

@ -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

View File

@ -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() },
}

View File

@ -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,

View File

@ -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 ?? {}

View File

@ -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,

View File

@ -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',

View File

@ -15,16 +15,21 @@
* ------------------------------------------------------------------------- */
import { ReactElement, memo, useState, useMemo } from 'react'
import PropTypes from 'prop-types'
import { object } from 'yup'
import { ButtonGenerator } from '@modules/components/Tabs/Service/ButtonGenerator'
import { ServiceAPI, VmTemplateAPI, useGeneralApi } from '@FeaturesModule'
import { ServiceAPI, useGeneralApi } from '@FeaturesModule'
import { deepClean } from '@UtilsModule'
import { VmsTable } from '@modules/components/Tables'
import { StatusCircle } from '@modules/components/Status'
import { getRoleState } from '@ModelsModule'
import { Box, Dialog, Typography, CircularProgress } from '@mui/material'
import { Content as RoleAddDialog } from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig'
import { Button, Box, Dialog, Typography } from '@mui/material'
import RoleStep from '@modules/components/Forms/ServiceTemplate/CreateForm/Steps/Roles'
import { ScaleDialog } from '@modules/components/Tabs/Service/ScaleDialog'
import { useForm, FormProvider } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import {
Plus,
Trash,
@ -41,6 +46,38 @@ import { Tr } from '@modules/components/HOC'
// Filters actions based on the data-cy key
const filterActions = ['vm_resume', 'vm-manage', 'vm-host', 'vm-terminate']
const { resolver: rolesResolver, content: RoleAddDialog } = RoleStep()
/* eslint-disable react/prop-types */
const AddRoleDialog = ({ open, onClose, onSubmit }) => {
const methods = useForm({
mode: 'onSubmit',
defaultValues: rolesResolver.default(),
resolver: yupResolver(object().shape({ roles: rolesResolver })),
})
const { handleSubmit } = methods
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<FormProvider {...methods}>
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<RoleAddDialog standaloneModal />
<Button
variant="contained"
color="primary"
type="submit"
sx={{ mt: 2, width: '100%' }}
>
{T.Submit}
</Button>
</Box>
</FormProvider>
</Dialog>
)
}
/* eslint-enable react/prop-types */
/**
* Renders template tab.
*
@ -49,8 +86,6 @@ const filterActions = ['vm_resume', 'vm-manage', 'vm-host', 'vm-terminate']
* @returns {ReactElement} Roles tab
*/
const RolesTab = ({ id }) => {
const [fetch, { data, error, isFetching }] =
VmTemplateAPI.useLazyGetTemplatesQuery()
const { enqueueError, enqueueSuccess, enqueueInfo } = useGeneralApi()
// wrapper
const createApiCallback = (apiFunction) => async (params) => {
@ -59,12 +94,20 @@ const RolesTab = ({ id }) => {
return response
}
// api calls
const [addRole] = ServiceAPI.useServiceAddRoleMutation()
const [addRoleAction] = ServiceAPI.useServiceRoleActionMutation()
const [scaleRole] = ServiceAPI.useServiceScaleRoleMutation()
// api handlers
const handleAddRole = createApiCallback(addRole)
const handleAddRole = async (data) => {
const cleanedRole = deepClean(data?.roles?.[0])
const result = await addRole({ id, role: cleanedRole })
handleCloseAddRole()
return result
}
const handleAddRoleAction = async (actionType) => {
for (const roleIdx of selectedRoles) {
@ -88,7 +131,6 @@ const RolesTab = ({ id }) => {
const handleScaleRole = createApiCallback(scaleRole)
const [activeRole, setActiveRole] = useState({ idx: null, roleName: null })
const [isAddRoleOpen, setAddRoleOpen] = useState(false)
const [isScaleDialogOpen, setScaleDialogOpen] = useState(false)
@ -109,21 +151,6 @@ const RolesTab = ({ id }) => {
[roles]
)
/* eslint-disable react/prop-types */
const AddRoleDialog = ({ open, onClose }) => (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<RoleAddDialog
standaloneModal
standaloneModalCallback={(params) => {
handleAddRole(params)
onClose()
}}
fetchedVmTemplates={{ vmTemplates: data, error: error }}
/>
</Dialog>
)
/* eslint-enable react/prop-types */
const handleRoleClick = (idx, role, event) => {
event.stopPropagation()
@ -150,8 +177,7 @@ const RolesTab = ({ id }) => {
}
}
const handleOpenAddRole = async () => {
await fetch()
const handleOpenAddRole = () => {
setAddRoleOpen(true)
}
@ -185,11 +211,10 @@ const RolesTab = ({ id }) => {
items={{
name: T.AddRole,
onClick: handleOpenAddRole,
icon: isFetching ? <CircularProgress size={24} /> : <Plus />,
icon: <Plus />,
}}
options={{
singleButton: {
disabled: !!isFetching,
sx: {
fontSize: '0.95rem',
padding: '6px 8px',
@ -202,7 +227,11 @@ const RolesTab = ({ id }) => {
},
}}
/>
<AddRoleDialog open={isAddRoleOpen} onClose={handleCloseAddRole} />
<AddRoleDialog
open={isAddRoleOpen}
onClose={handleCloseAddRole}
onSubmit={handleAddRole}
/>
</>
<ButtonGenerator
@ -414,7 +443,7 @@ const RolesTab = ({ id }) => {
minHeight="500px"
onClick={(event) => event.stopPropagation()}
>
<VmsTable
<VmsTable.Table
globalActions={filteredActions}
filterData={roleVms?.[activeRole?.roleName]}
filterLoose={false}
@ -431,7 +460,7 @@ RolesTab.propTypes = { tabProps: PropTypes.object, id: PropTypes.string }
RolesTab.displayName = 'RolesTab'
const RoleComponent = memo(({ role, selected, status }) => {
const { name, cardinality, vm_template: templateId } = role
const { name, cardinality, template_id: templateId } = role
return (
<Box

View File

@ -23,7 +23,7 @@ import { Translate } from '@modules/components/HOC'
import { T, Role } from '@ConstantsModule'
import { PATH } from '@modules/components/path'
const COLUMNS = [T.Name, T.Cardinality, T.VMTemplate, T.Parents]
const COLUMNS = [T.Name, T.Type, T.Cardinality, T.VMTemplate, T.Parents]
/**
* Renders roles tab.
@ -41,7 +41,7 @@ const RolesTab = ({ id }) => {
return (
<Box
display="grid"
gridTemplateColumns="repeat(4, 1fr)"
gridTemplateColumns="repeat(5, 1fr)"
padding="1em"
bgcolor="background.default"
>
@ -70,7 +70,7 @@ RolesTab.displayName = 'RolesTab'
const RoleComponent = memo(({ role }) => {
/** @type {Role} */
const { name, cardinality, vm_template: templateId, parents } = role
const { name, type, cardinality, template_id: templateId, parents } = role
const { data: template, isLoading } = VmTemplateAPI.useGetTemplatesQuery(
undefined,
@ -94,6 +94,9 @@ const RoleComponent = memo(({ role }) => {
<Typography {...commonProps} data-cy="name">
{name}
</Typography>
<Typography {...commonProps} data-cy="type">
{type}
</Typography>
<Typography {...commonProps} data-cy="cardinality">
{cardinality}
</Typography>

View File

@ -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`,
}),
}))

View File

@ -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,

View File

@ -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',

View File

@ -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()
)
)

View File

@ -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',

View File

@ -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()

View File

@ -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?.()

View File

@ -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%',

View File

@ -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
}

View File

@ -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,

View File

@ -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,
}
}
/**

View File

@ -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',

View File

@ -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,

View File

@ -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

View File

@ -29,4 +29,6 @@ require 'opennebula/flow/service_template'
require 'opennebula/flow/validator'
require 'models/role'
require 'models/vmrole'
require 'models/vrrole'
require 'models/service'

File diff suppressed because it is too large Load Diff

View File

@ -104,8 +104,8 @@ module OpenNebula
# List of attributes that can't be changed in update operation
#
# custom_attrs: it only has sense when deploying, not in running
# custom_attrs_values: it only has sense when deploying, not in running
# user_inputs: it only has sense when deploying, not in running
# user_inputs_values: it only has sense when deploying, not in running
# deployment: changing this, changes the undeploy operation
# log: this is just internal information, no sense to change it
# name: this has to be changed using rename operation
@ -115,8 +115,8 @@ module OpenNebula
# state: this is internal information managed by OneFlow server
# start_time: this is internal information managed by OneFlow server
IMMUTABLE_ATTRS = [
'custom_attrs',
'custom_attrs_values',
'user_inputs',
'user_inputs_values',
'deployment',
'log',
'name',
@ -129,6 +129,12 @@ module OpenNebula
LOG_COMP = 'SER'
# Returns the service name
# @return [String] the service name
def name
@body['name']
end
# Returns the service state
# @return [Integer] the service state
def state
@ -214,6 +220,10 @@ module OpenNebula
self['UNAME']
end
def uid
self['UID'].to_i
end
def gid
self['GID'].to_i
end
@ -224,8 +234,9 @@ module OpenNebula
@body['on_hold']
end
def hold?
state_str == 'HOLD'
# Change the `on_hold` option value
def on_hold=(on_hold)
@body['on_hold'] = on_hold
end
# Replaces this object's client with a new one
@ -237,20 +248,14 @@ module OpenNebula
# Sets a new state
# @param [Integer] the new state
# @return [true, false] true if the value was changed
# rubocop:disable Naming/AccessorMethodName
def set_state(state)
# rubocop:enable Naming/AccessorMethodName
if state < 0 || state > STATE_STR.size
return false
end
def state=(state)
return if state < 0 || state > STATE_STR.size
@body['state'] = state.to_i
msg = "New state: #{STATE_STR[state]}"
Log.info LOG_COMP, msg, id
log_info(msg)
true
end
# Returns true if all the nodes are correctly deployed
@ -323,9 +328,6 @@ module OpenNebula
template['start_time'] = Integer(Time.now)
# Replace $attibute by the corresponding value
resolve_attributes(template)
super(template.to_json, template['name'])
end
@ -336,38 +338,38 @@ module OpenNebula
if [Service::STATE['FAILED_DEPLOYING']].include?(state)
@roles.each do |_name, role|
if role.state == Role::STATE['FAILED_DEPLOYING']
role.set_state(Role::STATE['PENDING'])
role.state = Role::STATE['PENDING']
end
end
set_state(Service::STATE['DEPLOYING'])
self.state = Service::STATE['DEPLOYING']
elsif state == Service::STATE['FAILED_SCALING']
@roles.each do |_name, role|
if role.state == Role::STATE['FAILED_SCALING']
role.set_state(Role::STATE['SCALING'])
role.state = Role::STATE['SCALING']
end
end
set_state(Service::STATE['SCALING'])
self.state = Service::STATE['SCALING']
elsif state == Service::STATE['FAILED_UNDEPLOYING']
@roles.each do |_name, role|
if role.state == Role::STATE['FAILED_UNDEPLOYING']
role.set_state(Role::STATE['RUNNING'])
role.state = Role::STATE['RUNNING']
end
end
set_state(Service::STATE['UNDEPLOYING'])
self.state = Service::STATE['UNDEPLOYING']
elsif state == Service::STATE['COOLDOWN']
@roles.each do |_name, role|
if role.state == Role::STATE['COOLDOWN']
role.set_state(Role::STATE['RUNNING'])
role.state = Role::STATE['RUNNING']
end
end
set_state(Service::STATE['RUNNING'])
self.state = Service::STATE['RUNNING']
elsif state == Service::STATE['WARNING']
@roles.each do |_name, role|
@ -396,7 +398,7 @@ module OpenNebula
if @body['roles']
@body['roles'].each do |elem|
elem['state'] ||= Role::STATE['PENDING']
role = Role.new(elem, self)
role = Role.for(elem, self)
@roles[role.name] = role
end
end
@ -411,7 +413,7 @@ module OpenNebula
# @return [OpenNebula::Role] New role
def add_role(template)
template['state'] ||= Role::STATE['PENDING']
role = Role.new(template, self)
role = Role.for(template, self)
if @roles[role.name]
return OpenNebula::Error.new("Role #{role.name} already exists")
@ -444,7 +446,7 @@ module OpenNebula
if @body['roles']
@body['roles'].each do |elem|
elem['state'] ||= Role::STATE['PENDING']
role = Role.new(elem, self)
role = Role.for(elem, self)
@roles[role.name] = role
end
end
@ -540,11 +542,11 @@ module OpenNebula
# TODO: The update may not change the cardinality, only
# the max and min vms...
role.set_state(Role::STATE['SCALING'])
role.state = Role::STATE['SCALING']
role.set_default_cooldown_duration
set_state(Service::STATE['SCALING'])
self.state = Service::STATE['SCALING']
update
end
@ -634,6 +636,19 @@ module OpenNebula
[true, nil]
end
# Fills the service template with the provided values.
#
# This method replaces placeholders in the service template with corresponding values
# Placeholders are expected to be in the format $key.
#
# @return [nil, OpenNebula::Error] nil in case of success, Error otherwise
def fill_template
generate_template_contents
rescue StandardError => e
Log.error LOG_COMP, "Error generating VM template contents: #{e.message}"
OpenNebula::Error('Error generating VM template contents')
end
def deploy_networks(deploy = true)
body = if deploy
JSON.parse(self['TEMPLATE/BODY'])
@ -659,9 +674,6 @@ module OpenNebula
end
end if deploy
# Replace $attibute by the corresponding value
resolve_networks(body)
# @body = template.to_hash
update_body(body)
@ -726,7 +738,7 @@ module OpenNebula
if @body['roles']
@body['roles'].each do |elem|
elem['state'] ||= Role::STATE['PENDING']
role = Role.new(elem, self)
role = Role.for(elem, self)
@roles[role.name] = role
end
end
@ -779,60 +791,124 @@ module OpenNebula
"#{net}-#{id}"
end
# rubocop:disable Layout/LineLength
def resolve_networks(template)
template['roles'].each do |role|
next unless role['vm_template_contents']
# Generates and updates the `template_contents` for each role within a service.
# This method handles VM attributes (like MEMORY, CPU, etc.) and CONTEXT attributes
# within `template_contents` for each role. The contents are generated by combining
# the `user_inputs_values` from both the service and the individual role, with the
# role inputs taking precedence over the service inputs.
#
# The method also resolves network configurations for each role by mapping network
# IDs from the service-level `networks_values` to the NICs defined in the role's
# `template_contents`.
#
# @example
# Given the following input data:
# template_contents = {
# 'MEMORY' => '1024',
# 'NIC' => [
# {
# 'NAME' => 'NIC_0',
# 'NETWORK_ID' => '$private'
# }
# ]
# }
#
# networks_values = [{"private": {"id":"0"}}]
# user_inputs_values = {"ATT_A": "VALUE_A"}
#
# After executing `generate_template_contents`, the result would be:
# {
# 'ATT_A' => 'VALUE_A',
# 'MEMORY' => '1024',
# 'NIC' => [
# {
# 'NAME' => 'NIC_0',
# 'NETWORK_ID' => '0'
# }
# ],
# 'CONTEXT' => {
# 'ATT_A' => '$VALUE_A',
# }
# }
#
def generate_template_contents
service_inputs = @body['user_inputs_values'] || {}
service_networks = @body['networks_values'] || []
# $CUSTOM1_VAR Any word character
# (letter, number, underscore)
role['vm_template_contents'].scan(/\$(\w+)/).each do |key|
net = template['networks_values'].find {|att| att.key? key[0] }
@body['roles'].each do |role|
template_contents = role['template_contents'] || {}
role_inputs = role['user_inputs_values'] || {}
role_nets = template_contents['NIC'] || []
next if net.nil?
role['vm_template_contents'].gsub!("$#{key[0]}", net[key[0]]['id'].to_s)
# Resolve networks
unless role_nets.empty?
template_contents['NIC'] = resolve_networks(role_nets, service_networks)
end
end
end
def resolve_attributes(template)
template['roles'].each do |role|
if role['vm_template_contents']
# $CUSTOM1_VAR Any word character
# (letter, number, underscore)
role['vm_template_contents'].scan(/\$(\w+)/).each do |key|
# Check if $ var value is in custom_attrs_values within the role
if !role['custom_attrs_values'].nil? && \
role['custom_attrs_values'].key?(key[0])
role['vm_template_contents'].gsub!(
'$'+key[0],
role['custom_attrs_values'][key[0]]
)
next
end
# Resolve inputs
unless service_inputs.empty? && role_inputs.empty?
# role inputs have precedence over service inputs
role_inputs = service_inputs.deep_merge(role_inputs)
# Check if $ var value is in custom_attrs_values
# Add the role inputs to the template_contents,
# creating the CONTEXT section in case it doesn't exist
template_contents['CONTEXT'] = {} unless template_contents.key?('CONTEXT')
next unless !template['custom_attrs_values'].nil? && \
template['custom_attrs_values'].key?(key[0])
role['vm_template_contents'].gsub!(
'$'+key[0],
template['custom_attrs_values'][key[0]]
)
role_inputs.each do |key, value|
template_contents[key] = value
template_contents['CONTEXT'][key] = "$#{key}"
end
end
next unless role['user_inputs_values']
role['vm_template_contents'] ||= ''
role['user_inputs_values'].each do |key, value|
role['vm_template_contents'] += "\n#{key}=\"#{value}\""
end
role['template_contents'] = template_contents
end
end
# rubocop:enable Layout/LineLength
# Replaces the `NETWORK_ID` placeholders in the given NICs with their corresponding
# network IDs based on the provided `networks_values`. This method is used to resolve
# dynamic network references (e.g., `$private`) in the role's NIC configuration with
# the actual network IDs.
#
# @param nics [Array<Hash>] An array of NIC hashes for a role. Each NIC hash should
# contain a `NETWORK_ID` key, which may have a value that
# is a placeholder in the form `$network_name`.
# @param networks_values [Array<Hash>] An array of network values, where each value
# is a hash containing a network name as the key
# and a network configuration as the value. The network
# configuration should include an `id` key with the
# actual network ID.
#
# @return [Array<Hash>] An array of NIC hashes with the `NETWORK_ID` placeholders replaced
# by the corresponding network IDs from `networks_values`.
#
# @example
# Given the following input data:
# nics = [
# { 'NAME' => 'NIC_0', 'NETWORK_ID' => '$private' },
# { 'NAME' => 'NIC_1', 'NETWORK_ID' => '1' }
# ]
#
# networks_values = [{ 'private' => { 'id' => '0' } }]
#
# After calling `resolve_networks(nics, networks_values)`, the result would be:
# [
# { 'NAME' => 'NIC_0', 'NETWORK_ID' => '0' },
# { 'NAME' => 'NIC_1', 'NETWORK_ID' => '1' }
# ]
def resolve_networks(nics, networks_values)
nics.each do |nic|
next unless nic['NETWORK_ID']
match = nic['NETWORK_ID'].match(/\$(\w+)/)
next unless match
net_name = match[1]
network = networks_values.find {|att| att.key?(net_name) }
nic['NETWORK_ID'] = network[net_name]['id'] if network
end
nics
end
end

View File

@ -0,0 +1,700 @@
# -------------------------------------------------------------------------- #
# Copyright 2002-2024, OpenNebula Project, OpenNebula Systems #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may #
# not use this file except in compliance with the License. You may obtain #
# a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
#--------------------------------------------------------------------------- #
module OpenNebula
# Service Role class
class VMRole < Role
attr_reader :service
def initialize(body, service)
super(body, service)
@body['cooldown'] = @@default_cooldown if @body['cooldown'].nil?
end
# Sets a new state
# @param [Integer] the new state
def state=(state)
super(state)
return unless state == STATE['SCALING']
elasticity_pol = @body['elasticity_policies']
return if elasticity_pol.nil?
elasticity_pol.each do |policy|
policy.delete('true_evals')
end
end
########################################################################
# Operations
########################################################################
# Changes the owner/group of all the nodes in this role
#
# @param [Integer] uid the new owner id. Set to -1 to leave the current
# @param [Integer] gid the new group id. Set to -1 to leave the current
#
# @return [Array<true, nil>, Array<false, String>] true if all the VMs
# were updated, false and the error reason if there was a problem
# updating the VMs
def chown(uid, gid)
nodes.each do |node|
vm_id = node['deploy_id']
Log.debug LOG_COMP,
"Role #{name} : Chown for VM #{vm_id}",
@service.id
vm = OpenNebula::VirtualMachine.new_with_id(vm_id,
@service.client)
rc = vm.chown(uid, gid)
if OpenNebula.is_error?(rc)
msg = "Role #{name} : Chown failed for VM #{vm_id}; " \
"#{rc.message}"
Log.error LOG_COMP, msg, @service.id
@service.log_error(msg)
return [false, rc.message]
else
Log.debug LOG_COMP,
"Role #{name} : Chown success for VM #{vm_id}",
@service.id
end
end
[true, nil]
end
# Updates the role
# @param [Hash] template
# @return [nil, OpenNebula::Error] nil in case of success, Error
# otherwise
def update(template)
force = template['force'] == true
new_cardinality = template['cardinality']
return if new_cardinality.nil?
new_cardinality = new_cardinality.to_i
if !force
if new_cardinality < min_cardinality.to_i
return OpenNebula::Error.new(
"Minimum cardinality is #{min_cardinality}"
)
elsif !max_cardinality.nil? &&
new_cardinality > max_cardinality.to_i
return OpenNebula::Error.new(
"Maximum cardinality is #{max_cardinality}"
)
end
end
self.cardinality = new_cardinality
nil
end
########################################################################
# Scheduler
########################################################################
# Schedule the given action on all the VMs that belong to the Role
# @param [String] action one of the available SCHEDULE_ACTIONS
# @param [Integer] period
# @param [Integer] vm_per_period
# @param [String] action arguments
def batch_action(action, period, vms_per_period, args)
vms_id = []
error_msgs = []
nodes = @body['nodes']
now = Time.now.to_i
time_offset = 0
# if role is done, return error
if state == 5
return OpenNebula::Error.new("Role #{name} is in DONE state")
end
do_offset = !period.nil? && period.to_i > 0 &&
!vms_per_period.nil? && vms_per_period.to_i > 0
nodes.each_with_index do |node, index|
vm_id = node['deploy_id']
vm = OpenNebula::VirtualMachine.new_with_id(vm_id,
@service.client)
if do_offset
offset = (index / vms_per_period.to_i).floor
time_offset = offset * period.to_i
end
tmp_str = 'SCHED_ACTION = ['
tmp_str << "ACTION = #{action},"
tmp_str << "ARGS = \"#{args}\"," if args
tmp_str << "TIME = #{now + time_offset}]"
rc = vm.sched_action_add(tmp_str)
if OpenNebula.is_error?(rc)
msg = "Role #{name} : VM #{vm_id} error scheduling "\
"action; #{rc.message}"
error_msgs << msg
Log.error LOG_COMP, msg, @service.id
@service.log_error(msg)
else
vms_id << vm.id
end
end
log_msg = "Action:#{action} scheduled on Role:#{name}"\
"VMs:#{vms_id.join(',')}"
Log.info LOG_COMP, log_msg, @service.id
return [true, log_msg] if error_msgs.empty?
error_msgs << log_msg
[false, error_msgs.join('\n')]
end
########################################################################
# Scalability
########################################################################
# Returns the role max cardinality
# @return [Integer,nil] the role cardinality or nil if it isn't defined
def max_cardinality
max = @body['max_vms']
return if max.nil?
max.to_i
end
# Returns the role min cardinality
# @return [Integer,nil] the role cardinality or nil if it isn't defined
def min_cardinality
min = @body['min_vms']
return if min.nil?
min.to_i
end
# Returns a positive, 0, or negative number of nodes to adjust,
# according to the elasticity and scheduled policies
# @return [Array<Integer>] positive, 0, or negative number of nodes to
# adjust, plus the cooldown period duration
def scale?(vm_pool)
elasticity_pol = @body['elasticity_policies']
scheduled_pol = @body['scheduled_policies']
elasticity_pol ||= []
scheduled_pol ||= []
scheduled_pol.each do |policy|
diff, cooldown_duration = scale_time?(policy)
return [diff, cooldown_duration] if diff != 0
end
elasticity_pol.each do |policy|
diff, cooldown_duration = scale_attributes?(policy, vm_pool)
next if diff == 0
cooldown_duration = @body['cooldown'] if cooldown_duration.nil?
cooldown_duration = @@default_cooldown if cooldown_duration.nil?
return [diff, cooldown_duration]
end
# Implicit rule that scales up to maintain the min_cardinality, with
# no cooldown period
if cardinality < min_cardinality.to_i
return [min_cardinality.to_i - cardinality, 0]
end
[0, 0]
end
def elasticity_policies
@body['elasticity_policies']
end
def update_elasticity_policies(new_policies)
@body['elasticity_policies'] = new_policies
end
def cooldown
@body['cooldown']
end
def update_cooldown(new_cooldown)
@body['cooldown'] = new_cooldown unless new_cooldown.nil?
end
def scale_way(way)
@body['scale_way'] = SCALE_WAYS[way]
end
def clean_scale_way
@body.delete('scale_way')
end
########################################################################
# Deployment
########################################################################
# Deploys all the nodes in this role
#
# @return [Array<true, nil>, Array<false, String>] true if all the VMs
# were created, false and the error reason if there was a problem
# creating the VMs
def deploy
deployed_nodes = []
n_nodes = cardinality - nodes.size
return [deployed_nodes, nil] if n_nodes == 0
template_id, template, extra_template = init_template_attributes
n_nodes.times do
vm_name = @@vm_name_template
.gsub('$SERVICE_ID', @service.id.to_s)
.gsub('$SERVICE_NAME', @service.name.to_s)
.gsub('$ROLE_NAME', name.to_s)
.gsub('$VM_NUMBER', @body['last_vmname'].to_s)
@body['last_vmname'] += 1
Log.debug(
LOG_COMP,
"Role #{name} : Instantiate template #{template_id}, name #{vm_name}",
@service.id
)
# Instantiate VM
vm_id = template.instantiate(vm_name, on_hold?, extra_template)
if OpenNebula.is_error?(vm_id)
msg = "Role #{name} : Instantiate failed for template " \
"#{template_id}; #{vm_id.message}"
Log.error(LOG_COMP, msg, @service.id)
@service.log_error(msg)
return [false, "Error instantiating VM Template #{template_id} in Role " \
"#{name}: #{vm_id.message}"]
end
Log.debug(
LOG_COMP,
"Role #{name} : Instantiate success, VM ID #{vm_id}",
@service.id
)
# Once deployed, save VM info in role node body
deployed_nodes << vm_id
fill_node_info(vm_id)
end
[deployed_nodes, nil]
end
########################################################################
# Recover
########################################################################
def recover_deploy(report)
nodes = @body['nodes']
deployed_nodes = []
nodes.each do |node|
vm_id = node['deploy_id']
vm = OpenNebula::VirtualMachine.new_with_id(vm_id,
@service.client)
rc = vm.info
if OpenNebula.is_error?(rc)
msg = "Role #{name} : Retry failed for VM "\
"#{vm_id}; #{rc.message}"
Log.error LOG_COMP, msg, @service.id
next true
end
vm_state = vm.state
lcm_state = vm.lcm_state
# ACTIVE/RUNNING
next false if vm_state == 3 && lcm_state == 3 && !report
next true if vm_state == '6' # Delete DONE nodes
if Role.vm_failure?(vm_state, lcm_state)
rc = vm.recover(2)
if OpenNebula.is_error?(rc)
msg = "Role #{name} : Retry failed for VM "\
"#{vm_id}; #{rc.message}"
Log.error LOG_COMP, msg, @service.id
@service.log_error(msg)
else
deployed_nodes << vm_id
end
else
vm.resume
deployed_nodes << vm_id
end
end
rc = deploy
unless rc[0]
return [false, "Error deploying nodes for role `#{name}`"]
end
deployed_nodes.concat(rc[0])
deployed_nodes
end
def recover_undeploy
undeployed_nodes = []
rc = shutdown(true)
undeployed_nodes.concat(rc[0]) if rc[1].nil?
undeployed_nodes
end
def recover_scale(report)
rc = nil
if @body['scale_way'] == SCALE_WAYS['UP']
rc = [recover_deploy(report), true]
elsif @body['scale_way'] == SCALE_WAYS['DOWN']
rc = [recover_undeploy, false]
end
rc
end
########################################################################
# Helpers
########################################################################
private
# Shuts down all the given nodes
# @param scale_down [true,false] True to set the 'disposed' node flag
def shutdown_nodes(nodes, n_nodes, recover)
success = true
undeployed_nodes = []
action = @body['shutdown_action']
if action.nil?
action = @service.shutdown_action
end
if action.nil?
action = @@default_shutdown
end
nodes[0..n_nodes - 1].each do |node|
vm_id = node['deploy_id']
Log.debug(LOG_COMP,
"Role #{name} : Terminating VM #{vm_id}",
@service.id)
vm = OpenNebula::VirtualMachine.new_with_id(vm_id,
@service.client)
vm_state = nil
lcm_state = nil
if recover
vm.info
vm_state = vm.state
lcm_state = vm.lcm_state
end
if recover && Role.vm_failure?(vm_state, lcm_state)
rc = vm.recover(2)
elsif action == 'terminate-hard'
rc = vm.terminate(true)
else
rc = vm.terminate
end
if OpenNebula.is_error?(rc)
msg = "Role #{name} : Terminate failed for VM #{vm_id}, " \
"will perform a Delete; #{rc.message}"
Log.error LOG_COMP, msg, @service.id
@service.log_error(msg)
if action != 'terminate-hard'
rc = vm.terminate(true)
end
if OpenNebula.is_error?(rc)
rc = vm.delete
end
if OpenNebula.is_error?(rc)
msg = "Role #{name} : Delete failed for VM #{vm_id}; " \
"#{rc.message}"
Log.error LOG_COMP, msg, @service.id
@service.log_error(msg)
success = false
else
Log.debug(LOG_COMP,
"Role #{name} : Delete success for VM " \
"#{vm_id}",
@service.id)
undeployed_nodes << vm_id
end
else
Log.debug(LOG_COMP,
"Role #{name}: Terminate success for VM #{vm_id}",
@service.id)
undeployed_nodes << vm_id
end
end
[success, undeployed_nodes]
end
# Returns a positive, 0, or negative number of nodes to adjust,
# according to a SCHEDULED type policy
# @param [Hash] A SCHEDULED type policy
# @return [Integer] positive, 0, or negative number of nodes to adjust
def scale_time?(elasticity_pol)
now = Time.now.to_i
last_eval = elasticity_pol['last_eval'].to_i
elasticity_pol['last_eval'] = now
# If this is the first time this is evaluated, ignore it.
# We don't want to execute actions planned in the past when the
# server starts.
return 0 if last_eval == 0
start_time = elasticity_pol['start_time']
target_vms = elasticity_pol['adjust']
# TODO: error msg
return 0 if target_vms.nil?
if !(start_time.nil? || start_time.empty?)
begin
if !start_time.match(/^\d+$/)
start_time = Time.parse(start_time).to_i
else
start_time = start_time.to_i
end
rescue ArgumentError
# TODO: error msg
return 0
end
else
recurrence = elasticity_pol['recurrence']
# TODO: error msg
return 0 if recurrence.nil? || recurrence.empty?
begin
cron_parser = CronParser.new(recurrence)
# This returns the next planned time, starting from the last
# step
start_time = cron_parser.next(Time.at(last_eval)).to_i
rescue StandardError
# TODO: error msg bad format
return 0
end
end
# Only actions planned between last step and this one are triggered
if start_time > last_eval && start_time <= now
Log.debug LOG_COMP,
"Role #{name} : scheduled scalability for " \
"#{Time.at(start_time)} triggered", @service.id
new_cardinality = calculate_new_cardinality(elasticity_pol)
return [new_cardinality - cardinality,
elasticity_pol['cooldown']]
end
[0, elasticity_pol['cooldown']]
end
# Returns a positive, 0, or negative number of nodes to adjust,
# according to a policy based on attributes
# @param [Hash] A policy based on attributes
# @return [Array<Integer>] positive, 0, or negative number of nodes to
# adjust, plus the cooldown period duration
def scale_attributes?(elasticity_pol, vm_pool)
now = Time.now.to_i
# TODO: enforce true_up_evals type in ServiceTemplate::ROLE_SCHEMA ?
period_duration = elasticity_pol['period'].to_i
period_number = elasticity_pol['period_number'].to_i
last_eval = elasticity_pol['last_eval'].to_i
true_evals = elasticity_pol['true_evals'].to_i
expression = elasticity_pol['expression']
if !last_eval.nil? && now < (last_eval + period_duration)
return [0, 0]
end
elasticity_pol['last_eval'] = now
new_cardinality = cardinality
new_evals = 0
exp_value, exp_st = scale_rule(expression, vm_pool)
if exp_value
new_evals = true_evals + 1
new_evals = period_number if new_evals > period_number
if new_evals >= period_number
Log.debug LOG_COMP,
"Role #{name} : elasticy policy #{exp_st} "\
'triggered', @service.id
new_cardinality = calculate_new_cardinality(elasticity_pol)
end
end
elasticity_pol['true_evals'] = new_evals
elasticity_pol['expression_evaluated'] = exp_st
[new_cardinality - cardinality, elasticity_pol['cooldown']]
end
# Returns true if the scalability rule is triggered
# @return true if the scalability rule is triggered
def scale_rule(elas_expr, vm_pool)
parser = ElasticityGrammarParser.new
if elas_expr.nil? || elas_expr.empty?
return false
end
treetop = parser.parse(elas_expr)
if treetop.nil?
return [false,
"Parse error. '#{elas_expr}': #{parser.failure_reason}"]
end
val, st = treetop.result(self, vm_pool)
[val, st]
end
def calculate_new_cardinality(elasticity_pol)
type = elasticity_pol['type']
adjust = elasticity_pol['adjust'].to_i
# Min is a hard limit, if the current cardinality + adjustment does
# not reach it, the difference is added
max = [cardinality, max_cardinality.to_i].max
# min = [cardinality(), min_cardinality.to_i].min()
min = min_cardinality.to_i
case type.upcase
when 'CHANGE'
new_cardinality = cardinality + adjust
when 'PERCENTAGE_CHANGE'
min_adjust_step = elasticity_pol['min_adjust_step'].to_i
change = cardinality * adjust / 100.0
change > 0 ? sign = 1 : sign = -1
change = change.abs
if change < 1
change = 1
else
change = change.to_i
end
change = sign * [change, min_adjust_step].max
new_cardinality = cardinality + change
when 'CARDINALITY'
new_cardinality = adjust
else
Log.error(
LOG_COMP,
"Error calculating new cardinality for type #{type}",
service.id
)
return cardinality
end
# The cardinality can be forced to be outside the min,max
# range. If that is the case, the scale up/down will not
# move further outside the range. It will move towards the
# range with the adjustement set, instead of jumping the
# difference
if adjust > 0
new_cardinality = max if new_cardinality > max
elsif adjust < 0
new_cardinality = min if new_cardinality < min
end
new_cardinality
end
end
end

View File

@ -0,0 +1,266 @@
# -------------------------------------------------------------------------- #
# Copyright 2002-2024, OpenNebula Project, OpenNebula Systems #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may #
# not use this file except in compliance with the License. You may obtain #
# a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
#--------------------------------------------------------------------------- #
module OpenNebula
# Virtual Route Role class
class VRRole < Role
attr_reader :service
########################################################################
# Operations
########################################################################
def chown(uid, gid)
vrouter_id = @body['vrouter_id']
vrouter = OpenNebula::VirtualRouter.new_with_id(
vrouter_id, @service.client
)
rc = vrouter.chown(uid, gid)
if OpenNebula.is_error?(rc)
msg = "Role #{name} : Chown failed for VR #{vrouter_id}; " \
"#{rc.message}"
Log.error(LOG_COMP, msg, @service.id)
@service.log_error(msg)
return [false, rc.message]
else
msg = "Role #{name} : Chown success for VR #{vrouter_id}"
Log.debug(LOG_COMP, msg, @service.id)
end
[true, nil]
end
def update(_)
return OpenNebula::Error.new(
"Virtual Router role #{name} does not support cardinality update"
)
end
########################################################################
# Scheduler
########################################################################
def batch_action(_, _, _, _)
return OpenNebula::Error.new(
"Virtual Router role #{name} does not support schedule actions"
)
end
########################################################################
# Scalability
########################################################################
# Returns the role max cardinality
# @return [Integer,nil] the role cardinality
def max_cardinality
return cardinality
end
# Returns the role min cardinality
# @return [Integer,nil] the role cardinality
def min_cardinality
return cardinality
end
def scale?(_)
return [0, 0]
end
def elasticity_policies
[]
end
def update_elasticity_policies(_)
[]
end
def cooldown
[]
end
def update_cooldown(_)
[]
end
def scale_way(_)
[]
end
def clean_scale_way
[]
end
########################################################################
# Deployment
########################################################################
# Deploys all the nodes in this role
#
# @return [Array<true, nil>, Array<false, String>] true if all the VMs
# were created, false and the error reason if there was a problem
# creating the VMs
def deploy
deployed_nodes = []
n_nodes = cardinality - nodes.size
return [deployed_nodes, nil] if n_nodes == 0
vr_name = @@vr_name_template
.gsub('$SERVICE_ID', @service.id.to_s)
.gsub('$SERVICE_NAME', @service.name.to_s)
.gsub('$ROLE_NAME', name.to_s)
@body['template_contents']['NAME'] = vr_name
template_id, _, extra_template = init_template_attributes
# Create vrouter Object and description
vrouter = VirtualRouter.new(
VirtualRouter.build_xml(@service.uid),
@service.client
)
Log.debug(
LOG_COMP,
"Role #{name} : Creating service VRouter",
@service.id
)
# Allocating VR with role description provided
rc = vrouter.allocate(extra_template)
if OpenNebula.is_error?(rc)
msg = "Role #{name} : Allocate failed for Vrouter " \
"#{template_id}; #{rc.message}"
Log.error(LOG_COMP, msg, @service.id)
@service.log_error(msg)
return [false, "Error allocating Vrouter #{template_id} in Role " \
"#{name}: #{rc.message}"]
end
Log.debug(
LOG_COMP,
"Role #{name} : Instantiating VRouter #{vrouter.id}",
@service.id
)
# Instantiating Vrouters
vm_name = @@vm_name_template
.gsub('$SERVICE_ID', @service.id.to_s)
.gsub('$SERVICE_NAME', @service.name.to_s)
.gsub('$ROLE_NAME', name.to_s)
.gsub('$VM_NUMBER', '%i')
rc = vrouter.instantiate(
n_nodes,
template_id,
vm_name,
on_hold?,
extra_template
)
if OpenNebula.is_error?(rc)
msg = "Role #{name} : Instantiate failed for Vrouter " \
"#{vrouter.id}; #{rc.message}"
Log.error(LOG_COMP, msg, @service.id)
@service.log_error(msg)
return [false, "Error instantiating Vrouter #{vrouter.id} in Role " \
"#{name}: #{rc.message}"]
end
vrouter.info
# Once deployed, save VM info in role node body
deployed_nodes.concat(vrouter.vm_ids)
deployed_nodes.each do |vm_id|
fill_node_info(vm_id)
end
@body['vrouter_id'] = vrouter.id
# Fill vrouter IP in vrouter role body
vrouter_nics = vrouter.to_hash['VROUTER']['TEMPLATE']['NIC']
if vrouter_nics.is_a?(Array) && !vrouter_nics.empty?
@body['vrouter_ips'] = vrouter_nics.map do |nic|
next unless nic.is_a?(Hash) && nic.key?('VROUTER_IP')
{
'NETWORK_ID' => nic['NETWORK_ID'].to_i,
'VROUTER_IP' => nic['VROUTER_IP']
}
end.compact
end
[deployed_nodes, nil]
end
########################################################################
# Recover
########################################################################
# VRs do not support scale operations, returing empty array with
# zero nodes deployed / shutdown
def recover_scale(_)
[]
end
########################################################################
# Helpers
########################################################################
def shutdown_nodes(nodes, n_nodes, _)
success = true
vrouter_id = @body['vrouter_id']
return [success, nodes] if nodes.empty? && vrouter_id.nil?
msg = "Role #{name} : Terminating VR #{vrouter_id} (#{n_nodes} VMs associated)"
Log.debug(LOG_COMP, msg, @service.id)
vrouter = OpenNebula::VirtualRouter.new_with_id(
vrouter_id, @service.client
)
rc = vrouter.delete
if OpenNebula.is_error?(rc)
msg = "Role #{name} : Delete failed for VR #{vrouter_id}: #{rc.message}"
Log.error(LOG_COMP, msg, @service.id)
@service.log_error(msg)
success = false
end
[success, nodes]
end
end
end

View File

@ -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)

View File

@ -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

View File

@ -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