diff --git a/src/fireedge/src/client/apps/sunstone/routesOne.js b/src/fireedge/src/client/apps/sunstone/routesOne.js index eacce5d774..f4ebd75cd6 100644 --- a/src/fireedge/src/client/apps/sunstone/routesOne.js +++ b/src/fireedge/src/client/apps/sunstone/routesOne.js @@ -104,8 +104,17 @@ const ServiceTemplates = loadable( () => import('client/containers/ServiceTemplates'), { ssr: false } ) -// const DeployServiceTemplates = loadable(() => import('client/containers/ServiceTemplates/Instantiate'), { ssr: false }) -// const CreateServiceTemplates = loadable(() => import('client/containers/ServiceTemplates/Create'), { ssr: false }) + +const InstantiateServiceTemplate = loadable( + () => import('client/containers/ServiceTemplates/Instantiate'), + { ssr: false } +) + +const CreateServiceTemplates = loadable( + () => import('client/containers/ServiceTemplates/Create'), + { ssr: false } +) + const ServiceTemplateDetail = loadable( () => import('client/containers/ServiceTemplates/Detail'), { ssr: false } @@ -314,7 +323,7 @@ export const PATH = { SERVICES: { LIST: `/${RESOURCE_NAMES.SERVICE_TEMPLATE}`, DETAIL: `/${RESOURCE_NAMES.SERVICE_TEMPLATE}/:id`, - DEPLOY: `/${RESOURCE_NAMES.SERVICE_TEMPLATE}/deploy/`, + INSTANTIATE: `/${RESOURCE_NAMES.SERVICE_TEMPLATE}/instantiate/`, CREATE: `/${RESOURCE_NAMES.SERVICE_TEMPLATE}/create`, }, }, @@ -485,6 +494,13 @@ const ENDPOINTS = [ path: PATH.TEMPLATE.VMS.DETAIL, Component: VMTemplateDetail, }, + { + title: T.InstantiateServiceTemplate, + description: (_, state) => + state?.ID !== undefined && `#${state.ID} ${state.NAME}`, + path: PATH.TEMPLATE.SERVICES.INSTANTIATE, + Component: InstantiateServiceTemplate, + }, { title: T.ServiceTemplates, path: PATH.TEMPLATE.SERVICES.LIST, @@ -492,13 +508,6 @@ const ENDPOINTS = [ icon: ServiceTemplateIcon, Component: ServiceTemplates, }, - /* { - title: T.DeployServiceTemplate, - description: (_, state) => - state?.ID !== undefined && `#${state.ID} ${state.NAME}`, - path: PATH.TEMPLATE.SERVICES.DEPLOY, - Component: DeployServiceTemplates, - }, { title: (_, state) => state?.ID !== undefined @@ -508,7 +517,7 @@ const ENDPOINTS = [ state?.ID !== undefined && `#${state.ID} ${state.NAME}`, path: PATH.TEMPLATE.SERVICES.CREATE, Component: CreateServiceTemplates, - }, */ + }, { title: T.ServiceTemplate, description: (params) => `#${params?.id}`, diff --git a/src/fireedge/src/client/components/Cards/ServiceCard.js b/src/fireedge/src/client/components/Cards/ServiceCard.js index 34dc64f68b..799c801cb5 100644 --- a/src/fireedge/src/client/components/Cards/ServiceCard.js +++ b/src/fireedge/src/client/components/Cards/ServiceCard.js @@ -15,59 +15,35 @@ * ------------------------------------------------------------------------- */ import { ReactElement, memo, useMemo } from 'react' import PropTypes from 'prop-types' - -import { WarningCircledOutline as WarningIcon } from 'iconoir-react' import { Typography } from '@mui/material' - -import { useViews } from 'client/features/Auth' -import MultipleTags from 'client/components/MultipleTags' import Timer from 'client/components/Timer' import { StatusCircle } from 'client/components/Status' import { rowStyles } from 'client/components/Tables/styles' -import { - timeFromMilliseconds, - getUniqueLabels, - getColorFromString, -} from 'client/models/Helper' +import { timeFromMilliseconds } from 'client/models/Helper' import { getState } from 'client/models/Service' -import { T, Service, ACTIONS, RESOURCE_NAMES } from 'client/constants' +import { T, Service } from 'client/constants' const ServiceCard = memo( /** * @param {object} props - Props * @param {Service} props.service - Service resource * @param {object} props.rootProps - Props to root component - * @param {function(string):Promise} [props.onDeleteLabel] - Callback to delete label * @param {ReactElement} [props.actions] - Actions * @returns {ReactElement} - Card */ - ({ service, rootProps, actions, onDeleteLabel }) => { + ({ service, rootProps, actions }) => { const classes = rowStyles() - const { [RESOURCE_NAMES.SERVICE]: serviceView } = useViews() - - const enableEditLabels = - serviceView?.actions?.[ACTIONS.EDIT_LABELS] === true && !!onDeleteLabel const { ID, NAME, - TEMPLATE: { BODY: { description, labels, start_time: startTime } = {} }, + TEMPLATE: { BODY: { description, start_time: startTime } = {} }, } = service const { color: stateColor, name: stateName } = getState(service) const time = useMemo(() => timeFromMilliseconds(+startTime), [startTime]) - const uniqueLabels = useMemo( - () => - getUniqueLabels(labels).map((label) => ({ - text: label, - stateColor: getColorFromString(label), - onDelete: enableEditLabels && onDeleteLabel, - })), - [labels, enableEditLabels, onDeleteLabel] - ) - return (
@@ -76,10 +52,6 @@ const ServiceCard = memo( {NAME} - - - -
{`#${ID}`} diff --git a/src/fireedge/src/client/components/Cards/ServiceTemplateCard.js b/src/fireedge/src/client/components/Cards/ServiceTemplateCard.js index f02eca3fb6..17a64cc5f5 100644 --- a/src/fireedge/src/client/components/Cards/ServiceTemplateCard.js +++ b/src/fireedge/src/client/components/Cards/ServiceTemplateCard.js @@ -54,9 +54,9 @@ const ServiceTemplateCard = memo( TEMPLATE: { BODY: { description, - labels, - networks, - roles, + labels = {}, + networks = {}, + roles = {}, registration_time: regTime, } = {}, }, diff --git a/src/fireedge/src/client/components/FormControl/SelectController.js b/src/fireedge/src/client/components/FormControl/SelectController.js index 919c2c41b1..9b7fed955f 100644 --- a/src/fireedge/src/client/components/FormControl/SelectController.js +++ b/src/fireedge/src/client/components/FormControl/SelectController.js @@ -35,6 +35,7 @@ const SelectController = memo( tooltip, watcher, dependencies, + defaultValueProp, fieldProps = {}, readOnly = false, onConditionChange, @@ -45,8 +46,18 @@ const SelectController = memo( defaultValue: Array.isArray(dependencies) ? [] : undefined, }) - const firstValue = values?.[0]?.value ?? '' - const defaultValue = multiple ? [firstValue] : firstValue + const firstValue = defaultValueProp + ? values?.find((val) => val.value === defaultValueProp) + : values?.[0]?.value ?? '' + + const defaultValue = + defaultValueProp !== undefined + ? multiple + ? [defaultValueProp] + : defaultValueProp + : multiple + ? [firstValue] + : firstValue const { field: { ref, value: optionSelected, onChange, ...inputProps }, @@ -55,7 +66,8 @@ const SelectController = memo( const needShrink = useMemo( () => - multiple || values.find((o) => o.value === optionSelected)?.text !== '', + multiple || + values?.find((o) => o.value === optionSelected)?.text !== '', [optionSelected] ) @@ -167,6 +179,7 @@ SelectController.propTypes = { PropTypes.string, PropTypes.arrayOf(PropTypes.string), ]), + defaultValueProp: PropTypes.string, fieldProps: PropTypes.object, readOnly: PropTypes.bool, onConditionChange: PropTypes.func, diff --git a/src/fireedge/src/client/components/FormControl/TableController.js b/src/fireedge/src/client/components/FormControl/TableController.js index 3384c85739..59b78be5a0 100644 --- a/src/fireedge/src/client/components/FormControl/TableController.js +++ b/src/fireedge/src/client/components/FormControl/TableController.js @@ -68,8 +68,13 @@ const TableController = memo( } useEffect(() => { - onChange(singleSelect ? undefined : preserveState ? value : []) - setInitialRows(preserveState ? initialRows : {}) + if (preserveState) { + onChange(value) + setInitialRows(initialRows) + } else { + onChange(singleSelect ? undefined : []) + setInitialRows({}) + } }, [Table]) const handleSelectedRowsChange = useCallback( diff --git a/src/fireedge/src/client/components/Forms/FormWithSchema.js b/src/fireedge/src/client/components/Forms/FormWithSchema.js index 91715f73c2..5da5837d4e 100644 --- a/src/fireedge/src/client/components/Forms/FormWithSchema.js +++ b/src/fireedge/src/client/components/Forms/FormWithSchema.js @@ -218,10 +218,15 @@ const FieldComponent = memo( ) 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?.values - ? `${name}-${JSON.stringify(fieldProps.values)}` + fieldProps + ? `${name}-${JSON.stringify( + fieldProps?.type ?? + fieldProps?.values ?? + Object.values(fieldProps) + )}` : undefined, [fieldProps] ) diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/index.js new file mode 100644 index 0000000000..dabe03a527 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/index.js @@ -0,0 +1,64 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 { 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 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/schema' +import { FormWithSchema, Legend } from 'client/components/Forms' +import { Stack, Divider, FormControl } from '@mui/material' + +import { T } from 'client/constants' + +export const SECTION_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 ( + + + + + + + + + + ) +} + +export default memo(AdvancedParamsSection) diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/schema.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/schema.js new file mode 100644 index 0000000000..0cd2e5d5e2 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/advancedParams/schema.js @@ -0,0 +1,87 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 } from 'yup' +import { INPUT_TYPES, T } from 'client/constants' +import { getObjectSchemaFromFields, arrayToOptions } from 'client/utils' + +// Define the CA types +const STRATEGY_TYPES = { + straight: 'Straight', + none: 'None', +} + +const VM_SHUTDOWN_TYPES = { + terminate: 'Terminate', + terminateHard: 'Terminate Hard', +} + +const STRATEGY_TYPE = { + label: 'Strategy', + name: 'ADVANCED.DEPLOYMENT', + type: INPUT_TYPES.SELECT, + values: arrayToOptions(Object.keys(STRATEGY_TYPES), { + addEmpty: false, + getText: (key) => STRATEGY_TYPES[key], + getValue: (key) => key, + }), + + validation: string() + .trim() + .required() + .oneOf(Object.keys(STRATEGY_TYPES)) + .default(() => Object.keys(STRATEGY_TYPES)[0]), + grid: { sm: 2, md: 2 }, +} + +const VM_SHUTDOWN_TYPE = { + label: 'VM Shutdown', + name: 'ADVANCED.VMSHUTDOWN', + type: INPUT_TYPES.SELECT, + values: arrayToOptions(Object.values(VM_SHUTDOWN_TYPES), { addEmpty: false }), + validation: string() + .trim() + .required() + .oneOf(Object.values(VM_SHUTDOWN_TYPES)) + .default(() => Object.values(VM_SHUTDOWN_TYPES)[0]), + grid: { sm: 2, md: 2 }, +} + +const WAIT_VMS = { + label: T.WaitVmsReport, + name: 'ADVANCED.READY_STATUS_GATE', + type: INPUT_TYPES.CHECKBOX, + validation: boolean().default(() => false), + grid: { sd: 4, md: 4 }, +} + +const AUTO_DELETE = { + label: T.ServiceAutoDelete, + name: 'ADVANCED.AUTOMATIC_DELETION', + type: INPUT_TYPES.CHECKBOX, + validation: boolean().default(() => false), + grid: { sd: 4, md: 4 }, +} + +export const ADVANCED_PARAMS_FIELDS = [ + STRATEGY_TYPE, + VM_SHUTDOWN_TYPE, + WAIT_VMS, + AUTO_DELETE, +] + +export const ADVANCED_PARAMS_SCHEMA = getObjectSchemaFromFields( + ADVANCED_PARAMS_FIELDS +) diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes/index.js new file mode 100644 index 0000000000..925794c87a --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes/index.js @@ -0,0 +1,148 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes/schema' +import { FormWithSchema, Legend } from 'client/components/Forms' +import { Translate } from 'client/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 'client/components/FormControl/SubmitButton' +import { T } from 'client/constants' + +export const SECTION_ID = 'CUSTOM_ATTRIBUTES' + +/** + * @param {object} root0 - Params + * @param {string} root0.stepId - Step identifier + * @returns {Component} - Custom Attributes sub-step + */ +const CustomAttributesSection = ({ stepId }) => { + const fields = CUSTOM_ATTRIBUTES_FIELDS + + const { + fields: customattributes, + append, + remove, + } = useFieldArray({ + name: useMemo( + () => [stepId, SECTION_ID].filter(Boolean).join('.'), + [stepId] + ), + }) + + const methods = useForm({ + defaultValues: CUSTOM_ATTRIBUTES_SCHEMA.default(), + resolver: yupResolver(CUSTOM_ATTRIBUTES_SCHEMA), + }) + + const onSubmit = (newcustomAttribute) => { + append(newcustomAttribute) + methods.reset() + } + + if (fields.length === 0) { + return null + } + + return ( + + + + + + + + + + + {customattributes?.map( + ({ id, name, defaultvalue, description, mandatory, type }, index) => { + const secondaryFields = [ + description && `Description: ${description}`, + defaultvalue && `Default value: ${defaultvalue}`, + type && `Type: ${type}`, + mandatory && `Mandatory: ${mandatory ? 'Yes' : 'No'}`, + ].filter(Boolean) + + return ( + + remove(index)} + icon={} + /> + } + sx={{ '&:hover': { bgcolor: 'action.hover' } }} + > + + + + ) + } + )} + + + ) +} + +CustomAttributesSection.propTypes = { + stepId: PropTypes.string, +} + +export default CustomAttributesSection diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes/schema.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes/schema.js new file mode 100644 index 0000000000..7261364116 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes/schema.js @@ -0,0 +1,181 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 } from 'client/utils' +import { INPUT_TYPES, T } from 'client/constants' + +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: 'Type', + type: INPUT_TYPES.SELECT, + values: arrayToOptions(Object.values(CA_TYPES), { addEmpty: false }), + 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: 'Name', + type: INPUT_TYPES.TEXT, + validation: string() + .trim() + .notRequired() + .default(() => undefined), + grid: { sm: 2.5, md: 2.5 }, +} + +const DESCRIPTION = { + name: 'description', + label: '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: 'Default value', + dependOf: CA_TYPE.name, + + htmlType: (type) => type === CA_TYPES.password && INPUT_TYPES.HIDDEN, + + type: getTypeProp, + + fieldProps: getFieldProps, + + validation: string(), + + grid: { sm: 2.5, md: 2.5 }, +} + +const DEFAULT_VALUE_RANGE_MIN = { + name: 'defaultvaluerangemin', + label: 'Min range', + 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: 'Max range', + 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: 'Comma separated list of options', + 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, + MANDATORY, + DEFAULT_VALUE_RANGE_MIN, + DEFAULT_VALUE_RANGE_MAX, + DEFAULT_VALUE_LIST, +] + +export const CUSTOM_ATTRIBUTES_SCHEMA = getObjectSchemaFromFields( + CUSTOM_ATTRIBUTES_FIELDS +) diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/index.js new file mode 100644 index 0000000000..5c6c982bd7 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/index.js @@ -0,0 +1,79 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 { SCHEMA } from './schema' +import { Stack, FormControl, Divider } from '@mui/material' +import NetworkingSection from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking' + +import CustomAttributesSection from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/customAttributes' + +import ScheduleActionsSection from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/scheduledActions' + +import { FormWithSchema } from 'client/components/Forms' + +import { T } from 'client/constants' +import { ADVANCED_PARAMS_FIELDS } from './advancedParams/schema' + +export const STEP_ID = 'extra' + +const Content = () => + useMemo( + () => ( + + + + + + + + + + ), + [STEP_ID] + ) + +Content.propTypes = { + data: PropTypes.any, + setFormData: PropTypes.func, + hypervisor: PropTypes.string, + control: PropTypes.object, +} + +/** + *@returns {Component} - Extra step + */ +const Extra = () => ({ + id: STEP_ID, + label: T.Extra, + resolver: SCHEMA, + optionsValidate: { abortEarly: false }, + content: Content, +}) + +export default Extra diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/index.js new file mode 100644 index 0000000000..95765a29ab --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/index.js @@ -0,0 +1,151 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 { useFieldArray, useForm, FormProvider } from 'react-hook-form' +import { yupResolver } from '@hookform/resolvers/yup' +import { + NETWORK_INPUT_FIELDS, + NETWORK_INPUT_SCHEMA, +} from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/schema' +import { FormWithSchema, Legend } from 'client/components/Forms' +import { Translate } from 'client/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 'client/components/FormControl/SubmitButton' +import { T } from 'client/constants' + +export const SECTION_ID = 'NETWORKING' + +/** + * @param {object} root0 - Params + * @param {string} root0.stepId - Step identifier + * @returns {Component} - Networking sub-section + */ +const NetworkingSection = ({ stepId }) => { + const fields = useMemo(() => NETWORK_INPUT_FIELDS) + + const { + fields: networks, + append, + remove, + } = useFieldArray({ + name: useMemo( + () => [stepId, SECTION_ID].filter(Boolean).join('.'), + [stepId] + ), + }) + + const methods = useForm({ + defaultValues: NETWORK_INPUT_SCHEMA.default(), + resolver: yupResolver(NETWORK_INPUT_SCHEMA), + }) + + const onSubmit = async (newNetwork) => { + const isValid = await methods.trigger() + if (isValid) { + append(newNetwork) + methods.reset() + } + } + + if (fields.length === 0) { + return null + } + + return ( + + + + + + + + + + + {networks?.map( + ({ name, description, netextra, id, network, type }, index) => { + const secondaryFields = [ + description && `Description: ${description}`, + type && `Type: ${type}`, + network && `Network: ${network}`, + netextra && `Extra: ${netextra}`, + ].filter(Boolean) + + return ( + + remove(index)} + icon={} + /> + } + sx={{ '&:hover': { bgcolor: 'action.hover' } }} + > + + + + ) + } + )} + + + ) +} + +NetworkingSection.propTypes = { + stepId: PropTypes.string, +} + +export default NetworkingSection diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/schema.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/schema.js new file mode 100644 index 0000000000..7edfa80560 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/schema.js @@ -0,0 +1,107 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 } from 'yup' +import { getObjectSchemaFromFields, arrayToOptions } from 'client/utils' +import { INPUT_TYPES } from 'client/constants' +import { useGetVNetworksQuery } from 'client/features/OneApi/network' + +// Define the network types +export const NETWORK_TYPES = { + create: 'Create', + reserve: 'Reserve', + existing: 'Existing', +} + +// Network Type Field +const NETWORK_TYPE = { + name: 'type', + label: 'Type', + type: INPUT_TYPES.SELECT, + values: arrayToOptions(Object.values(NETWORK_TYPES), { addEmpty: false }), + validation: string() + .trim() + .oneOf([...Object.keys(NETWORK_TYPES), ...Object.values(NETWORK_TYPES)]) + .default(() => Object.values(NETWORK_TYPES)[0]), + grid: { sm: 1.5, md: 1.5 }, +} + +// Network Name Field +const NAME = { + name: 'name', + label: 'Name', + type: INPUT_TYPES.TEXT, + validation: string().trim().required(), + grid: { sm: 2.5, md: 2.5 }, +} + +// Network Description Field +const DESCRIPTION = { + name: 'description', + label: 'Description', + type: INPUT_TYPES.TEXT, + validation: string() + .trim() + .notRequired() + .default(() => undefined), + grid: { sm: 2.5, md: 2.5 }, +} + +// Network Selection Field (for 'reserve' or 'existing') +const NETWORK_SELECTION = { + name: 'network', + label: 'Network', + type: INPUT_TYPES.SELECT, + values: () => { + const { data: vnets = [] } = useGetVNetworksQuery() + const networks = vnets + .map((vnet) => ({ NAME: vnet?.NAME, ID: vnet?.ID })) + .flat() + + return arrayToOptions(networks, { + getText: (network = '') => network?.NAME, + getValue: (network) => network?.ID, + }) + }, + 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: { sm: 2.5, md: 2.5 }, +} + +// List of Network Input Fields +export const NETWORK_INPUT_FIELDS = [ + NETWORK_TYPE, + NAME, + DESCRIPTION, + NETWORK_SELECTION, + NETEXTRA, +] + +export const NETWORK_INPUT_SCHEMA = + getObjectSchemaFromFields(NETWORK_INPUT_FIELDS) diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/scheduledActions/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/scheduledActions/index.js new file mode 100644 index 0000000000..10be49779a --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/scheduledActions/index.js @@ -0,0 +1,147 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 { Box, Stack, Divider } from '@mui/material' +import { useFieldArray } from 'react-hook-form' +import { array, object } from 'yup' + +import { ScheduleActionCard } from 'client/components/Cards' +import { + CreateSchedButton, + CharterButton, + UpdateSchedButton, + DeleteSchedButton, +} from 'client/components/Buttons/ScheduleAction' + +import PropTypes from 'prop-types' +import { T } from 'client/constants' +import { Legend } from 'client/components/Forms' + +import { mapNameByIndex } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema' +import { STEP_ID as EXTRA_ID } from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra' +import { Component } from 'react' + +export const TAB_ID = 'SCHED_ACTION' + +const mapNameFunction = mapNameByIndex('SCHED_ACTION') + +export const SCHED_ACTION_SCHEMA = object({ + SCHED_ACTION: array() + .ensure() + .transform((actions) => actions.map(mapNameByIndex('SCHED_ACTION'))), +}) + +/** + * @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, + remove, + update, + append, + } = useFieldArray({ + name: `${EXTRA_ID}.${TAB_ID}`, + keyName: 'ID', + }) + + const handleCreateAction = (action) => { + append(mapNameFunction(action, scheduleActions.length)) + } + + const handleCreateCharter = (actions) => { + const mappedActions = actions?.map((action, idx) => + mapNameFunction(action, scheduleActions.length + idx) + ) + + append(mappedActions) + } + + const handleUpdate = (action, index) => { + update(index, mapNameFunction(action, index)) + } + + const handleRemove = (index) => { + remove(index) + } + + return ( + <> + + + + + + + + + {scheduleActions?.map((schedule, index) => { + const { ID, NAME } = schedule + const fakeValues = { ...schedule, ID: index } + + return ( + + handleUpdate(newAction, index)} + oneConfig={oneConfig} + adminGroup={adminGroup} + /> + handleRemove(index)} + oneConfig={oneConfig} + adminGroup={adminGroup} + /> + + } + /> + ) + })} + + + + + ) +} + +ScheduleActionsSection.propTypes = { + data: PropTypes.any, + setFormData: PropTypes.func, + oneConfig: PropTypes.object, + adminGroup: PropTypes.bool, +} + +export default ScheduleActionsSection diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/schema.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/schema.js new file mode 100644 index 0000000000..eb15f6ca3e --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/schema.js @@ -0,0 +1,31 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 } 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' + +export const SCHEMA = object() + .shape({ + NETWORKING: array().of(NETWORK_INPUT_SCHEMA), + }) + .shape({ + CUSTOM_ATTRIBUTES: array().of(CUSTOM_ATTRIBUTES_SCHEMA), + }) + .concat(ADVANCED_PARAMS_SCHEMA) + .concat(SCHED_ACTION_SCHEMA) diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/General/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/General/index.js new file mode 100644 index 0000000000..feb5fadd4a --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/General/index.js @@ -0,0 +1,59 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 'client/components/Forms/FormWithSchema' +import { T } from 'client/constants' +import { SCHEMA, NAME_FIELD, DESCRIPTION_FIELD } from './schema' + +export const STEP_ID = 'general' + +const Content = ({ isUpdate }) => ( + +) + +/** + * 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 }), + } +} + +General.propTypes = { + data: PropTypes.object, + setFormData: PropTypes.func, +} + +Content.propTypes = { isUpdate: PropTypes.bool } + +export default General diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/General/schema.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/General/schema.js new file mode 100644 index 0000000000..5347018737 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/General/schema.js @@ -0,0 +1,53 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 'client/constants' +import { Field, getObjectSchemaFromFields } from 'client/utils' +import { string } from 'yup' + +/** @type {Field} Name field */ +const NAME_FIELD = { + 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') + .default(() => undefined), + grid: { md: 12 }, +} + +/** @type {Field} Description field */ +const DESCRIPTION_FIELD = { + 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', + (value) => isNaN(value) || value.trim() === '' + ) + .default(() => undefined), + grid: { md: 12 }, +} + +const SCHEMA = getObjectSchemaFromFields([NAME_FIELD, DESCRIPTION_FIELD]) + +export { SCHEMA, NAME_FIELD, DESCRIPTION_FIELD } diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters/index.js new file mode 100644 index 0000000000..b0322d0a35 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters/index.js @@ -0,0 +1,111 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 'client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters/schema' +import { FormWithSchema } from 'client/components/Forms' +import { + Accordion, + AccordionSummary, + AccordionDetails, + FormControl, + Box, + Typography, + useTheme, +} from '@mui/material' +import { T } from 'client/constants' + +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 ( + + + + {T.AdvancedParams} + + + + + + + + + + + ) +} + +AdvancedParametersSection.propTypes = { + selectedRoleIndex: PropTypes.number, + stepId: PropTypes.string, + roleConfigs: PropTypes.object, + onChange: PropTypes.func, +} + +export default AdvancedParametersSection diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters/schema.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters/schema.js new file mode 100644 index 0000000000..0678dc8cb4 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters/schema.js @@ -0,0 +1,53 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 } from 'yup' +import { getValidationFromFields, arrayToOptions } from 'client/utils' +import { INPUT_TYPES } from 'client/constants' +import { SECTION_ID as ADVANCED_SECTION_ID } from 'client/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 SHUTDOWN_TYPE = { + name: `${ADVANCED_SECTION_ID}.SHUTDOWNTYPE`, + label: 'VM Shutdown action', + type: INPUT_TYPES.SELECT, + values: arrayToOptions(Object.keys(SHUTDOWN_TYPES), { + addEmpty: false, + getText: (key) => SHUTDOWN_TYPES[key], + getValue: (key) => SHUTDOWN_ENUMS_ONEFLOW[key], + }), + validation: string() + .trim() + .notRequired() + .oneOf(Object.values(SHUTDOWN_TYPES)) + .default(() => Object.values(SHUTDOWN_TYPES)[0]), + grid: { xs: 12, sm: 12, md: 12 }, +} + +export const ADVANCED_PARAMS_FIELDS = [SHUTDOWN_TYPE] + +export const ADVANCED_PARAMS_SCHEMA = object( + getValidationFromFields(ADVANCED_PARAMS_FIELDS) +) diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies/index.js new file mode 100644 index 0000000000..dddb281213 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies/index.js @@ -0,0 +1,203 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 'client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies/schema' +import { FormWithSchema } from 'client/components/Forms' +import { Translate } from 'client/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 'client/components/FormControl/SubmitButton' +import { T } from 'client/constants' + +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 ( + + + + {T.ElasticityPolicies} + + + + + + + + + + + {currentPolicies.map( + ( + { + TYPE, + ADJUST, + MIN, + COOLDOWN, + PERIOD_NUMBER, + PERIOD, + EXPRESSION, + }, + index + ) => { + const secondaryFields = [ + `Expression: ${EXPRESSION}`, + `Adjust: ${ADJUST}`, + `Cooldown: ${COOLDOWN}`, + `Period: ${PERIOD}`, + `#: ${PERIOD_NUMBER}`, + ] + if (MIN !== undefined && TYPE === 'PERCENTAGE_CHANGE') { + secondaryFields.push(`Min: ${MIN}`) + } + + return ( + + remove(index)} + icon={} + /> + } + sx={{ '&:hover': { bgcolor: 'action.hover' } }} + > + + + + ) + } + )} + + + + + + ) +} + +ElasticityPoliciesSection.propTypes = { + stepId: PropTypes.string, + selectedRoleIndex: PropTypes.number, +} + +export default ElasticityPoliciesSection diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies/schema.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies/schema.js new file mode 100644 index 0000000000..cef9348a0f --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies/schema.js @@ -0,0 +1,145 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 'client/utils' +import { INPUT_TYPES } from 'client/constants' + +// 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: 'Type', + type: INPUT_TYPES.SELECT, + 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: '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: '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: 'Expression', + type: INPUT_TYPES.TEXT, + cy: 'roleconfig-elasticitypolicies', + validation: string().trim().required(), + grid: (type) => ({ + xs: 12, + ...(type !== Object.keys(ELASTICITY_TYPES)[2] + ? { sm: 12, md: 12 } + : { sm: 6, md: 6 }), + }), + }, + { + name: getPath('PERIOD_NUMBER'), + label: '#', + type: INPUT_TYPES.TEXT, + cy: 'roleconfig-elasticitypolicies', + fieldProps: { + type: 'number', + }, + validation: number(), + grid: { xs: 12, sm: 6, md: 4 }, + }, + { + name: getPath('PERIOD'), + label: 'Period', + type: INPUT_TYPES.TEXT, + cy: 'roleconfig-elasticitypolicies', + fieldProps: { + type: 'number', + }, + validation: number(), + grid: { xs: 12, sm: 6, md: 4 }, + }, + { + name: getPath('COOLDOWN'), + label: 'Cooldown', + type: INPUT_TYPES.TEXT, + cy: 'roleconfig-elasticitypolicies', + fieldProps: { + type: 'number', + }, + validation: number(), + grid: { xs: 12, sm: 12, md: 4 }, + }, + ] +} + +/** + * Creates a Yup schema for elasticity policies based on a given path prefix. + * + * @param {string} pathPrefix - Path prefix for field names in the schema. + * @returns {object} - Yup schema object for elasticity policies. + */ +export const createElasticityPoliciesSchema = (pathPrefix) => { + const fields = createElasticityPolicyFields(pathPrefix) + + return object(getValidationFromFields(fields)) +} diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms/index.js new file mode 100644 index 0000000000..c37f9bfbc5 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms/index.js @@ -0,0 +1,79 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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, useEffect } from 'react' +import { Box, FormControl } from '@mui/material' +import { createMinMaxVmsFields } from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms/schema' +import { FormWithSchema } from 'client/components/Forms' +import { useFieldArray, useFormContext } from 'react-hook-form' +import { STEP_ID as ROLE_DEFINITION_ID } from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles' + +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, setValue, getValues } = useFormContext() + const cardinality = useMemo( + () => + getValues(ROLE_DEFINITION_ID)?.[selectedRoleIndex]?.CARDINALITY ?? + undefined, + [selectedRoleIndex] + ) + + const fields = createMinMaxVmsFields( + `${stepId}.${SECTION_ID}.${selectedRoleIndex}`, + cardinality + ) + + useFieldArray({ + name: useMemo(() => `${stepId}.${SECTION_ID}`, [stepId, selectedRoleIndex]), + control: control, + }) + + if (fields.length === 0) { + return null + } + + // Set default values + useEffect(() => { + fields.forEach((field) => { + const defaultValue = field.validation.default() + setValue(field.name, defaultValue || 0) + }) + }, [fields]) + + return ( + + + + + + ) +} + +MinMaxVms.propTypes = { + stepId: PropTypes.string, + selectedRoleIndex: PropTypes.number, +} + +export default MinMaxVms diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms/schema.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms/schema.js new file mode 100644 index 0000000000..78bb954d45 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms/schema.js @@ -0,0 +1,96 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 'client/utils' +import { INPUT_TYPES } from 'client/constants' + +const MAX_VALUE = 999999 + +/** + * Creates fields for minmax vms schema based on a path prefix. + * + * @param {string} pathPrefix - Path prefix for field names. + * @param { number } cardinality - Number of VMs defined in Role Def. step. + * @returns {object[]} - Array of field definitions for minmax vms. + */ +export const createMinMaxVmsFields = (pathPrefix, cardinality) => { + const getPath = (fieldName) => + pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName + + return [ + { + name: getPath('min_vms'), + label: 'Min VMs', + type: INPUT_TYPES.TEXT, + cy: 'elasticity', + validation: number() + .integer('Min VMs must be an integer') + .min( + cardinality, + `Min VMs cannot be less than defined cardinality: ${cardinality}` + ) + .default(() => cardinality), + fieldProps: { + type: 'number', + }, + grid: { sm: 4, md: 4 }, + }, + { + name: getPath('max_vms'), + label: 'Max VMs', + type: INPUT_TYPES.TEXT, + cy: 'elasticity', + validation: number() + .integer('Max VMs must be an integer') + .min(cardinality, `Max VMs cannot be less than ${cardinality}`) + .max(MAX_VALUE, `Max VMs cannot exceed ${MAX_VALUE}`) + .default(() => cardinality), + fieldProps: { + type: 'number', + }, + grid: { sm: 4, md: 4 }, + }, + { + name: getPath('cooldown'), + label: '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(() => 0), + 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. + * @param { number } cardinality - Number of VMs defined in Role Def. step. + * @returns {object} - Yup schema object for minmax vms. + */ +export const createMinMaxVmsSchema = (pathPrefix, cardinality = 0) => { + const fields = createMinMaxVmsFields(pathPrefix, cardinality) + + return object(getValidationFromFields(fields)) +} diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies/index.js new file mode 100644 index 0000000000..3120135d0d --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies/index.js @@ -0,0 +1,193 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 'client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies/schema' +import { FormWithSchema } from 'client/components/Forms' +import { Translate } from 'client/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 'client/components/FormControl/SubmitButton' +import { T } from 'client/constants' + +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 ( + + + + {T.ScheduledPolicies} + + + + + + + + + + + + {currentPolicies.map( + ( + { TIMEFORMAT, SCHEDTYPE, ADJUST, MIN, TIMEEXPRESSION }, + index + ) => { + const secondaryFields = [ + `Time Expression: ${TIMEEXPRESSION}`, + `Adjust: ${ADJUST}`, + `Time Format: ${TIMEFORMAT}`, + ] + + if (MIN !== undefined) { + secondaryFields?.push(`Min: ${MIN}`) + } + + return ( + + remove(index)} + icon={} + /> + } + sx={{ '&:hover': { bgcolor: 'action.hover' } }} + > + + + + ) + } + )} + + + + + + ) +} + +ScheduledPoliciesSection.propTypes = { + stepId: PropTypes.string, + selectedRoleIndex: PropTypes.number, +} + +export default ScheduledPoliciesSection diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies/schema.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies/schema.js new file mode 100644 index 0000000000..a0e8611b9f --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies/schema.js @@ -0,0 +1,135 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 'client/utils' +import { INPUT_TYPES } from 'client/constants' + +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: 'Type', + type: INPUT_TYPES.SELECT, + cy: 'roleconfig-scheduledpolicies', + values: arrayToOptions(Object.keys(SCHED_TYPES), { + addEmpty: false, + getText: (key) => SCHED_TYPES?.[key], + getValue: (key) => key, + }), + validation: string() + .trim() + .required() + .default(() => Object.keys(SCHED_TYPES)[0]), + grid: { xs: 12, sm: 6, md: 3.3 }, + }, + { + name: getPath('ADJUST'), + label: 'Adjust', + type: INPUT_TYPES.TEXT, + cy: 'roleconfig-scheduledpolicies', + validation: string() + .trim() + .required() + .default(() => ''), + grid: { xs: 12, sm: 6, md: 3.1 }, + }, + { + name: getPath('MIN'), + label: '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: 'Time Format', + type: INPUT_TYPES.SELECT, + 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: 'Time Expression', + type: INPUT_TYPES.TEXT, + cy: 'roleconfig-scheduledpolicies', + validation: string() + .trim() + .when(getPath('TIMEFORMAT'), { + is: 'Start time', + then: string().matches( + timeExpressionRegex, + 'Time Expression must be in the format YYYY-MM-DD hh:mm:ss or YYYY-MM-DDThh:mm:ssZ' + ), + otherwise: string().matches( + cronExpressionRegex, + 'Time Expression must be a valid CRON expression' + ), + }) + .required(), + grid: { xs: 12, sm: 12, md: 12 }, + }, + ] +} + +/** + * Creates a Yup schema for scheduled policies based on a given path prefix. + * + * @param {string} pathPrefix - Path prefix for field names in the schema. + * @returns {object} - Yup schema object for scheduled policies. + */ +export const createScheduledPoliciesSchema = (pathPrefix) => { + const fields = createScheduledPolicyFields(pathPrefix) + + return object(getValidationFromFields(fields)) +} diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/index.js new file mode 100644 index 0000000000..a144ded4a2 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/index.js @@ -0,0 +1,305 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 'client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/schema' +import RoleColumn from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesColumn' +import RoleSummary from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/roleSummary' +import RoleNetwork from './roleNetwork' +import { STEP_ID as ROLE_DEFINITION_ID } from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles' +import ElasticityPoliciesSection from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ElasticityPolicies' +import ScheduledPoliciesSection from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/ScheduledPolicies' +import AdvancedParametersSection from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/AdvancedParameters' +import VmTemplatesPanel from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/vmTemplatesPanel' +import MinMaxSection from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/MinMaxVms' +import { Translate } from 'client/components/HOC' +import FormWithSchema from 'client/components/Forms/FormWithSchema' +import { Legend } from 'client/components/Forms' +import { yupResolver } from '@hookform/resolvers/yup' +import { INPUT_TYPES, T } from 'client/constants' +import { AddCircledOutline } from 'iconoir-react' +import { Field, getObjectSchemaFromFields } from 'client/utils' +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 + * @returns {Component} - Role configuration component + */ +export const Content = ({ + standaloneModal = false, + standaloneModalCallback = () => {}, +}) => { + const [standaloneRole, setStandaloneRole] = useState([ + { SELECTED_VM_TEMPLATE_ID: [] }, + ]) + + const HANDLE_VM_SELECT_STANDALONE_ROLE = (updatedRole) => { + setStandaloneRole(updatedRole) + } + + const formMethods = standaloneModal + ? useForm({ + defaultValues: SCHEMA.concat(STANDALONE_SCHEMA).default(), + resolver: yupResolver(STANDALONE_SCHEMA), + mode: 'onChange', + }) + : useFormContext() + const { getValues, setValue } = formMethods + + /** + * + */ + const handleAddRoleClick = async () => { + const role = getValues(STEP_ID) + + const formatRole = { + name: role?.name, + cardinality: role?.cardinality, + vm_template: standaloneRole?.SELECTED_VM_TEMPLATE_ID?.[0], + ...(role?.ADVANCEDPARAMS?.SHUTDOWNTYPE && { + shutdown_type: role.ADVANCEDPARAMS.SHUTDOWNTYPE, + }), + min_vms: +role?.MINMAXVMS?.[0]?.min_vms, + max_vms: +role?.MINMAXVMS?.[0]?.max_vms, + cooldown: role?.MINMAXVMS?.[0]?.cooldown, + ...(role?.ELASTICITYPOLICIES && { + elasticity_policies: role?.ELASTICITYPOLICIES?.[0], + }), + + ...(role?.SCHEDULEDPOLICIES && { + scheduled_policies: role?.SCHEDULEDPOLICIES?.[0], + }), + } + standaloneModalCallback({ role: formatRole }) + } + + const definedConfigs = getValues(`${STEP_ID}.ROLES`) + const roleConfigs = useRef(definedConfigs ?? []) + + /** + * + */ + const syncFormState = () => { + setValue(`${STEP_ID}.ROLES`, roleConfigs.current) + } + + const [selectedRoleIndex, setSelectedRoleIndex] = useState(0) + const roles = getValues(ROLE_DEFINITION_ID) + + const handleConfigChange = (operationType, config, shouldReset = false) => { + const configKey = Object.keys(config)[0] + const configValue = Object.values(config)[0] + + _.defaultsDeep(roleConfigs.current, { + [selectedRoleIndex]: { [configKey]: [] }, + }) + + switch (operationType) { + case 'add': + _.get(roleConfigs.current, [selectedRoleIndex, configKey]).push( + configValue + ) + break + case 'remove': + _.remove( + _.get(roleConfigs.current, [selectedRoleIndex, configKey]), + (_v, index) => index === configValue + ) + break + case 'update': + _.set( + roleConfigs.current, + [selectedRoleIndex, configKey, 0], + configValue + ) + break + } + + syncFormState() + } + + const ComponentContent = ( + + {!standaloneModal && ( + + + + )} + + + {!standaloneModal && ( + + )} + + + {standaloneModal && ( + + + + )} + + {standaloneModal && ( + + + + )} + + + + + + + + + + {standaloneModal && ( + + + + )} + + + + {!standaloneModal && ( + + + + )} + + ) + + return standaloneModal ? ( + {ComponentContent} + ) : ( + ComponentContent + ) +} + +Content.propTypes = { + standaloneModal: PropTypes.Boolean, + standaloneModalCallback: PropTypes.func, +} + +/** + * Role definition configuration. + * + * @returns {object} Roles definition configuration step + */ +const RoleConfig = () => ({ + id: STEP_ID, + label: 'Role Configuration', + resolver: SCHEMA, + optionsValidate: { abortEarly: false }, + content: Content, +}) +RoleConfig.propTypes = { + data: PropTypes.array, + setFormData: PropTypes.func, +} + +export default RoleConfig diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/roleNetwork.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/roleNetwork.js new file mode 100644 index 0000000000..b2f10d01cf --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/roleNetwork.js @@ -0,0 +1,300 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 { useFieldArray, useFormContext } from 'react-hook-form' +import { useEffect, useState, useRef, useMemo, Component } from 'react' +import { DataGrid } from '@mui/x-data-grid' +import makeStyles from '@mui/styles/makeStyles' +import { Box, Checkbox, TextField, Autocomplete } from '@mui/material' +import { T } from 'client/constants' +import { Legend } from 'client/components/Forms' +import { STEP_ID as EXTRA_ID } from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra' +import _ from 'lodash' + +const useStyles = makeStyles({ + root: { + '& .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 }) => { + // 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 = useStyles() + const { getValues, setValue } = useFormContext() + + const { fields, update } = useFieldArray({ + name: fieldArrayLocation, + }) + + 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 handleSelectRow = (row, forceSelect = false) => { + const fieldArray = getValues(fieldArrayLocation) + const fieldArrayIndex = fieldArray?.findIndex((f) => f?.idx === row?.idx) + const rowToggle = forceSelect + ? true + : !fieldArray?.[fieldArrayIndex]?.rowSelected + + const updatedFieldArray = fieldArray?.map((f, index) => { + if (index === fieldArrayIndex) { + return { ...f, rowSelected: rowToggle, aliasSelected: false } + } + if (f.aliasIdx === fieldArrayIndex) { + return { ...f, aliasIdx: -1 } + } + + 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, + }) + } + + const columns = useMemo( + () => [ + { + field: 'select', + disableColumnMenu: true, + sortable: false, + headerName: 'Select', + width: 100, + renderCell: (params) => ( + handleSelectRow(params?.row)} + inputProps={{ + 'data-cy': `role-config-network-${params?.row?.idx}`, + }} + /> + ), + }, + { + field: 'network', + disableColumnMenu: true, + flex: 1, + headerName: 'Network', + width: 150, + }, + { + field: 'aliasToggle', + disableColumnMenu: true, + sortable: false, + headerName: 'NIC Alias', + width: 110, + renderCell: (params) => + params?.row?.rowSelected && ( + handleSelectAlias(params?.row)} + inputProps={{ + 'data-cy': `role-config-network-alias-${params?.row?.idx}`, + }} + /> + ), + }, + { + field: 'alias', + disableColumnMenu: true, + flex: 1, + headerName: 'Alias', + width: 200, + renderCell: (params) => + params?.row?.aliasSelected && ( + { + const fieldArray = getValues(fieldArrayLocation)?.[index] + + return ( + net?.name !== params?.row?.network && + fieldArray?.rowSelected && + fieldArray?.aliasIdx === -1 + ) + }) + + ?.map((net) => net?.name)} + renderOption={(props, option) => ( +
  • + {option} +
  • + )} + renderInput={(props) => } + onChange={(_event, value) => handleSetAlias(params?.row, value)} + value={ + getValues(fieldArrayLocation)?.[params?.row?.aliasIdx]?.name ?? + null + } + data-cy={`role-config-network-aliasname-${params?.row?.idx}`} + sx={{ width: '100%', height: '100%' }} + /> + ), + }, + ], + [networks, fieldArrayLocation] + ) + + return ( + + + + + ) +} + +RoleNetwork.propTypes = { + networks: PropTypes.array, + roleConfigs: PropTypes.object, + stepId: PropTypes.string, + selectedRoleIndex: PropTypes.number, +} + +export default RoleNetwork diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/schema.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/schema.js new file mode 100644 index 0000000000..1a38e0013f --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig/schema.js @@ -0,0 +1,39 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 } from 'yup' + +import { ADVANCED_PARAMS_SCHEMA } from './AdvancedParameters/schema' +import { createElasticityPoliciesSchema } from './ElasticityPolicies/schema' +import { createScheduledPoliciesSchema } from './ScheduledPolicies/schema' +import { createMinMaxVmsSchema } from './MinMaxVms/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(), + }) + .concat(ADVANCED_PARAMS_SCHEMA) diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/index.js new file mode 100644 index 0000000000..0dac001327 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/index.js @@ -0,0 +1,131 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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, useCallback, useEffect } from 'react' +import { useFormContext, useWatch } from 'react-hook-form' +import { Box, Grid } from '@mui/material' +import { SCHEMA } from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schema' +import RoleVmVmPanel from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesPanel' +import RoleColumn from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesColumn' +import VmTemplatesPanel from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/vmTemplatesPanel' +import RoleSummary from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/roleSummary' + +export const STEP_ID = 'roledefinition' + +const Content = () => { + const { getValues, setValue, reset } = useFormContext() + const [selectedRoleIndex, setSelectedRoleIndex] = useState(0) + + const defaultRole = [ + { NAME: '', SELECTED_VM_TEMPLATE_ID: [], CARDINALITY: 0 }, + ] + + const watchedRoles = useWatch({ + name: STEP_ID, + defaultValue: defaultRole, + }) + const definedRoles = getValues(STEP_ID) + + useEffect(() => { + if (definedRoles) { + reset({ [STEP_ID]: definedRoles ?? defaultRole }) + } + }, []) + + const [roles, setRoles] = useState(getValues(STEP_ID)) + + useEffect(() => { + setRoles(watchedRoles) + }, [definedRoles, watchedRoles]) + + const handleChangeRoles = (updatedRoles) => { + setValue(STEP_ID, updatedRoles) + } + + const handleRoleChange = useCallback( + (updatedRole) => { + const updatedRoles = [...roles] + + if (selectedRoleIndex >= 0 && selectedRoleIndex < roles.length) { + updatedRoles[selectedRoleIndex] = updatedRole + } else { + updatedRoles.push(updatedRole) + } + + handleChangeRoles(updatedRoles) + }, + [roles, selectedRoleIndex] + ) + + return ( + + + + + + + + + + + + + + + ) +} + +/** + * Role definition configuration. + * + * @returns {object} Roles definition configuration step + */ +const RoleDefinition = () => ({ + id: STEP_ID, + label: 'Role Definition', + resolver: SCHEMA, + optionsValidate: { abortEarly: false }, + content: Content, +}) +RoleDefinition.propTypes = { + data: PropTypes.array, + setFormData: PropTypes.func, +} + +export default RoleDefinition diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/roleSummary.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/roleSummary.js new file mode 100644 index 0000000000..334a1f3a07 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/roleSummary.js @@ -0,0 +1,203 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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, + CardActions, + Typography, + Divider, +} from '@mui/material' +import PropTypes from 'prop-types' +import { T } from 'client/constants' +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 }) => ( + + + + #{selectedRoleIndex + 1 ?? 0} Role Configuration + + + + Name: {role?.NAME || 'Enter a name for this role.'} + + + + {T.NumberOfVms}: {role?.CARDINALITY} + + + {role?.SELECTED_VM_TEMPLATE_ID ? ( + <> + + VM Template ID: {role?.SELECTED_VM_TEMPLATE_ID} + + + ) : ( + + Select a VM template. + + )} + + + Networks: {role?.NETWORKS || 'Select a network for this role.'} + + + + Role Elasticity + + + + Min VMs: + {role?.MINVMS || ' Minimum number of VMs for elasticity adjustments.'} + + + + Max VMs: + {role?.MAXVMS || ' Maximum number of VMs for elasticity adjustments.'} + + + + Cooldown: + {role?.COOLDOWN || + ' Duration after a scale operation in seconds. If it is not set, the default set in oneflow-server.conf will be used.'} + + + + Elasticity Policies + + + + Type: + {role?.ELASTICITYPOLICIES?.TYPE || ' Adjustment type'} + + + + Adjust: + {role?.ELASTICITYPOLICIES?.ADJUST || ' Positive or negative adjustment'} + + + + + VM Group Configuration: +
      +
    • Define roles and placement constraints.
    • +
    • Optimize performance and fault tolerance.
    • +
    • Manage multi-VM applications efficiently.
    • +
    +
    +
    +
    +) + +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.isRequired, +} + +export default RoleSummary diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesColumn.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesColumn.js new file mode 100644 index 0000000000..0fb6439520 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesColumn.js @@ -0,0 +1,168 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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' + +/** + * RoleColumn component for displaying and managing roles. + * + * @param {object} props - The properties passed to the component. + * @param {Array} props.roles - The list of roles. + * @param {Function} props.onChange - Callback function when roles are changed. + * @param {number|null} props.selectedRoleIndex - The index of the currently selected role. + * @param {Function} props.setSelectedRoleIndex - Function to set the selected role index. + * @param {boolean} props.disableModify - Disables the modification of roles. + * @returns {Component} - Role columns component + */ +const RoleColumn = ({ + roles, + onChange, + selectedRoleIndex, + setSelectedRoleIndex, + disableModify = false, +}) => { + const newRole = { NAME: '', SELECTED_VM_TEMPLATE_ID: [], CARDINALITY: 0 } + + const handleAddRole = useCallback(() => { + const updatedRoles = [...roles, newRole] + onChange(updatedRoles) + setSelectedRoleIndex(roles?.length) + }, [roles, onChange, selectedRoleIndex]) + + const handleRemoveRole = useCallback( + (indexToRemove) => { + const updatedRoles = [ + ...roles.slice(0, indexToRemove), + ...roles.slice(indexToRemove + 1), + ] + + onChange(updatedRoles) + if (selectedRoleIndex === indexToRemove) { + setSelectedRoleIndex(null) + } + }, + [roles, selectedRoleIndex] + ) + + return ( + + + {!disableModify && ( + + )} + + + {Array.isArray(roles) && + roles.length > 0 && + roles.map((role, index) => ( + setSelectedRoleIndex(index)} + key={index} + sx={{ + my: 0.5, + minHeight: '43.5px', + border: '1px solid', + borderColor: 'divider', + borderRadius: '4px', + overflowX: 'hidden', + bgcolor: + index === selectedRoleIndex + ? 'action.selected' + : 'inherit', + '&.Mui-selected, &.Mui-selected:hover': { + bgcolor: 'action.selected', + }, + '&:hover': { + bgcolor: 'action.hover', + }, + }} + data-cy={`role-column-${index}`} + > + {!disableModify && ( + { + event.stopPropagation() + handleRemoveRole(index) + }} + data-cy={`delete-role-${index}`} + sx={{ mr: 1.5 }} + > + + + )} +
    + {role?.NAME || 'New Role'} +
    +
    + ))} +
    +
    +
    +
    + ) +} + +RoleColumn.propTypes = { + roles: PropTypes.arrayOf(PropTypes.object).isRequired, + onChange: PropTypes.func.isRequired, + selectedRoleIndex: PropTypes.number, + setSelectedRoleIndex: PropTypes.func.isRequired, + disableModify: PropTypes.bool, +} + +RoleColumn.defaultProps = { + roles: [], +} + +export default RoleColumn diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesPanel.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesPanel.js new file mode 100644 index 0000000000..d3a898a139 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/rolesPanel.js @@ -0,0 +1,93 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 } from 'react' +import PropTypes from 'prop-types' +import { Box, TextField, Typography } from '@mui/material' +import { T } from 'client/constants' + +/** + * 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 handleInputChange = (event, passedName = '') => { + let value + let name = passedName + if (typeof event === 'object' && event?.target) { + const { name: eventName = '', value: eventValue = '' } = + event.target || {} + value = eventValue + name = passedName || eventName + } else { + value = event + } + onChange({ ...roles[selectedRoleIndex], [name]: value }) // updated role + } + + return ( + + + Role Details + + + + + + + + + + ) +} + +RoleVmVmPanel.propTypes = { + roles: PropTypes.arrayOf( + PropTypes.shape({ + NAME: PropTypes.string, + CARDINALITY: PropTypes.number, + }) + ), + onChange: PropTypes.func.isRequired, + selectedRoleIndex: PropTypes.number, +} + +export default RoleVmVmPanel diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schema.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schema.js new file mode 100644 index 0000000000..b41a5bb170 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/schema.js @@ -0,0 +1,93 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 'client/constants' +import { object, string, array, number } from 'yup' +import { Field } from 'client/utils' + +/** @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() + .positive('Number of VMs must be positive') + .default(() => 1), +} + +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, + 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 a positive number', (roles) => + roles.every((role) => role?.CARDINALITY >= 1) + ) + .test( + 'valid-characters', + 'Role names can only contain letters and numbers', + (roles) => + roles.every((role) => role.NAME && /^[a-zA-Z0-9]+$/.test(role.NAME)) + ) + .test( + 'has-unique-name', + 'All roles must have unique names', + (roles) => new Set(roles.map((role) => role.NAME)).size === roles.length + ) + +/** + * @returns {Field[]} Fields + */ +export const FIELDS = [ROLE_NAME_FIELD, ROLE_SCHEMA] diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/vmTemplatesPanel.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/vmTemplatesPanel.js new file mode 100644 index 0000000000..26ce22158d --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles/vmTemplatesPanel.js @@ -0,0 +1,177 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 { useLazyGetTemplatesQuery } from 'client/features/OneApi/vmTemplate' +import { useGeneralApi } from 'client/features/General' +import { DateTime } from 'luxon' + +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. + * @returns {Component} The VmTemplatesPanel component. + */ +const VmTemplatesPanel = ({ roles, selectedRoleIndex, onChange }) => { + const theme = useTheme() + const { enqueueError } = useGeneralApi() + const [vmTemplates, setVmTemplates] = useState([]) + const [fetch, { data, error }] = useLazyGetTemplatesQuery() + const templateID = roles?.[selectedRoleIndex]?.SELECTED_VM_TEMPLATE_ID ?? [] + + useEffect(() => { + fetch() + }, [fetch]) + + useEffect(() => { + if (error) { + enqueueError( + `Error fetching VM templates data: ${error?.message ?? error}` + ) + } + }, [error, enqueueError]) + + useEffect(() => { + setVmTemplates(data) + }, [data]) + + 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) + } + + return ( + + + VM Templates + + + + + + + ID + Name + Owner + Group + Registration time + + + + {vmTemplates + ?.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + ?.map((vmTemplate) => ( + + onChange({ + ...roles[selectedRoleIndex], + SELECTED_VM_TEMPLATE_ID: [vmTemplate.ID], + }) + } + name="SELECTED_VM_TEMPLATE_ID" + role="checkbox" + aria-checked={templateID.includes(vmTemplate.ID)} + style={{ + backgroundColor: templateID?.includes(vmTemplate.ID) + ? theme?.palette?.action?.selected + : theme?.palette.action?.disabledBackground, + }} + data-cy={`role-vmtemplate-${vmTemplate.ID}`} + tabIndex={-1} + > + + + + {vmTemplate.ID} + {vmTemplate.NAME} + {vmTemplate.UNAME} + {vmTemplate.GNAME} + + {convertTimestampToDate(vmTemplate.REGTIME)} + + + ))} + +
    +
    + +
    + ) +} + +VmTemplatesPanel.propTypes = { + roles: PropTypes.arrayOf( + PropTypes.shape({ + NAME: PropTypes.string, + POLICY: PropTypes.string, + SELECTED_VM_TEMPLATE_ID: PropTypes.array, + }) + ), + selectedRoleIndex: PropTypes.number, + onChange: PropTypes.func.isRequired, + templateID: PropTypes.array, +} + +export default VmTemplatesPanel diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/index.js new file mode 100644 index 0000000000..1eaef1cc6e --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/Steps/index.js @@ -0,0 +1,255 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 _ from 'lodash' +import General, { + STEP_ID as GENERAL_ID, +} from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/General' +import Extra, { + STEP_ID as EXTRA_ID, +} from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra' +import RoleDefinition, { + STEP_ID as ROLE_DEFINITION_ID, +} from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Roles' + +import RoleConfig, { + STEP_ID as ROLE_CONFIG_ID, +} from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig' + +import { + parseNetworkString, + parseCustomInputString, + parseVmTemplateContents, + createSteps, +} from 'client/utils' + +const convertKeysToCase = (obj, toLower = true) => { + if (_.isArray(obj)) { + return obj.map((item) => convertKeysToCase(item, toLower)) + } + + if (_.isObject(obj) && !_.isDate(obj) && !_.isFunction(obj)) { + return _.mapValues( + _.mapKeys(obj, (_value, key) => + toLower ? key.toLowerCase() : key.toUpperCase() + ), + (value) => convertKeysToCase(value, toLower) + ) + } + + return obj +} + +const Steps = createSteps([General, Extra, RoleDefinition, RoleConfig], { + transformInitialValue: (ServiceTemplate, schema) => { + const definedNetworks = Object.entries( + ServiceTemplate?.TEMPLATE?.BODY?.networks || {} + ) + ?.map(([name, networkString]) => + parseNetworkString(`${name}|${networkString}`, true) + ) + .filter(Boolean) + + const customAttributes = Object.entries( + ServiceTemplate?.TEMPLATE?.BODY?.custom_attrs || {} + ) + ?.map(([name, customInputString]) => + parseCustomInputString(`${name}|${customInputString}`, true) + ) + .filter(Boolean) + + const reversedVmTc = ServiceTemplate?.TEMPLATE?.BODY?.roles?.map((role) => + parseVmTemplateContents(role?.vm_template_contents, true) + ) + + const generalData = { + NAME: ServiceTemplate?.TEMPLATE?.BODY?.name, + DESCRIPTION: ServiceTemplate?.TEMPLATE?.BODY.description, + } + + 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()], + })) + + const roleDefinitionData = definedRoles?.map((role) => ({ + ...role, + })) + + 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 + ), + 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: reversedVmTc?.map((rtc) => rtc.networks), + } + + 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 + }, + + transformBeforeSubmit: (formData) => { + const { + [GENERAL_ID]: generalData, + [ROLE_DEFINITION_ID]: roleDefinitionData, + [EXTRA_ID]: extraData, + [ROLE_CONFIG_ID]: roleConfigData, + } = formData + + const formatTemplate = { + ...generalData, + roles: roleDefinitionData?.map((roleDef, index) => { + const scheduledPolicies = roleConfigData?.SCHEDULEDPOLICIES?.[ + index + ]?.map((policy) => { + const newPolicy = { + ...policy, + TYPE: policy?.SCHEDTYPE, + ADJUST: +policy?.ADJUST, + [policy.TIMEFORMAT?.split(' ')?.join('_')?.toLowerCase()]: + policy.TIMEEXPRESSION, + } + delete newPolicy.SCHEDTYPE + delete newPolicy.TIMEFORMAT + delete newPolicy.TIMEEXPRESSION + + return newPolicy + }) + + const newRoleDef = { + vm_template_contents: parseVmTemplateContents({ + networks: roleConfigData?.NETWORKS?.[index] ?? undefined, + schedActions: extraData?.SCHED_ACTION ?? undefined, + }), + ...roleDef, + + ...roleConfigData?.MINMAXVMS?.[index], + VM_TEMPLATE: +roleDef?.SELECTED_VM_TEMPLATE_ID?.[0], + ...(scheduledPolicies && + scheduledPolicies.length > 0 && { + scheduled_policies: scheduledPolicies, + }), + elasticity_policies: [ + ...roleConfigData?.ELASTICITYPOLICIES?.[index].flatMap((elap) => ({ + ...elap, + ...(elap?.ADJUST && { adjust: +elap?.ADJUST }), + })), + ], + } + + delete newRoleDef.SELECTED_VM_TEMPLATE_ID + delete newRoleDef.MINMAXVMS + + return newRoleDef + }), + ...extraData?.ADVANCED, + ...(extraData?.NETWORKING?.length && { + networks: extraData?.NETWORKING?.reduce((acc, network) => { + if (network?.name) { + acc[network.name] = parseNetworkString(network) + } + + return acc + }, {}), + }), + custom_attrs: extraData?.CUSTOM_ATTRIBUTES?.reduce((acc, cinput) => { + if (cinput?.name) { + acc[cinput.name] = parseCustomInputString(cinput) + } + + return acc + }, {}), + } + + return convertKeysToCase(formatTemplate) + }, +}) + +export default Steps diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/index.js new file mode 100644 index 0000000000..b515e00ee0 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/CreateForm/index.js @@ -0,0 +1,16 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 { default } from 'client/components/Forms/ServiceTemplate/CreateForm/Steps' diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/Charters/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/Charters/index.js new file mode 100644 index 0000000000..8e529fe0d7 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/Charters/index.js @@ -0,0 +1,164 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 { Box, Stack, Divider } from '@mui/material' +import { useFieldArray } from 'react-hook-form' +import { array, object } from 'yup' + +import { ScheduleActionCard } from 'client/components/Cards' +import { + CreateSchedButton, + CharterButton, + UpdateSchedButton, + DeleteSchedButton, +} from 'client/components/Buttons/ScheduleAction' + +import PropTypes from 'prop-types' +import { T } from 'client/constants' +import { Legend } from 'client/components/Forms' + +import { mapNameByIndex } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema' +import { STEP_ID as EXTRA_ID } from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra' + +import { Component, useMemo } from 'react' + +export const TAB_ID = 'SCHED_ACTION' + +const mapNameFunction = mapNameByIndex('SCHED_ACTION') + +export const SCHED_ACTION_SCHEMA = object({ + SCHED_ACTION: array() + .ensure() + .transform((actions) => actions.map(mapNameByIndex('SCHED_ACTION'))), +}) + +const ScheduleActionsSection = ({ oneConfig, adminGroup }) => { + const { + fields: scheduleActions, + remove, + update, + append, + } = useFieldArray({ + name: `${EXTRA_ID}.${TAB_ID}`, + keyName: 'ID', + }) + + const handleCreateAction = (action) => { + append(mapNameFunction(action, scheduleActions.length)) + } + + const handleCreateCharter = (actions) => { + const mappedActions = actions?.map((action, idx) => + mapNameFunction(action, scheduleActions.length + idx) + ) + + append(mappedActions) + } + + const handleUpdate = (action, index) => { + update(index, mapNameFunction(action, index)) + } + + const handleRemove = (index) => { + remove(index) + } + + return ( + + + + + + + + + + {scheduleActions?.map((schedule, index) => { + const { ID, NAME } = schedule + const fakeValues = { ...schedule, ID: index } + + return ( + + handleUpdate(newAction, index)} + oneConfig={oneConfig} + adminGroup={adminGroup} + /> + handleRemove(index)} + oneConfig={oneConfig} + adminGroup={adminGroup} + /> + + } + /> + ) + })} + + + + + ) +} + +ScheduleActionsSection.propTypes = { + data: PropTypes.any, + setFormData: PropTypes.func, + oneConfig: PropTypes.object, + adminGroup: PropTypes.bool, +} +export const STEP_ID = 'charter' + +const Content = () => useMemo(() => , [STEP_ID]) + +Content.propTypes = { + data: PropTypes.any, + setFormData: PropTypes.func, + hypervisor: PropTypes.string, + control: PropTypes.object, +} + +/** + * + * @returns {Component} - Charters step + */ +const Charter = () => ({ + id: STEP_ID, + label: T.Charter, + resolver: SCHED_ACTION_SCHEMA, + optionsValidate: { abortEarly: false }, + content: Content, +}) + +export default Charter diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/General/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/General/index.js new file mode 100644 index 0000000000..a83e4d8f72 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/General/index.js @@ -0,0 +1,59 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 'client/components/Forms/FormWithSchema' +import { T } from 'client/constants' +import { SCHEMA, NAME_FIELD, INSTANCE_FIELD } from './schema' + +export const STEP_ID = 'general' + +const Content = ({ isUpdate }) => ( + +) + +/** + * 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 }), + } +} + +General.propTypes = { + data: PropTypes.object, + setFormData: PropTypes.func, +} + +Content.propTypes = { isUpdate: PropTypes.bool } + +export default General diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/General/schema.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/General/schema.js new file mode 100644 index 0000000000..af8a300865 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/General/schema.js @@ -0,0 +1,48 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 } from 'client/constants' +import { Field, getObjectSchemaFromFields } from 'client/utils' +import { string, number } from 'yup' + +/** @type {Field} Name field */ +const NAME_FIELD = { + name: 'NAME', + label: 'Service Name', + 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') + .default(() => undefined), + grid: { md: 12 }, +} + +/** @type {Field} Description field */ +const INSTANCE_FIELD = { + name: 'INSTANCES', + label: 'Number of instances', + type: INPUT_TYPES.TEXT, + validation: number().required(), + fieldProps: { + type: 'number', + }, + grid: { md: 12 }, +} + +const SCHEMA = getObjectSchemaFromFields([NAME_FIELD, INSTANCE_FIELD]) + +export { SCHEMA, NAME_FIELD, INSTANCE_FIELD } diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/Network/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/Network/index.js new file mode 100644 index 0000000000..6f2aa9c4cb --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/Network/index.js @@ -0,0 +1,144 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 'client/components/Forms/FormWithSchema' +import { NETWORK_TYPES } from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/Extra/networking/schema' +import { T } from 'client/constants' +import { Box, Grid, TextField } from '@mui/material' +import { useState, useMemo, useEffect } from 'react' +import { createNetworkFields, createNetworkSchema } from './schema' +import { useFieldArray, useFormContext } from 'react-hook-form' + +export const STEP_ID = 'network' +export const FIELD_ARRAY = 'NETWORKS' + +const Content = (props) => { + const { control, setValue } = useFormContext() + + const templatePath = props?.dataTemplate?.TEMPLATE?.BODY?.networks + const networkInfo = Object.entries(templatePath || {}).reduce( + (acc, [key, value]) => { + const extraPart = value.split('::')?.[1] + acc[key] = extraPart + + return acc + }, + {} + ) + + const [tableIndex, setTableIndex] = useState(0) + + const [tableType, setTableType] = useState( + Object.keys(NETWORK_TYPES)?.[0] ?? '' + ) + + const fields = useMemo( + () => + createNetworkFields(`${STEP_ID}.${FIELD_ARRAY}.${tableIndex}`, tableType), + [tableIndex, tableType, STEP_ID] + ) + + useFieldArray({ + name: useMemo(() => `${STEP_ID}.${FIELD_ARRAY}`, [STEP_ID, tableIndex]), + control: control, + }) + + useEffect(() => { + setValue(`${STEP_ID}.${FIELD_ARRAY}.${tableIndex}.tableType`, tableType) + }, [tableType, tableIndex, STEP_ID]) + + if (fields?.length === 0) { + return null + } + + return ( + + + + { + setTableIndex(e.target.selectedIndex) + }} + fullWidth + InputProps={{ + inputProps: { 'data-cy': `select-${STEP_ID}-id` }, + }} + variant="outlined" + > + {Object.keys(networkInfo).map((key) => ( + + ))} + + + + + setTableType(e.target.value)} + fullWidth + InputProps={{ + inputProps: { 'data-cy': `select-${STEP_ID}-type` }, + }} + variant="outlined" + > + {Object.entries(NETWORK_TYPES).map(([key, value]) => ( + + ))} + + + + + + ) +} + +Content.propTypes = { + dataTemplate: PropTypes.object, + isUpdate: PropTypes.bool, +} + +/** + * @param {object} template - Service Template + * @returns {object} - Step + */ +const Network = (template) => ({ + id: STEP_ID, + label: T.Network, + resolver: createNetworkSchema(), + optionsValidate: { abortEarly: false }, + defaultDisabled: { + condition: () => { + const disableStep = !template?.dataTemplate?.TEMPLATE?.BODY?.networks + + return disableStep + }, + }, + content: () => Content(template), +}) +Network.propTypes = { + data: PropTypes.object, + setFormData: PropTypes.func, +} + +export default Network diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/Network/schema.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/Network/schema.js new file mode 100644 index 0000000000..82e63d08a6 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/Network/schema.js @@ -0,0 +1,75 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 'client/constants' +import { getValidationFromFields } from 'client/utils' +import { mixed, string, object, array } from 'yup' +import { + VNetworkTemplatesTable, + VNetworksTable, +} from 'client/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) + ? VNetworksTable + : VNetworkTemplatesTable, + singleSelect: true, + fieldProps: { + preserveState: true, + }, + validation: mixed() + .required('Network ID missing or malformed!') + .default(() => null), + grid: { xs: 12, sm: 12, md: 12 }, + }, + ] +} + +/** + * @param {string} pathPrefix - Path + * @param {string} tableType - Type of table to display + * @returns {object} - Yup schema + */ +export const createNetworkSchema = (pathPrefix, tableType) => { + const fields = createNetworkFields(pathPrefix, tableType) + + return object().shape({ + NETWORKS: array().of(object(getValidationFromFields(fields))), + }) +} diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/UserInputs/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/UserInputs/index.js new file mode 100644 index 0000000000..6d148dae26 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/UserInputs/index.js @@ -0,0 +1,115 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 'client/components/Forms/FormWithSchema' +import { T } from 'client/constants' +import { generateFormFields } from './schema' +import { getValidationFromFields } from 'client/utils' +import { object } from 'yup' +import { useState } from 'react' +import { Box, Pagination, Stack } from '@mui/material' + +const FIELDS_PER_PAGE = 10 + +export const STEP_ID = 'custom_attrs_values' + +const Content = (formFields) => { + const [currentPage, setCurrentPage] = useState(1) + + const pageCount = Math.ceil(formFields?.length / FIELDS_PER_PAGE) + + const fieldsForCurrentPage = formFields.slice( + (currentPage - 1) * FIELDS_PER_PAGE, + currentPage * FIELDS_PER_PAGE + ) + + const handlePageChange = (_event, page) => { + setCurrentPage(page) + } + + return ( + + + + + + + ) +} + +/** + * UserInputs Service Template configuration. + * + * @param {object} data - Service Template data + * @returns {object} UserInputs configuration step + */ +const UserInputs = (data) => { + const customAttrs = data?.dataTemplate?.TEMPLATE?.BODY?.custom_attrs ?? {} + + const userInputs = Object.entries(customAttrs) + .map(([key, value]) => { + const parts = value.split('|') + if (parts.length < 5) return null + + const [mandatory, type, description, rangeOrList, defaultValue] = parts + + return { + key, + mandatory, + type, + description, + rangeOrList, + defaultValue, + } + }) + .filter((entry) => entry !== null) + + const formFields = generateFormFields(userInputs) + + const formSchema = object(getValidationFromFields(formFields)) + + return { + id: STEP_ID, + label: T.UserInputs, + resolver: formSchema, + optionsValidate: { abortEarly: false }, + defaultDisabled: { + condition: () => { + const exists = !Object.keys(customAttrs ?? {})?.length > 0 + + return exists + }, + }, + content: () => Content(formFields), + } +} + +UserInputs.propTypes = { + data: PropTypes.object, + setFormData: PropTypes.func, +} + +Content.propTypes = { isUpdate: PropTypes.bool } + +export default UserInputs diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/UserInputs/schema.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/UserInputs/schema.js new file mode 100644 index 0000000000..d7e11004a1 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/UserInputs/schema.js @@ -0,0 +1,108 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 { INPUT_TYPES } from 'client/constants' + +const getTypeProp = (type) => { + switch (type) { + case 'boolean': + return INPUT_TYPES.SWITCH + case 'text': + case 'text64': + case 'number': + case 'numberfloat': + return INPUT_TYPES.TEXT + default: + return INPUT_TYPES.TEXT + } +} + +const getFieldProps = (type) => { + switch (type) { + case 'text': + case 'text64': + return { type: 'text' } + case 'number': + case 'numberfloat': + case 'range': + case 'rangefloat': + return { type: 'number' } + default: + return {} + } +} + +const getValidation = (type, mandatory) => { + const isMandatory = mandatory === 'M' + + switch (type) { + case 'text': + case 'text64': + return isMandatory + ? string() + .trim() + .required() + .default(() => undefined) + : string() + .trim() + .notRequired() + .default(() => undefined) + case 'number': + case 'numberfloat': + return isMandatory + ? number() + .required() + .default(() => undefined) + : number() + .notRequired() + .default(() => undefined) + case 'boolean': + return isMandatory + ? boolean().yesOrNo().required() + : boolean().yesOrNo().notRequired() + default: + return isMandatory + ? string() + .trim() + .required() + .default(() => undefined) + : string() + .trim() + .notRequired() + .default(() => undefined) + } +} + +const generateField = (input) => { + const { key, type, defaultValue, mandatory } = input + + return { + name: key.toLowerCase(), + label: key, + type: getTypeProp(type), + fieldProps: getFieldProps(type), + validation: getValidation(type, mandatory), + defaultValue: defaultValue, + grid: { md: 12 }, + } +} + +/** + * @param {Array} inputs - Array of user inputs + * @returns {Array} - User input fields + */ +export const generateFormFields = (inputs) => + inputs.map((input) => generateField(input)) diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/index.js new file mode 100644 index 0000000000..44626f4632 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/Steps/index.js @@ -0,0 +1,97 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 General, { + STEP_ID as GENERAL_ID, +} from 'client/components/Forms/ServiceTemplate/InstantiateForm/Steps/General' + +import UserInputs, { + STEP_ID as USERINPUTS_ID, +} from 'client/components/Forms/ServiceTemplate/InstantiateForm/Steps/UserInputs' + +import Network, { + STEP_ID as NETWORK_ID, +} from 'client/components/Forms/ServiceTemplate/InstantiateForm/Steps/Network' + +import Charter, { + STEP_ID as CHARTER_ID, +} from 'client/components/Forms/ServiceTemplate/InstantiateForm/Steps/Charters' + +import { createSteps, parseVmTemplateContents } from 'client/utils' + +const Steps = createSteps([General, UserInputs, Network, Charter], { + 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, + } + } + ) + + const knownTemplate = schema.cast({ + [GENERAL_ID]: {}, + [USERINPUTS_ID]: {}, + [NETWORK_ID]: { NETWORKS: networks }, + [CHARTER_ID]: {}, + }) + + return { ...knownTemplate, roles: roles } + }, + + transformBeforeSubmit: (formData) => { + const { + [GENERAL_ID]: generalData, + [USERINPUTS_ID]: userInputsData, + [NETWORK_ID]: networkData, + [CHARTER_ID]: charterData, + } = formData + + const formatTemplate = { + custom_attrs_values: { ...userInputsData }, + networks_values: networkData?.NETWORKS?.map((network) => ({ + [network?.name]: { + [['existing', 'reserve'].includes(network?.tableType) + ? 'id' + : 'template_id']: network?.netid, + }, + })), + roles: formData?.roles?.map((role) => ({ + ...role, + vm_template_contents: parseVmTemplateContents( + { + vmTemplateContents: role?.vm_template_contents, + customAttrsValues: userInputsData, + }, + false, + true + ), + })), + ...(!!charterData?.SCHED_ACTION?.length && { ...charterData }), + name: generalData?.NAME, + } + + return formatTemplate + }, +}) + +export default Steps diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/index.js new file mode 100644 index 0000000000..647b5c299f --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/InstantiateForm/index.js @@ -0,0 +1,16 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 { default } from 'client/components/Forms/ServiceTemplate/InstantiateForm/Steps' diff --git a/src/fireedge/src/client/components/Forms/ServiceTemplate/index.js b/src/fireedge/src/client/components/Forms/ServiceTemplate/index.js new file mode 100644 index 0000000000..5d8eb99750 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/ServiceTemplate/index.js @@ -0,0 +1,41 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 { ReactElement } from 'react' +import { AsyncLoadForm, ConfigurationProps } from 'client/components/HOC' +import { CreateFormCallback, CreateStepsCallback } from 'client/utils/schema' + +/** + * @param {ConfigurationProps} configProps - Configuration + * @returns {ReactElement|CreateFormCallback} Asynchronous loaded form + */ +const CloneForm = (configProps) => + AsyncLoadForm({ formPath: 'ServiceTemplate/CloneForm' }, configProps) + +/** + * @param {ConfigurationProps} configProps - Configuration + * @returns {ReactElement|CreateStepsCallback} Asynchronous loaded form + */ +const CreateForm = (configProps) => + AsyncLoadForm({ formPath: 'ServiceTemplate/CreateForm' }, configProps) + +/** + * @param {ConfigurationProps} configProps - Configuration + * @returns {ReactElement|CreateStepsCallback} Asynchronous loaded form + */ +const InstantiateForm = (configProps) => + AsyncLoadForm({ formPath: 'ServiceTemplate/InstantiateForm' }, configProps) + +export { CloneForm, CreateForm, InstantiateForm } diff --git a/src/fireedge/src/client/components/Forms/Vm/CreateSchedActionForm/fields.js b/src/fireedge/src/client/components/Forms/Vm/CreateSchedActionForm/fields.js index 145e68e21e..e6ec2b5263 100644 --- a/src/fireedge/src/client/components/Forms/Vm/CreateSchedActionForm/fields.js +++ b/src/fireedge/src/client/components/Forms/Vm/CreateSchedActionForm/fields.js @@ -23,7 +23,6 @@ import { PERIOD_TYPES, REPEAT_VALUES, SCHEDULE_TYPE, - SERVER_CONFIG, T, TEMPLATE_SCHEDULE_TYPE_STRING, VM_ACTIONS_IN_CHARTER, @@ -68,13 +67,13 @@ const DAYS_OF_WEEK = [ T.Saturday, ] -const getNow = () => - SERVER_CONFIG?.currentTimeZone - ? DateTime.now().setZone(SERVER_CONFIG.currentTimeZone) - : DateTime.now() +const getNow = () => DateTime.now() const getTomorrow = () => getNow().plus({ days: 1 }) +const getTomorrowAtMidnight = () => + getTomorrow().set({ hour: 12, minute: 0, second: 0 }) + const getNextWeek = () => getNow().plus({ weeks: 1 }) const parseDateString = (_, originalValue) => { @@ -240,7 +239,7 @@ const TIME_FIELD = { typeAction !== SCHEDULE_TYPE.RELATIVE ? schema.required() : schema ), fieldProps: { - defaultValue: getTomorrow(), + defaultValue: getTomorrowAtMidnight(), minDateTime: getNow(), }, } @@ -283,24 +282,13 @@ const WEEKLY_FIELD = { addEmpty: false, getValue: (_, index) => String(index), }), - fieldProps: ([_, repeat] = [], form) => { - if (repeat === REPEAT_VALUES.DAILY) { - const allDays = Array.from( - { length: DAYS_OF_WEEK.length }, - (__, index) => `${index}` - ) - - form?.setValue('WEEKLY', allDays) - } - }, htmlType: (_, context) => { const values = context?.getValues() || {} return ( !( values?.PERIODIC === SCHEDULE_TYPE.PERIODIC && - (values?.REPEAT === REPEAT_VALUES.WEEKLY || - values?.REPEAT === REPEAT_VALUES.DAILY) + values?.REPEAT === REPEAT_VALUES.WEEKLY ) && INPUT_TYPES.HIDDEN ) }, @@ -309,10 +297,9 @@ const WEEKLY_FIELD = { .min(1) .default(() => context?.[DAYS_FIELD.name]?.split?.(',') ?? []) .when(REPEAT_FIELD.name, (repeatType, schema) => - repeatType === REPEAT_VALUES.WEEKLY || - repeatType === REPEAT_VALUES.DAILY - ? schema.required(T.DaysBetween0_6) - : schema.strip() + repeatType !== REPEAT_VALUES.WEEKLY + ? schema.strip() + : schema.required(T.DaysBetween0_6) ) .afterSubmit((value) => value?.join?.(',')) ), @@ -418,21 +405,25 @@ const HOURLY_FIELD = { */ const DAYS_FIELD = { name: 'DAYS', - validation: string().afterSubmit((_, { parent }) => { - const isPeriodic = !!parent?.[PERIODIC_FIELD_NAME] - const repeatType = parent?.[REPEAT_FIELD.name] + validation: mixed() + .notRequired() + .transform((value, _originalValue, context) => { + const isPeriodic = !!context?.parent?.[PERIODIC_FIELD_NAME] + const repeatType = context?.parent?.[REPEAT_FIELD.name] - if (!isPeriodic) return undefined + if (!isPeriodic) return undefined - const { WEEKLY, MONTHLY, YEARLY, HOURLY } = REPEAT_VALUES + const { WEEKLY, MONTHLY, YEARLY, HOURLY } = REPEAT_VALUES - return { - [WEEKLY]: parent?.[WEEKLY_FIELD.name], - [MONTHLY]: parent?.[MONTHLY_FIELD.name], - [YEARLY]: parent?.[YEARLY_FIELD.name], - [HOURLY]: parent?.[HOURLY_FIELD.name], - }[repeatType] - }), + const dayValues = { + [WEEKLY]: context?.parent?.[WEEKLY_FIELD.name], + [MONTHLY]: context?.parent?.[MONTHLY_FIELD.name], + [YEARLY]: context?.parent?.[YEARLY_FIELD.name], + [HOURLY]: context?.parent?.[HOURLY_FIELD.name], + } + + return dayValues[repeatType] ?? value + }), } // -------------------------------------------------------- @@ -452,21 +443,13 @@ const END_TYPE_FIELD = { getText: (value) => sentenceCase(value), getValue: (value) => END_TYPE_VALUES[value], }), - validation: string() - .trim() - .default(() => END_TYPE_VALUES.NEVER) - .when(PERIODIC_FIELD_NAME, (typeAction, schema) => - typeAction === SCHEDULE_TYPE.PERIODIC ? schema.required() : schema - ), + validation: mixed().notRequired(), } /** @type {Field} End value field */ const END_VALUE_FIELD = { name: 'END_VALUE', - label: ([_, endType] = []) => - endType === END_TYPE_VALUES.REPETITION - ? T.NumberOfRepetitions - : T.WhenDoYouWantThisActionToStop, + label: T.WhenYouWantThatTheActionFinishes, dependOf: [PERIODIC_FIELD_NAME, END_TYPE_FIELD.name], type: ([typeAction, endType] = []) => typeAction === SCHEDULE_TYPE.PERIODIC && endType === END_TYPE_VALUES.DATE @@ -501,9 +484,7 @@ const END_VALUE_FIELD = { } ), fieldProps: ([_, endType] = []) => - endType === END_TYPE_VALUES.DATE - ? { defaultValue: getNextWeek() } - : { min: 1 }, + endType === END_TYPE_VALUES.DATE && { defaultValue: getNextWeek() }, } // -------------------------------------------------------- diff --git a/src/fireedge/src/client/components/Tables/ServiceTemplates/actions.js b/src/fireedge/src/client/components/Tables/ServiceTemplates/actions.js new file mode 100644 index 0000000000..1015c2de19 --- /dev/null +++ b/src/fireedge/src/client/components/Tables/ServiceTemplates/actions.js @@ -0,0 +1,232 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 { useMemo } from 'react' +import { useHistory } from 'react-router-dom' +import { Typography } from '@mui/material' +import { AddCircledOutline, Trash, PlayOutline, Group } from 'iconoir-react' + +import { useViews } from 'client/features/Auth' +import { + // useCloneTemplateMutation, + useRemoveServiceTemplateMutation, + useChangeServiceTemplateOwnershipMutation, +} from 'client/features/OneApi/serviceTemplate' + +import { ChangeUserForm, ChangeGroupForm } from 'client/components/Forms/Vm' +// import { CloneForm } from 'client/components/Forms/ServiceTemplate' +import { + createActions, + GlobalAction, +} from 'client/components/Tables/Enhanced/Utils' + +import { Translate } from 'client/components/HOC' +import { PATH } from 'client/apps/sunstone/routesOne' +import { T, SERVICE_TEMPLATE_ACTIONS, RESOURCE_NAMES } from 'client/constants' + +const ListServiceTemplateNames = ({ rows = [] }) => + rows?.map?.(({ id, original }) => { + const { ID, NAME } = original + + return ( + + {`#${ID} ${NAME}`} + + ) + }) + +const SubHeader = (rows) => + +const MessageToConfirmAction = (rows, description) => ( + <> + + {description && } + + +) + +MessageToConfirmAction.displayName = 'MessageToConfirmAction' + +/** + * Generates the actions to operate resources on VM Template table. + * + * @returns {GlobalAction} - Actions + */ +const Actions = () => { + const history = useHistory() + const { view, getResourceView } = useViews() + + // const [clone] = useCloneTemplateMutation() + const [remove] = useRemoveServiceTemplateMutation() + const [changeOwnership] = useChangeServiceTemplateOwnershipMutation() + + return useMemo( + () => + createActions({ + filters: getResourceView(RESOURCE_NAMES.SERVICE_TEMPLATE)?.actions, + actions: [ + { + accessor: SERVICE_TEMPLATE_ACTIONS.CREATE_DIALOG, + tooltip: T.Create, + icon: AddCircledOutline, + action: () => history.push(PATH.TEMPLATE.SERVICES.CREATE), + }, + { + accessor: SERVICE_TEMPLATE_ACTIONS.INSTANTIATE_DIALOG, + tooltip: T.Instantiate, + icon: PlayOutline, + selected: { max: 1 }, + action: (rows) => { + const template = rows?.[0]?.original ?? {} + const path = PATH.TEMPLATE.SERVICES.INSTANTIATE + + history.push(path, template) + }, + }, + { + accessor: SERVICE_TEMPLATE_ACTIONS.UPDATE_DIALOG, + label: T.Update, + tooltip: T.Update, + selected: { max: 1 }, + color: 'secondary', + action: (rows) => { + const serviceTemplate = rows?.[0]?.original ?? {} + const path = PATH.TEMPLATE.SERVICES.CREATE + + history.push(path, serviceTemplate) + }, + }, + // { + // accessor: SERVICE_TEMPLATE_ACTIONS.CLONE, + // label: T.Clone, + // tooltip: T.Clone, + // selected: true, + // color: 'secondary', + // options: [ + // { + // dialogProps: { + // title: (rows) => { + // const isMultiple = rows?.length > 1 + // const { ID, NAME } = rows?.[0]?.original ?? {} + + // return [ + // Tr( + // isMultiple ? T.CloneSeveralTemplates : T.CloneTemplate + // ), + // !isMultiple && `#${ID} ${NAME}`, + // ] + // .filter(Boolean) + // .join(' - ') + // }, + // dataCy: 'modal-clone', + // }, + // form: (rows) => { + // const names = rows?.map(({ original }) => original?.NAME) + // const stepProps = { isMultiple: names.length > 1 } + // const initialValues = { name: `Copy of ${names?.[0]}` } + + // return CloneForm({ stepProps, initialValues }) + // }, + // onSubmit: + // (rows) => + // async ({ prefix, name, image } = {}) => { + // const serviceTemplates = rows?.map?.( + // ({ original: { ID, NAME } = {} }) => + // // overwrite all names with prefix+NAME + // ({ + // id: ID, + // name: prefix ? `${prefix} ${NAME}` : name, + // image, + // }) + // ) + + // await Promise.all(serviceTemplates.map(clone)) + // }, + // }, + // ], + // }, + { + tooltip: T.Ownership, + icon: Group, + selected: true, + color: 'secondary', + dataCy: 'template-ownership', + options: [ + { + accessor: SERVICE_TEMPLATE_ACTIONS.CHANGE_OWNER, + name: T.ChangeOwner, + dialogProps: { + title: T.ChangeOwner, + subheader: SubHeader, + dataCy: `modal-${SERVICE_TEMPLATE_ACTIONS.CHANGE_OWNER}`, + }, + form: ChangeUserForm, + onSubmit: (rows) => (newOwnership) => { + rows?.map?.(({ original }) => + changeOwnership({ id: original?.ID, ...newOwnership }) + ) + }, + }, + { + accessor: SERVICE_TEMPLATE_ACTIONS.CHANGE_GROUP, + name: T.ChangeGroup, + dialogProps: { + title: T.ChangeGroup, + subheader: SubHeader, + dataCy: `modal-${SERVICE_TEMPLATE_ACTIONS.CHANGE_GROUP}`, + }, + form: ChangeGroupForm, + onSubmit: (rows) => async (newOwnership) => { + const ids = rows?.map?.(({ original }) => original?.ID) + await Promise.all( + ids.map((id) => changeOwnership({ id, ...newOwnership })) + ) + }, + }, + ], + }, + { + accessor: SERVICE_TEMPLATE_ACTIONS.DELETE, + tooltip: T.Delete, + icon: Trash, + selected: { min: 1 }, + color: 'error', + options: [ + { + isConfirmDialog: true, + dialogProps: { + title: T.Delete, + dataCy: `modal-${SERVICE_TEMPLATE_ACTIONS.DELETE}`, + children: MessageToConfirmAction, + }, + onSubmit: (rows) => async () => { + const ids = rows?.map?.(({ original }) => original?.ID) + await Promise.all(ids.map((id) => remove({ id }))) + }, + }, + ], + }, + ], + }), + [view] + ) +} + +export default Actions diff --git a/src/fireedge/src/client/components/Tables/Services/actions.js b/src/fireedge/src/client/components/Tables/Services/actions.js new file mode 100644 index 0000000000..31e474e357 --- /dev/null +++ b/src/fireedge/src/client/components/Tables/Services/actions.js @@ -0,0 +1,260 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 { useMemo } from 'react' +import { useHistory } from 'react-router-dom' +import { Typography } from '@mui/material' +import { AddCircledOutline, Trash, Group, RefreshCircular } from 'iconoir-react' +import { makeStyles } from '@mui/styles' + +import { useViews } from 'client/features/Auth' +import { + useRemoveServiceMutation, + useChangeServiceOwnerMutation, + useRecoverServiceMutation, +} from 'client/features/OneApi/service' + +import { ChangeUserForm, ChangeGroupForm } from 'client/components/Forms/Vm' +import { + createActions, + GlobalAction, +} from 'client/components/Tables/Enhanced/Utils' + +import ServiceTemplatesTable from 'client/components/Tables/ServiceTemplates' +import { Tr, Translate } from 'client/components/HOC' +import { PATH } from 'client/apps/sunstone/routesOne' +import { T, SERVICE_TEMPLATE_ACTIONS, RESOURCE_NAMES } from 'client/constants' + +const useTableStyles = makeStyles({ + body: { gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }, +}) + +const ListVmTemplateNames = ({ rows = [] }) => + rows?.map?.(({ id, original }) => { + const { ID, NAME } = original + + return ( + + {`#${ID} ${NAME}`} + + ) + }) + +const SubHeader = (rows) => + +const MessageToConfirmAction = (rows, description) => ( + <> + + {description && } + + +) + +MessageToConfirmAction.displayName = 'MessageToConfirmAction' + +/** + * Generates the actions to operate resources on VM Template table. + * + * @returns {GlobalAction} - Actions + */ +const Actions = () => { + const history = useHistory() + const { view, getResourceView } = useViews() + + const [remove] = useRemoveServiceMutation() + const [recover] = useRecoverServiceMutation() + const [changeOwnership] = useChangeServiceOwnerMutation() + + return useMemo( + () => + createActions({ + filters: getResourceView(RESOURCE_NAMES.SERVICE_TEMPLATE)?.actions, + actions: [ + { + accessor: SERVICE_TEMPLATE_ACTIONS.CREATE_DIALOG, + tooltip: T.Create, + icon: AddCircledOutline, + options: [ + { + isConfirmDialog: true, + dialogProps: { + title: T.Instantiate, + children: () => { + const classes = useTableStyles() + + const redirectToInstantiate = (template) => + history.push(PATH.TEMPLATE.SERVICES.INSTANTIATE, template) + + return ( + + ) + }, + fixedWidth: true, + fixedHeight: true, + handleAccept: undefined, + }, + }, + ], + }, + { + tooltip: T.Ownership, + icon: Group, + selected: { min: 1 }, + color: 'secondary', + dataCy: 'template-ownership', + options: [ + { + accessor: SERVICE_TEMPLATE_ACTIONS.CHANGE_OWNER, + name: T.ChangeOwner, + dialogProps: { + title: T.ChangeOwner, + subheader: SubHeader, + dataCy: `modal-${SERVICE_TEMPLATE_ACTIONS.CHANGE_OWNER}`, + }, + form: ChangeUserForm, + onSubmit: (rows) => (newOwnership) => { + rows?.map?.(({ original }) => + changeOwnership({ id: original?.ID, ...newOwnership }) + ) + }, + }, + { + accessor: SERVICE_TEMPLATE_ACTIONS.CHANGE_GROUP, + name: T.ChangeGroup, + dialogProps: { + title: T.ChangeGroup, + subheader: SubHeader, + dataCy: `modal-${SERVICE_TEMPLATE_ACTIONS.CHANGE_GROUP}`, + }, + form: ChangeGroupForm, + onSubmit: (rows) => async (newOwnership) => { + const ids = rows?.map?.(({ original }) => original?.ID) + await Promise.all( + ids.map((id) => changeOwnership({ id, ...newOwnership })) + ) + }, + }, + ], + }, + { + tooltip: T.Recover, + icon: RefreshCircular, + selected: true, + color: 'secondary', + dataCy: 'service-recover', + options: [ + { + accessor: SERVICE_TEMPLATE_ACTIONS.RECOVER, + name: T.RecoverService, + isConfirmDialog: true, + dialogProps: { + dataCy: `modal-${SERVICE_TEMPLATE_ACTIONS.RECOVER}`, + title: (rows) => { + const isMultiple = rows?.length > 1 + const { ID, NAME } = rows?.[0]?.original ?? {} + + return [ + Tr( + isMultiple ? T.RecoverSeveralServices : T.RecoverService + ), + !isMultiple && `#${ID} ${NAME}`, + ] + .filter(Boolean) + .join(' - ') + }, + }, + onSubmit: (rows) => async () => { + const ids = rows?.map?.(({ original }) => original?.ID) + await Promise.all(ids.map((id) => recover({ id }))) + }, + }, + { + accessor: SERVICE_TEMPLATE_ACTIONS.RECOVER, + name: `${T.Recover} ${T.Delete.toLowerCase()}`, + isConfirmDialog: true, + dialogProps: { + dataCy: `modal-${SERVICE_TEMPLATE_ACTIONS.RECOVER}`, + title: (rows) => { + const isMultiple = rows?.length > 1 + const { ID, NAME } = rows?.[0]?.original ?? {} + + return [ + Tr( + isMultiple ? T.RecoverSeveralServices : T.RecoverService + ), + !isMultiple && `#${ID} ${NAME}`, + ] + .filter(Boolean) + .join(' - ') + }, + }, + onSubmit: (rows) => async () => { + const ids = rows?.map?.(({ original }) => original?.ID) + await Promise.all( + ids.map((id) => recover({ id, delete: true })) + ) + }, + }, + ], + }, + { + accessor: SERVICE_TEMPLATE_ACTIONS.DELETE, + tooltip: T.Delete, + icon: Trash, + selected: true, + color: 'error', + options: [ + { + isConfirmDialog: true, + dialogProps: { + dataCy: `modal-${SERVICE_TEMPLATE_ACTIONS.DELETE}`, + title: (rows) => { + const isMultiple = rows?.length > 1 + const { ID, NAME } = rows?.[0]?.original ?? {} + + return [ + Tr( + isMultiple ? T.DeleteSeveralTemplates : T.DeleteTemplate + ), + !isMultiple && `#${ID} ${NAME}`, + ] + .filter(Boolean) + .join(' - ') + }, + }, + onSubmit: (rows) => async () => { + const ids = rows?.map?.(({ original }) => original?.ID) + await Promise.all(ids.map((id) => remove({ id }))) + }, + }, + ], + }, + ], + }), + [view] + ) +} + +export default Actions diff --git a/src/fireedge/src/client/components/Tables/Vms/index.js b/src/fireedge/src/client/components/Tables/Vms/index.js index 33557d583d..9f1b49661f 100644 --- a/src/fireedge/src/client/components/Tables/Vms/index.js +++ b/src/fireedge/src/client/components/Tables/Vms/index.js @@ -37,6 +37,8 @@ const VmsTable = (props) => { host, backupjobs, backupjobsState, + filterData = [], + filterLoose = true, ...rest } = props ?? {} @@ -94,6 +96,9 @@ const VmsTable = (props) => { // This is for return data without filters return true }) + ?.filter(({ ID }) => + filterData?.length ? filterData?.includes(ID) : filterLoose + ) ?.filter(({ STATE }) => VM_STATES[STATE]?.name !== STATES.DONE) ?? [], }), }) diff --git a/src/fireedge/src/client/components/Tabs/Service/ButtonGenerator.js b/src/fireedge/src/client/components/Tabs/Service/ButtonGenerator.js new file mode 100644 index 0000000000..cd73f958de --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/Service/ButtonGenerator.js @@ -0,0 +1,132 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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, Component } from 'react' +import { Box, Button, Menu, MenuItem, IconButton } from '@mui/material' +import PropTypes from 'prop-types' +import { NavArrowDown } from 'iconoir-react' + +/** + * @param {object} root0 - Props + * @param {object} root0.items - Button props + * @param {object} root0.options - Button styles + * @returns {Component} - Custom Button + */ +export const ButtonGenerator = ({ items, options = {} }) => { + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + + const handleClick = (event, onClick) => { + if (Array.isArray(items)) { + setAnchorEl(event.currentTarget) + } else if (onClick) { + onClick() + } + } + + const handleClose = () => { + setAnchorEl(null) + } + + if (Array.isArray(items)) { + return ( + + {options?.button?.type === 'icon' ? ( + + {options?.button?.icon ?? } + + ) : ( + + )} + + {items.map(({ name, onClick }, index) => ( + { + onClick() + handleClose() + }} + {...options?.menuItem} + > + {name} + + ))} + + + ) + } else { + return options?.singleButton?.type === 'icon' ? ( + + {options?.singleButton?.icon ?? } + + ) : ( + + ) + } +} + +ButtonGenerator.propTypes = { + items: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), + options: PropTypes.object, +} diff --git a/src/fireedge/src/client/components/Tabs/Service/Info/index.js b/src/fireedge/src/client/components/Tabs/Service/Info/index.js index 8d7ca9298d..a12ebe8d73 100644 --- a/src/fireedge/src/client/components/Tabs/Service/Info/index.js +++ b/src/fireedge/src/client/components/Tabs/Service/Info/index.js @@ -17,10 +17,14 @@ import { ReactElement } from 'react' import PropTypes from 'prop-types' import { Stack } from '@mui/material' -import { useGetServiceQuery } from 'client/features/OneApi/service' +import { + useGetServiceQuery, + useServiceAddActionMutation, +} from 'client/features/OneApi/service' import { Permissions, Ownership } from 'client/components/Tabs/Common' import Information from 'client/components/Tabs/Service/Info/information' -import { getActionsAvailable } from 'client/models/Helper' +import { toSnakeCase } from 'client/utils' +import { getActionsAvailable, permissionsToOctal } from 'client/models/Helper' /** * Renders mainly information tab. @@ -38,10 +42,49 @@ const ServiceInfoTab = ({ tabProps = {}, id }) => { } = tabProps const { data: service = {} } = useGetServiceQuery({ id }) + const [addServiceAction] = useServiceAddActionMutation() + + /* eslint-disable no-shadow */ + const changePermissions = ({ id, octet }) => { + addServiceAction({ + id: id, + perform: 'chmod', + params: { octet: octet }, + }) + } + /* eslint-enable no-shadow */ + + const changeOwnership = (newOwnership) => { + addServiceAction({ + id: id, + perform: 'chown', + params: { + group_id: newOwnership?.group || '-1', + owner_id: newOwnership?.user || '-1', + }, + }) + } + const { UNAME, UID, GNAME, GID, PERMISSIONS = {} } = service const getActions = (actions) => getActionsAvailable(actions) + const handleChangeOwnership = async (newOwnership) => { + await changeOwnership({ id, ...newOwnership }) + } + + const handleChangePermission = async (newPermission) => { + const [key, value] = Object.entries(newPermission)[0] + + const [member, permission] = toSnakeCase(key).toUpperCase().split('_') + const fullPermissionName = `${member}_${permission[0]}` + + const newPermissions = { ...PERMISSIONS, [fullPermissionName]: value } + const octet = permissionsToOctal(newPermissions) + + await changePermissions({ id, octet }) + } + return ( { {permissionsPanel?.enabled && ( { {ownershipPanel?.enabled && ( { + const [addServiceAction] = useServiceAddActionMutation() const { ID, NAME, @@ -46,11 +48,29 @@ const InformationPanel = ({ service = {}, actions }) => { }, } = service || {} + const handleRename = async (_, newName) => { + await renameTemplate({ id: ID, name: newName }) + } + + const renameTemplate = ({ id, name }) => { + addServiceAction({ + id: id, + perform: 'rename', + params: { name }, + }) + } + const { name: stateName, color: stateColor } = getState(service) const info = [ { name: T.ID, value: ID, dataCy: 'id' }, - { name: T.Name, value: NAME, dataCy: 'name' }, + { + name: T.Name, + value: NAME, + canEdit: actions?.includes(VM_TEMPLATE_ACTIONS.RENAME), + dataCy: 'name', + handleEdit: handleRename, + }, { name: T.State, value: ( diff --git a/src/fireedge/src/client/components/Tabs/Service/Log.js b/src/fireedge/src/client/components/Tabs/Service/Log.js index f382e9f953..9f59270af7 100644 --- a/src/fireedge/src/client/components/Tabs/Service/Log.js +++ b/src/fireedge/src/client/components/Tabs/Service/Log.js @@ -13,49 +13,230 @@ * See the License for the specific language governing permissions and * * limitations under the License. * * ------------------------------------------------------------------------- */ -import { ReactElement } from 'react' +import { useState, useMemo, Component } from 'react' import PropTypes from 'prop-types' -import { Stack, Typography } from '@mui/material' - +import { + Stack, + Typography, + TextField, + Select, + MenuItem, + Pagination, + Box, + InputLabel, + FormControl, + FormControlLabel, + Switch, + ButtonGroup, + Button, +} from '@mui/material' import { useGetServiceQuery } from 'client/features/OneApi/service' import { timeFromMilliseconds } from 'client/models/Helper' -import { Service, SERVICE_LOG_SEVERITY } from 'client/constants' +import { SERVICE_LOG_SEVERITY } from 'client/constants' + +const PAGE_SIZE = 10 + +const severityDisplayNames = { + I: 'Info', + D: 'Debug', + E: 'Error', +} + +const sortOptions = { + TIME: 'Time', + SEVERITY: 'Severity', + MESSAGE: 'Message', +} /** - * Renders log tab. - * - * @param {object} props - Props - * @param {string} props.id - Service id - * @returns {ReactElement} Log tab + * @param {object} root - Params + * @param {number} root.id - Service Instance ID + * @returns {Component} - Logging component */ const LogTab = ({ id }) => { const { data: service = {} } = useGetServiceQuery({ id }) - - /** @type {Service} */ const { TEMPLATE: { BODY: { log = [] } = {} } = {} } = service - return ( - - {log?.map(({ severity, message, timestamp } = {}) => { - const time = timeFromMilliseconds(+timestamp) - const isError = severity === SERVICE_LOG_SEVERITY.ERROR + const [filter, setFilter] = useState('') + const [severityFilter, setSeverityFilter] = useState('ALL') + const [sortType, setSortType] = useState('TIME') + const [sortAscending, setSortAscending] = useState(false) + const [page, setPage] = useState(1) - return ( - { + const sorted = [...log] + sorted.sort((a, b) => { + switch (sortType) { + case 'SEVERITY': + return sortAscending + ? a.severity.localeCompare(b.severity) + : b.severity.localeCompare(a.severity) + case 'MESSAGE': + return sortAscending + ? a.message.localeCompare(b.message) + : b.message.localeCompare(a.message) + default: + return sortAscending + ? a.timestamp - b.timestamp + : b.timestamp - a.timestamp + } + }) + + return sorted + }, [sortType, sortAscending]) + + const filteredLogs = useMemo( + () => + sortedLogs.filter( + ({ severity, message }) => + (severityFilter === 'ALL' || severity === severityFilter) && + message.toLowerCase().includes(filter.toLowerCase()) + ), + [sortedLogs, severityFilter, filter] + ) + + const paginatedLogs = useMemo( + () => filteredLogs.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), + [filteredLogs, page] + ) + + const handleSortChange = (key) => { + if (key === sortType) { + setSortAscending(!sortAscending) + } else { + setSortType(key) + setSortAscending(false) + } + } + + return ( + + + setFilter(e.target.value)} + /> + + + Severity + + + + {Object.entries(sortOptions).map(([key, name]) => ( + + ))} + + setSortAscending(!sortAscending)} + /> + } + label={sortAscending ? 'Asc' : 'Desc'} + /> + + + + + {paginatedLogs.map(({ severity, message, timestamp }, index) => { + const time = timeFromMilliseconds(+timestamp) + + return ( + + {`${time.toFormat('ff')} [${ + severityDisplayNames[severity] + }] ${message}`} + + ) + })} + + + + setPage(value)} + variant="outlined" + shape="rounded" + size="medium" + /> + ) } LogTab.propTypes = { tabProps: PropTypes.object, id: PropTypes.string } -LogTab.displayName = 'RolesTab' +LogTab.displayName = 'Roles' export default LogTab diff --git a/src/fireedge/src/client/components/Tabs/Service/Roles.js b/src/fireedge/src/client/components/Tabs/Service/Roles.js index c369692908..d2c90adfb0 100644 --- a/src/fireedge/src/client/components/Tabs/Service/Roles.js +++ b/src/fireedge/src/client/components/Tabs/Service/Roles.js @@ -13,18 +13,38 @@ * See the License for the specific language governing permissions and * * limitations under the License. * * ------------------------------------------------------------------------- */ -import { ReactElement, memo, useMemo } from 'react' +import { ReactElement, memo, useState, useMemo } from 'react' import PropTypes from 'prop-types' -import { Link as RouterLink, generatePath } from 'react-router-dom' -import { Box, Typography, Link, CircularProgress } from '@mui/material' -import { useGetServiceQuery } from 'client/features/OneApi/service' -import { useGetTemplatesQuery } from 'client/features/OneApi/vmTemplate' -import { Translate } from 'client/components/HOC' -import { T, ServiceTemplateRole } from 'client/constants' -import { PATH } from 'client/apps/sunstone/routesOne' +import { ButtonGenerator } from 'client/components/Tabs/Service/ButtonGenerator' +import { + useGetServiceQuery, + useServiceAddRoleMutation, + useServiceRoleActionMutation, + useServiceScaleRoleMutation, +} from 'client/features/OneApi/service' -const COLUMNS = [T.Name, T.Cardinality, T.VMTemplate, T.Parents] +import { VmsTable } from 'client/components/Tables' +import VmActions from 'client/components/Tables/Vms/actions' +import { StatusCircle } from 'client/components/Status' +import { getRoleState } from 'client/models/Service' +import { Box, Dialog, Typography } from '@mui/material' +import { Content as RoleAddDialog } from 'client/components/Forms/ServiceTemplate/CreateForm/Steps/RoleConfig' +import { ScaleDialog } from 'client/components/Tabs/Service/ScaleDialog' +import { + Plus, + Trash, + SystemShut, + TransitionRight, + NavArrowDown, + Refresh, + PlayOutline, +} from 'iconoir-react' + +import { useGeneralApi } from 'client/features/General' + +// Filters actions based on the data-cy key +const filterActions = ['vm_resume', 'vm-manage', 'vm-host', 'vm-terminate'] /** * Renders template tab. @@ -34,30 +54,371 @@ const COLUMNS = [T.Name, T.Cardinality, T.VMTemplate, T.Parents] * @returns {ReactElement} Roles tab */ const RolesTab = ({ id }) => { + const { enqueueError, enqueueSuccess, enqueueInfo } = useGeneralApi() + // wrapper + const createApiCallback = (apiFunction) => async (params) => { + const payload = { id, ...params } + const response = await apiFunction(payload) + + return response + } + // api calls + const [addRole] = useServiceAddRoleMutation() + const [addRoleAction] = useServiceRoleActionMutation() + const [scaleRole] = useServiceScaleRoleMutation() + // api handlers + const handleAddRole = createApiCallback(addRole) + + const handleAddRoleAction = async (actionType) => { + for (const roleIdx of selectedRoles) { + const roleName = roles?.[roleIdx]?.name + + try { + enqueueInfo(`Starting '${actionType}' action on role: ${roleName}`) + + await createApiCallback(addRoleAction)({ + perform: actionType, + role: roleName, + }) + + enqueueSuccess(`Action '${actionType}' completed on role: ${roleName}`) + } catch (error) { + enqueueError( + `Action '${actionType}' failed on role: ${roleName}. Error: ${error}` + ) + } + } + } + + const handleScaleRole = createApiCallback(scaleRole) + + const [activeRole, setActiveRole] = useState({ idx: null, roleName: null }) + + const [isAddRoleOpen, setAddRoleOpen] = useState(false) + const [isScaleDialogOpen, setScaleDialogOpen] = useState(false) + const { data: template = {} } = useGetServiceQuery({ id }) + const [selectedRoles, setSelectedRoles] = useState([]) + const filteredActions = VmActions()?.filter((action) => + filterActions?.includes(action?.dataCy) + ) const roles = template?.TEMPLATE?.BODY?.roles || [] + const roleVms = useMemo( + () => + roles?.reduce((acc, role) => { + acc[role?.name] = role?.nodes?.map((node) => node?.vm_info?.VM.ID) + + return acc + }, {}), + [roles] + ) + + /* eslint-disable react/prop-types */ + const AddRoleDialog = ({ open, onClose }) => ( + + { + handleAddRole(params) + onClose() + }} + /> + + ) + /* eslint-enable react/prop-types */ + + const handleRoleClick = (idx, role, event) => { + event.stopPropagation() + + if (event.ctrlKey || event.metaKey) { + setSelectedRoles((prevSelectedRoles) => + prevSelectedRoles.includes(idx) + ? prevSelectedRoles.filter((roleIdx) => roleIdx !== idx) + : [...prevSelectedRoles, idx] + ) + } else { + setSelectedRoles((prevSelectedRoles) => { + if (prevSelectedRoles.length > 1 || !prevSelectedRoles.includes(idx)) { + return [idx] + } + + return prevSelectedRoles + }) + + setActiveRole((prevActiveRole) => + prevActiveRole.idx === idx + ? { idx: null, roleName: null } + : { idx: idx, roleName: role.name } + ) + } + } + + const handleOpenAddRole = () => { + setAddRoleOpen(true) + } + + const handleCloseAddRole = () => { + setAddRoleOpen(false) + } + + const handleOpenScale = () => { + setScaleDialogOpen(true) + } + + const handleCloseScale = () => { + setScaleDialogOpen(false) + } + + const isSelected = (idx) => selectedRoles.includes(idx) + return ( - - {COLUMNS.map((col) => ( - - - - ))} + + + + <> + + + + + 0, + sx: { + fontSize: '0.95rem', + padding: '6px 12px', + minWidth: '80px', + minHeight: '30px', + maxHeight: '40px', + }, + }, + }} + /> + + + + + handleAddRoleAction('resume'), + }} + options={{ + singleButton: { + disabled: !selectedRoles?.length > 0, + startIcon: , + sx: { + fontSize: 20, + padding: '0px 8px', + }, + title: null, + }, + }} + /> + + + handleAddRoleAction({ + perform: 'suspend', + role: roles?.[selectedRoles?.[0]]?.name, + }), + }, + { + name: 'Poweroff', + + onClick: () => handleAddRoleAction('poweroff'), + }, + { + name: 'Poweroff Hard', + + onClick: () => handleAddRoleAction('poweroff-hard'), + }, + ]} + options={{ + button: { + disabled: !selectedRoles?.length > 0, + startIcon: , + endIcon: , + sx: { + fontSize: 20, + padding: '8px 16px', + }, + title: null, + }, + }} + /> + + handleAddRoleAction('stop'), + }, + { + name: 'Undeploy', + onClick: () => handleAddRoleAction('undeploy'), + }, + { + name: 'Undeploy Hard', + onClick: () => handleAddRoleAction('undeploy-hard'), + }, + ]} + options={{ + button: { + disabled: !selectedRoles?.length > 0, + startIcon: , + endIcon: , + sx: { + fontSize: 20, + padding: '8px 16px', + }, + title: null, + }, + }} + /> + + handleAddRoleAction('reboot'), + }, + { + name: 'Reboot Hard', + onClick: () => handleAddRoleAction('reboot-hard'), + }, + ]} + options={{ + button: { + disabled: !selectedRoles?.length > 0, + startIcon: , + endIcon: , + sx: { + fontSize: 20, + padding: '8px 16px', + marginRight: '1em', + }, + title: null, + }, + }} + /> + + handleAddRoleAction('terminate'), + }, + { + name: 'Terminate Hard', + onClick: () => handleAddRoleAction('terminate-hard'), + }, + ]} + options={{ + button: { + disabled: !selectedRoles?.length > 0, + startIcon: , + endIcon: , + sx: { + fontSize: 20, + padding: '8px 16px', + marginLeft: '2em', + }, + title: null, + }, + }} + /> + + + {roles.map((role, idx) => ( *:not(span)': { bgcolor: 'action.hover' } }} + display="flex" + flexDirection="column" + padding="0.75em" + marginY="0.25em" + sx={(theme) => ({ + '&:hover': { bgcolor: 'action.hover', boxShadow: 3 }, + boxShadow: 1, + transition: 'all 0.1s ease-in-out', + cursor: 'pointer', + width: '100%', + borderRadius: '8px', + bgcolor: 'background.paper', + border: `2px solid ${ + isSelected(idx) + ? theme.palette.grey[600] + : theme.palette.grey[400] + }`, + })} + onClick={(event) => handleRoleClick(idx, role, event)} > - + + + {activeRole.idx === idx && ( + event.stopPropagation()} + > + + + )} ))} @@ -67,52 +428,49 @@ const RolesTab = ({ id }) => { RolesTab.propTypes = { tabProps: PropTypes.object, id: PropTypes.string } RolesTab.displayName = 'RolesTab' -const RoleComponent = memo(({ role }) => { - /** @type {ServiceTemplateRole} */ - const { name, cardinality, vm_template: templateId, parents } = role - - const { data: template, isLoading } = useGetTemplatesQuery(undefined, { - selectFromResult: ({ data = [], ...restOfQuery }) => ({ - data: data.find((item) => +item.ID === +templateId), - ...restOfQuery, - }), - }) - - const linkToVmTemplate = useMemo( - () => generatePath(PATH.TEMPLATE.VMS.DETAIL, { id: templateId }), - [templateId] - ) - - const commonProps = { noWrap: true, variant: 'subtitle2', padding: '0.5em' } +const RoleComponent = memo(({ role, selected, status }) => { + const { name, cardinality, vm_template: templateId } = role return ( - <> - - {name} - - - {cardinality} - - {isLoading ? ( - - ) : ( - - {`#${template?.ID} ${template?.NAME}`} - - )} - - {parents?.join?.()} - - + ({ + '&:hover': { boxShadow: 2, transition: 'box-shadow 0.3s' }, + bgcolor: theme.palette.background, + filter: selected ? 'brightness(100%)' : 'brightness(90%)', + })} + > + + + + + + + {name} + + + VM Template ID: {templateId} + + Cardinality: {cardinality} + + ) }) -RoleComponent.propTypes = { role: PropTypes.object } +RoleComponent.propTypes = { + role: PropTypes.object, + selected: PropTypes.bool, + status: PropTypes.number, +} RoleComponent.displayName = 'RoleComponent' export default RolesTab diff --git a/src/fireedge/src/client/components/Tabs/Service/ScaleDialog.js b/src/fireedge/src/client/components/Tabs/Service/ScaleDialog.js new file mode 100644 index 0000000000..4a6424792c --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/Service/ScaleDialog.js @@ -0,0 +1,102 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 } from 'react' +import PropTypes from 'prop-types' +import { useForm } from 'react-hook-form' +import { + Dialog, + TextField, + Switch, + FormControlLabel, + Button, + Box, + Typography, +} from '@mui/material' + +/** + * Dialog for scaling the number of VMs. + * + * @param {object} props - Props + * @param {boolean} props.open - Determines if the dialog is open + * @param {Function} props.onClose - Function to call when closing the dialog + * @param {Function} props.onScale - API call when the form is submitted + * @param {string} props.roleName - Selected role name + * @returns {Component} The scale dialog component + */ +export const ScaleDialog = ({ open, onClose, onScale, roleName }) => { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm() + + /** + * Handles the form submission. + * + * @param {object} data - The data from the form + */ + const onSubmit = (data) => { + onScale({ + action: { + force: data?.force, + cardinality: data?.numberOfVms, + role_name: roleName, + }, + }) + onClose() + } + + return ( + + + Scale +
    + + } + label="Force" + /> + + +
    +
    + ) +} + +ScaleDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onScale: PropTypes.func.isRequired, + roleName: PropTypes.string, +} diff --git a/src/fireedge/src/client/constants/index.js b/src/fireedge/src/client/constants/index.js index bb7f49b351..d401202ac2 100644 --- a/src/fireedge/src/client/constants/index.js +++ b/src/fireedge/src/client/constants/index.js @@ -216,4 +216,5 @@ export * from 'client/constants/vdc' export * from 'client/constants/vm' export * from 'client/constants/vmGroup' export * from 'client/constants/vmTemplate' +export * from 'client/constants/serviceTemplate' export * from 'client/constants/zone' diff --git a/src/fireedge/src/client/constants/serviceTemplate.js b/src/fireedge/src/client/constants/serviceTemplate.js new file mode 100644 index 0000000000..90b31ae70c --- /dev/null +++ b/src/fireedge/src/client/constants/serviceTemplate.js @@ -0,0 +1,64 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 * as ACTIONS from 'client/constants/actions' +// eslint-disable-next-line no-unused-vars +import { LockInfo, Permissions } from 'client/constants/common' + +/** + * @typedef ServiceTemplate + * @property {string|number} ID - Id + * @property {string} NAME - Name + * @property {string|number} UID - User id + * @property {string|number} GID - Group id + * @property {string} UNAME - User name + * @property {string} GNAME - Group name + * @property {Permissions} PERMISSIONS - Permissions + * @property {LockInfo} [LOCK] - Lock information + * @property {string|number} REGTIME - Registration time + * @property {object} TEMPLATE - Template information + * @property {string} [TEMPLATE.CONTEXT] - Context + * @property {string} [TEMPLATE.VCENTER_CCR_REF] - vCenter information + * @property {string} [TEMPLATE.VCENTER_INSTANCE_ID] - vCenter information + * @property {string} [TEMPLATE.VCENTER_TEMPLATE_REF] - vCenter information + */ + +/** + * @typedef ServiceTemplateFeatures + * @property {boolean} hide_cpu - If `true`, the CPU fields is hidden + * @property {false|number} cpu_factor - Scales CPU by VCPU + * - ``1``: Set it to 1 to tie CPU and vCPU + * - ``{number}``: CPU = cpu_factor * VCPU + * - ``{false}``: False to not scale the CPU + */ + +export const SERVICE_TEMPLATE_ACTIONS = { + CREATE_DIALOG: 'create_dialog', + IMPORT_DIALOG: 'import_dialog', + UPDATE_DIALOG: 'update_dialog', + INSTANTIATE_DIALOG: 'instantiate_dialog', + CREATE_APP_DIALOG: 'create_app_dialog', + CLONE: 'clone', + DELETE: 'delete', + RECOVER: 'recover', + LOCK: 'lock', + UNLOCK: 'unlock', + SHARE: 'share', + UNSHARE: 'unshare', + + RENAME: ACTIONS.RENAME, + CHANGE_OWNER: ACTIONS.CHANGE_OWNER, + CHANGE_GROUP: ACTIONS.CHANGE_GROUP, +} diff --git a/src/fireedge/src/client/constants/translates.js b/src/fireedge/src/client/constants/translates.js index dad59f4443..67ffd4be2f 100644 --- a/src/fireedge/src/client/constants/translates.js +++ b/src/fireedge/src/client/constants/translates.js @@ -44,6 +44,7 @@ module.exports = { Accept: 'Accept', Active: 'Active', Add: 'Add', + AddRole: 'Add role', AddAction: 'Add action', Append: 'Append', Attach: 'Attach', @@ -81,6 +82,7 @@ module.exports = { CreateProvision: 'Create Provision', CreateSecurityGroup: 'Create Security Group', CreateServiceTemplate: 'Create Service Template', + InstantiateServiceTemplate: 'Instantiate Service Template', CreateUser: 'Create User', UpdateUser: 'Update User', CreateVirtualNetwork: 'Create Virtual Network', @@ -141,6 +143,8 @@ module.exports = { RebootHard: 'Reboot hard', Recover: 'Recover', RecoverSeveralVMs: 'Recover several VMs', + RecoverSeveralServices: 'Recover several services', + RecoverService: 'Recover service', RecoverSomething: 'Recover: %s', Recreate: 'Recreate', Refresh: 'Refresh', @@ -226,6 +230,7 @@ module.exports = { /* Scheduling */ Action: 'Action', ScheduleAction: 'Schedule action', + ScheduledActions: 'Scheduled Actions', ScheduleActionType: 'Schedule action type', Charter: 'Charter', OneTimeAction: 'One time', @@ -324,6 +329,7 @@ module.exports = { /* steps form */ AdvancedOptions: 'Advanced options', + AdvancedParams: 'Advanced Parameters', /* steps form - flow */ ApplicationOverview: 'Application overview', WhereWillItRun: 'Where will it run?', @@ -427,8 +433,10 @@ module.exports = { /* sections - network */ Network: 'Network', Networks: 'Networks', - VirtualNetwork: 'Virtual Network', - VirtualNetworks: 'Virtual Networks', + VirtualNetwork: 'Virtual network', + VirtualNetworks: 'Virtual networks', + RoleNetwork: 'Role Network', + RoleNetworks: 'Role Networks', NetworkTemplate: 'Network Template', NetworkTemplates: 'Network Templates', NetworkTopology: 'Network topology', @@ -648,6 +656,7 @@ module.exports = { /* tabs */ Drivers: 'Drivers', General: 'General', + Extra: 'Extra', Information: 'Information', Placement: 'Placement', @@ -860,6 +869,7 @@ module.exports = { When creating several VMs, the wildcard %%idx will be replaced with a number starting from 0`, NumberOfInstances: 'Number of instances', + NumberOfVms: 'Number of VMs', MakeTemplateAvailableForVROnly: 'Make this template available for Virtual Router machines only', VmOnHoldState: 'Start VM on hold state', @@ -927,6 +937,11 @@ module.exports = { Policy: 'Policy', VmAffinity: 'VM Affinity', RolesAffinity: 'Roles Affinity', + RoleElasticity: 'Role Elasticity', + ElasticityPolicy: 'Elasticity Policy', + ElasticityPolicies: 'Elasticity Policies', + ScheduledPolicy: 'Scheduled Policy', + ScheduledPolicies: 'Scheduled Policies', AssociateToVMGroup: 'Associate VM to a VM Group', /* VM Template schema - vCenter */ vCenterTemplateRef: 'vCenter Template reference', @@ -1206,6 +1221,9 @@ module.exports = { be unshared with the group's users. Permission changed: GROUP USE`, /* Service Template schema */ + WaitVmsReport: + 'Consider VMs as running only when they report READY status via OneGate', + ServiceAutoDelete: 'Automatic deletion of service when all VMs terminated', /* Service Template schema - general */ Strategy: 'Strategy', ShutdownAction: 'Shutdown action', diff --git a/src/fireedge/src/client/constants/vm.js b/src/fireedge/src/client/constants/vm.js index d168803617..b24fb804ae 100644 --- a/src/fireedge/src/client/constants/vm.js +++ b/src/fireedge/src/client/constants/vm.js @@ -777,10 +777,10 @@ export const VM_ACTIONS = { // STORAGE ATTACH_DISK: 'attach_disk', DETACH_DISK: 'detach_disk', - SNAPSHOT_DISK_CREATE: 'disk-snapshot-create', - SNAPSHOT_DISK_RENAME: 'disk-snapshot-rename', - SNAPSHOT_DISK_REVERT: 'disk-snapshot-revert', - SNAPSHOT_DISK_DELETE: 'disk-snapshot-delete', + SNAPSHOT_DISK_CREATE: 'snapshot-disk-create', + SNAPSHOT_DISK_RENAME: 'snapshot-disk-rename', + SNAPSHOT_DISK_REVERT: 'snapshot-disk-revert', + SNAPSHOT_DISK_DELETE: 'snapshot-disk-delete', RESIZE_DISK: 'resize_disk', DISK_SAVEAS: 'disk_saveas', diff --git a/src/fireedge/src/client/containers/ServiceTemplates/Create.js b/src/fireedge/src/client/containers/ServiceTemplates/Create.js new file mode 100644 index 0000000000..8bb12fa927 --- /dev/null +++ b/src/fireedge/src/client/containers/ServiceTemplates/Create.js @@ -0,0 +1,101 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 { ReactElement } from 'react' +import { useHistory, useLocation } from 'react-router' + +import { useGeneralApi } from 'client/features/General' +import { + useUpdateServiceTemplateMutation, + useCreateServiceTemplateMutation, + useGetServiceTemplateQuery, +} from 'client/features/OneApi/serviceTemplate' +import { useGetVMGroupsQuery } from 'client/features/OneApi/vmGroup' +import { useGetHostsQuery } from 'client/features/OneApi/host' +import { useGetImagesQuery } from 'client/features/OneApi/image' +import { useGetUsersQuery } from 'client/features/OneApi/user' +import { useGetDatastoresQuery } from 'client/features/OneApi/datastore' + +import { + DefaultFormStepper, + SkeletonStepsForm, +} from 'client/components/FormStepper' +import { CreateForm } from 'client/components/Forms/ServiceTemplate' +import { PATH } from 'client/apps/sunstone/routesOne' + +const _ = require('lodash') + +/** + * Displays the creation or modification form to a VM Template. + * + * @returns {ReactElement} VM Template form + */ +function CreateServiceTemplate() { + const history = useHistory() + const { state: { ID: templateId, NAME } = {} } = useLocation() + + const { enqueueSuccess } = useGeneralApi() + const [update] = useUpdateServiceTemplateMutation() + const [allocate] = useCreateServiceTemplateMutation() + + const { data: apiTemplateData } = useGetServiceTemplateQuery({ + id: templateId, + }) + + const dataTemplate = _.cloneDeep(apiTemplateData) + + useGetVMGroupsQuery(undefined, { refetchOnMountOrArgChange: false }) + useGetHostsQuery(undefined, { refetchOnMountOrArgChange: false }) + useGetImagesQuery(undefined, { refetchOnMountOrArgChange: false }) + useGetUsersQuery(undefined, { refetchOnMountOrArgChange: false }) + useGetDatastoresQuery(undefined, { refetchOnMountOrArgChange: false }) + + const onSubmit = async (jsonTemplate) => { + try { + if (!templateId) { + const newTemplateId = await allocate({ + template: jsonTemplate, + }).unwrap() + history.push(PATH.TEMPLATE.SERVICES.LIST) + enqueueSuccess(`Service Template created - #${newTemplateId} ${NAME}`) + } else { + await update({ + id: templateId, + template: jsonTemplate, + merge: false, + }).unwrap() + history.push(PATH.TEMPLATE.SERVICES.LIST) + enqueueSuccess(`Service Template updated - #${templateId} ${NAME}`) + } + } catch {} + } + + return templateId && !dataTemplate ? ( + + ) : ( + } + > + {(config) => } + + ) +} + +export default CreateServiceTemplate diff --git a/src/fireedge/src/client/containers/ServiceTemplates/Instantiate.js b/src/fireedge/src/client/containers/ServiceTemplates/Instantiate.js new file mode 100644 index 0000000000..99d572a02b --- /dev/null +++ b/src/fireedge/src/client/containers/ServiceTemplates/Instantiate.js @@ -0,0 +1,90 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 { ReactElement } from 'react' +import { useHistory, useLocation } from 'react-router' + +import { useGeneralApi } from 'client/features/General' +import { + useDeployServiceTemplateMutation, + useGetServiceTemplateQuery, +} from 'client/features/OneApi/serviceTemplate' +import { useGetVMGroupsQuery } from 'client/features/OneApi/vmGroup' +import { useGetHostsQuery } from 'client/features/OneApi/host' +import { useGetImagesQuery } from 'client/features/OneApi/image' +import { useGetUsersQuery } from 'client/features/OneApi/user' +import { useGetDatastoresQuery } from 'client/features/OneApi/datastore' + +import { + DefaultFormStepper, + SkeletonStepsForm, +} from 'client/components/FormStepper' +import { InstantiateForm } from 'client/components/Forms/ServiceTemplate' +import { PATH } from 'client/apps/sunstone/routesOne' + +const _ = require('lodash') + +/** + * Displays the instantiate form for a Service Template. + * + * @returns {ReactElement} Service Template form + */ +function CreateServiceTemplate() { + const history = useHistory() + const { state: { ID: templateId, NAME } = {} } = useLocation() + + const { enqueueSuccess } = useGeneralApi() + const [instantiate] = useDeployServiceTemplateMutation() + + const { data: apiTemplateData } = useGetServiceTemplateQuery({ + id: templateId, + }) + + const dataTemplate = _.cloneDeep(apiTemplateData) + + useGetVMGroupsQuery(undefined, { refetchOnMountOrArgChange: false }) + useGetHostsQuery(undefined, { refetchOnMountOrArgChange: false }) + useGetImagesQuery(undefined, { refetchOnMountOrArgChange: false }) + useGetUsersQuery(undefined, { refetchOnMountOrArgChange: false }) + useGetDatastoresQuery(undefined, { refetchOnMountOrArgChange: false }) + + const onSubmit = async (jsonTemplate) => { + try { + await instantiate({ + id: templateId, + template: jsonTemplate, + }).unwrap() + history.push(PATH.INSTANCE.SERVICES.LIST) + enqueueSuccess(`Service Template initiated - #${templateId} ${NAME}`) + } catch {} + } + + return templateId && !dataTemplate ? ( + + ) : ( + } + > + {(config) => } + + ) +} + +export default CreateServiceTemplate diff --git a/src/fireedge/src/client/containers/ServiceTemplates/index.js b/src/fireedge/src/client/containers/ServiceTemplates/index.js index f8fdb85d15..63af1242eb 100644 --- a/src/fireedge/src/client/containers/ServiceTemplates/index.js +++ b/src/fireedge/src/client/containers/ServiceTemplates/index.js @@ -26,6 +26,7 @@ import { ServiceTemplatesTable } from 'client/components/Tables' import ServiceTemplateTabs from 'client/components/Tabs/ServiceTemplate' import SplitPane from 'client/components/SplitPane' import MultipleTags from 'client/components/MultipleTags' +import ServiceTemplateActions from 'client/components/Tables/ServiceTemplates/actions' import { SubmitButton } from 'client/components/FormControl' import { Tr } from 'client/components/HOC' import { T } from 'client/constants' @@ -38,6 +39,7 @@ import { T } from 'client/constants' */ function ServiceTemplates() { const [selectedRows, onSelectedRowsChange] = useState(() => []) + const actions = ServiceTemplateActions() const hasSelectedRows = selectedRows?.length > 0 const moreThanOneSelected = selectedRows?.length > 1 @@ -46,7 +48,10 @@ function ServiceTemplates() { {({ getGridProps, GutterComponent }) => ( - + {hasSelectedRows && ( <> @@ -56,6 +61,7 @@ function ServiceTemplates() { ) : ( selectedRows[0]?.toggleRowSelected(false)} /> diff --git a/src/fireedge/src/client/containers/Services/index.js b/src/fireedge/src/client/containers/Services/index.js index 0fb0b2f845..955231b30d 100644 --- a/src/fireedge/src/client/containers/Services/index.js +++ b/src/fireedge/src/client/containers/Services/index.js @@ -26,6 +26,7 @@ import { ServicesTable } from 'client/components/Tables' import ServiceTabs from 'client/components/Tabs/Service' import SplitPane from 'client/components/SplitPane' import MultipleTags from 'client/components/MultipleTags' +import ServiceActions from 'client/components/Tables/Services/actions' import { SubmitButton } from 'client/components/FormControl' import { Tr } from 'client/components/HOC' import { T } from 'client/constants' @@ -38,6 +39,7 @@ import { T } from 'client/constants' */ function Services() { const [selectedRows, onSelectedRowsChange] = useState(() => []) + const actions = ServiceActions() const hasSelectedRows = selectedRows?.length > 0 const moreThanOneSelected = selectedRows?.length > 1 @@ -46,7 +48,10 @@ function Services() { {({ getGridProps, GutterComponent }) => ( - + {hasSelectedRows && ( <> @@ -55,7 +60,7 @@ function Services() { ) : ( selectedRows[0]?.toggleRowSelected(false)} /> diff --git a/src/fireedge/src/client/features/OneApi/service.js b/src/fireedge/src/client/features/OneApi/service.js index 2300b3be67..51f608029a 100644 --- a/src/fireedge/src/client/features/OneApi/service.js +++ b/src/fireedge/src/client/features/OneApi/service.js @@ -19,6 +19,7 @@ import { oneApi, DOCUMENT, DOCUMENT_POOL } from 'client/features/OneApi' import { updateResourceOnPool, removeResourceOnPool, + updateOwnershipOnResource, } from 'client/features/OneApi/common' import { Service } from 'client/constants' @@ -92,6 +93,284 @@ const serviceApi = oneApi.injectEndpoints({ } }, }), + removeService: builder.mutation({ + /** + * Removes a service instance. + * + * @param {object} params - Request params + * @param {string} params.id - Service id + * @returns {Service} Remove service id + * @throws Fails when response isn't code 200 + */ + query: (params) => { + const name = Actions.SERVICE_DELETE + const command = { name, ...Commands[name] } + + return { params, command } + }, + invalidatesTags: [SERVICE_POOL], + }), + + changeServiceOwner: builder.mutation({ + /** + * Changes a service instance owner. + * + * @param {object} params - Request params + * @param {string} params.id - Service id + * @param {string} params.user - Service id + * @param {string} params.group - Service id + * @returns {Service} Updated service id + * @throws Fails when response isn't code 200 + */ + query: ({ user = '-1', group = '-1', ...params }) => { + params.action = { + perform: 'chown', + params: { owner_id: user, group_id: group }, + } + const name = Actions.SERVICE_ADD_ACTION + const command = { name, ...Commands[name] } + + return { params, command } + }, + invalidatesTags: (_, __, { id }) => [{ type: SERVICE, id }], + async onQueryStarted(params, { getState, dispatch, queryFulfilled }) { + try { + const patchServiceTemplate = dispatch( + serviceApi.util.updateQueryData( + 'getService', + { id: params.id }, + updateOwnershipOnResource(getState(), params) + ) + ) + + const patchServiceTemplates = dispatch( + serviceApi.util.updateQueryData( + 'getServices', + undefined, + updateOwnershipOnResource(getState(), params) + ) + ) + + queryFulfilled.catch(() => { + patchServiceTemplate.undo() + patchServiceTemplates.undo() + }) + } catch {} + }, + }), + recoverService: builder.mutation({ + /** + * Tries to recover a failed service. + * + * @param {object} params - Request params + * @param {string} params.id - Service id + * @returns {Service} Recovered service id + * @throws Fails when response isn't code 200 + */ + query: (params) => { + params.action = { + perform: 'recover', + ...(params?.delete && { params: { delete: true } }), + } + const name = Actions.SERVICE_ADD_ACTION + const command = { name, ...Commands[name] } + + return { params, command } + }, + invalidatesTags: (_, __, id) => [{ type: SERVICE, id }, SERVICE_POOL], + }), + + serviceAddRole: builder.mutation({ + /** + * Tries to add a role to a service. + * + * @param {object} params - Request params + * @param {string} params.id - Service id + * @param {string} params.role - Role config + * @returns {Service} Service id + * @throws Fails when response isn't code 200 + */ + query: (params) => { + params.action = { + perform: 'add_role', + ...(params?.role && { params: { role: params.role } }), + } + const name = Actions.SERVICE_ADD_ROLE + const command = { name, ...Commands[name] } + + return { params, command } + }, + invalidatesTags: (_, __, params) => [{ type: 'SERVICE', id: params.id }], + async onQueryStarted(params, { getState, dispatch, queryFulfilled }) { + try { + const patchServiceTemplate = dispatch( + serviceApi.util.updateQueryData( + 'getService', + { id: params.id }, + updateOwnershipOnResource(getState(), params) + ) + ) + + const patchServiceTemplates = dispatch( + serviceApi.util.updateQueryData( + 'getServices', + undefined, + updateOwnershipOnResource(getState(), params) + ) + ) + + queryFulfilled.catch(() => { + patchServiceTemplate.undo() + patchServiceTemplates.undo() + }) + } catch {} + }, + }), + + serviceScaleRole: builder.mutation({ + /** + * Tries to scale a role. + * + * @param {object} params - Request params + * @param {string} params.id - Service id + * @param {string} params.role - Role config + * @returns {Service} Service id + * @throws Fails when response isn't code 200 + */ + query: (params) => { + const name = Actions.SERVICE_ADD_SCALE + const command = { name, ...Commands[name] } + + return { params, command } + }, + invalidatesTags: (_, __, params) => [{ type: 'SERVICE', id: params.id }], + async onQueryStarted(params, { getState, dispatch, queryFulfilled }) { + try { + const patchServiceTemplate = dispatch( + serviceApi.util.updateQueryData( + 'getService', + { id: params.id }, + updateOwnershipOnResource(getState(), params) + ) + ) + + const patchServiceTemplates = dispatch( + serviceApi.util.updateQueryData( + 'getServices', + undefined, + updateOwnershipOnResource(getState(), params) + ) + ) + + queryFulfilled.catch(() => { + patchServiceTemplate.undo() + patchServiceTemplates.undo() + }) + } catch {} + }, + }), + + serviceRoleAction: builder.mutation({ + /** + * Tries to perform a role action. + * + * @param {object} params - Request params + * @param {string} params.id - Service id + * @param {string} params.role - Role config + * @returns {Service} Service id + * @throws Fails when response isn't code 200 + */ + query: (params) => { + params.action = { + perform: params?.perform, + params: { + ...params.params, + number: params?.number || '', + period: params?.period || '', + }, + } + + const name = Actions.SERVICE_ADD_ROLEACTION + const command = { name, ...Commands[name] } + + return { params, command } + }, + invalidatesTags: (_, __, params) => [{ type: 'SERVICE', id: params.id }], + async onQueryStarted(params, { getState, dispatch, queryFulfilled }) { + try { + const patchServiceTemplate = dispatch( + serviceApi.util.updateQueryData( + 'getService', + { id: params.id }, + updateOwnershipOnResource(getState(), params) + ) + ) + + const patchServiceTemplates = dispatch( + serviceApi.util.updateQueryData( + 'getServices', + undefined, + updateOwnershipOnResource(getState(), params) + ) + ) + + queryFulfilled.catch(() => { + patchServiceTemplate.undo() + patchServiceTemplates.undo() + }) + } catch {} + }, + }), + + serviceAddAction: builder.mutation({ + /** + * Tries to perform a role action. + * + * @param {object} params - Request params + * @param {string} params.id - Service id + * @param {string} params.role - Role config + * @returns {Service} Service id + * @throws Fails when response isn't code 200 + */ + query: (params) => { + params.action = { + perform: params?.perform, + params: { + ...params.params, + }, + } + + const name = Actions.SERVICE_ADD_ACTION + const command = { name, ...Commands[name] } + + return { params, command } + }, + invalidatesTags: (_, __, params) => [{ type: 'SERVICE', id: params.id }], + async onQueryStarted(params, { getState, dispatch, queryFulfilled }) { + try { + const patchServiceTemplate = dispatch( + serviceApi.util.updateQueryData( + 'getService', + { id: params.id }, + updateOwnershipOnResource(getState(), params) + ) + ) + + const patchServiceTemplates = dispatch( + serviceApi.util.updateQueryData( + 'getServices', + undefined, + updateOwnershipOnResource(getState(), params) + ) + ) + + queryFulfilled.catch(() => { + patchServiceTemplate.undo() + patchServiceTemplates.undo() + }) + } catch {} + }, + }), }), }) @@ -101,6 +380,13 @@ export const { useLazyGetServicesQuery, useGetServiceQuery, useLazyGetServiceQuery, + useRemoveServiceMutation, + useChangeServiceOwnerMutation, + useRecoverServiceMutation, + useServiceAddRoleMutation, + useServiceAddActionMutation, + useServiceRoleActionMutation, + useServiceScaleRoleMutation, } = serviceApi export default serviceApi diff --git a/src/fireedge/src/client/features/OneApi/serviceTemplate.js b/src/fireedge/src/client/features/OneApi/serviceTemplate.js index 9e8351e1de..09ad7456df 100644 --- a/src/fireedge/src/client/features/OneApi/serviceTemplate.js +++ b/src/fireedge/src/client/features/OneApi/serviceTemplate.js @@ -191,17 +191,11 @@ const serviceTemplateApi = oneApi.injectEndpoints({ * @returns {number} Service id * @throws Fails when response isn't code 200 */ - query: (params) => { - /* - data: { - action: { - perform: 'instantiate', - params: { merge_template: data }, - }, - }, - method: PUT, - url: `/api/${SERVICE_TEMPLATE}/action/${id}`, - */ + query: ({ template, ...params }) => { + params.action = { + perform: 'instantiate', + params: { merge_template: template }, + } const name = Actions.SERVICE_TEMPLATE_ACTION const command = { name, ...Commands[name] } diff --git a/src/fireedge/src/client/models/Helper.js b/src/fireedge/src/client/models/Helper.js index 94d54a6a48..6e78c253da 100644 --- a/src/fireedge/src/client/models/Helper.js +++ b/src/fireedge/src/client/models/Helper.js @@ -20,6 +20,7 @@ import { X2jOptions, } from 'fast-xml-parser' import { DateTime, Settings } from 'luxon' +import { isEmpty } from 'lodash' import { CURRENCY, @@ -555,7 +556,7 @@ export const userInputsToObject = (userInputs) => * @returns {string[]} List of unique labels */ export const getUniqueLabels = (labels) => { - if (labels?.length < 1) { + if (labels?.length < 1 || labels === undefined || isEmpty(labels)) { return [] } diff --git a/src/fireedge/src/client/models/Service.js b/src/fireedge/src/client/models/Service.js index a2b9ecc26e..8bf125159a 100644 --- a/src/fireedge/src/client/models/Service.js +++ b/src/fireedge/src/client/models/Service.js @@ -24,3 +24,11 @@ import { Service, SERVICE_STATES, STATES } from 'client/constants' */ export const getState = ({ TEMPLATE = {} } = {}) => SERVICE_STATES[TEMPLATE?.BODY?.state] + +/** + * Returns information about Service state. + * + * @param {number} state - Role state + * @returns {STATES.StateInfo} - Service state object + */ +export const getRoleState = (state) => SERVICE_STATES?.[state] diff --git a/src/fireedge/src/client/utils/parser/index.js b/src/fireedge/src/client/utils/parser/index.js index 02870984af..f869f2e632 100644 --- a/src/fireedge/src/client/utils/parser/index.js +++ b/src/fireedge/src/client/utils/parser/index.js @@ -18,6 +18,11 @@ import parseApplicationToForm from 'client/utils/parser/parseApplicationToForm' import parseFormToApplication from 'client/utils/parser/parseFormToApplication' import parseFormToDeployApplication from 'client/utils/parser/parseFormToDeployApplication' import { parseAcl } from 'client/utils/parser/parseACL' +import { + parseNetworkString, + parseCustomInputString, +} from 'client/utils/parser/parseServiceTemplate' +import parseVmTemplateContents from 'client/utils/parser/parseVmTemplateContents' export { templateToObject, @@ -25,4 +30,7 @@ export { parseFormToApplication, parseFormToDeployApplication, parseAcl, + parseNetworkString, + parseCustomInputString, + parseVmTemplateContents, } diff --git a/src/fireedge/src/client/utils/parser/parseServiceTemplate.js b/src/fireedge/src/client/utils/parser/parseServiceTemplate.js new file mode 100644 index 0000000000..4ee4aa492b --- /dev/null +++ b/src/fireedge/src/client/utils/parser/parseServiceTemplate.js @@ -0,0 +1,141 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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. * + * ------------------------------------------------------------------------- */ + +const NETWORK_TYPE = { + template_id: 'create', + id: 'existing', + reserve_from: 'reserve', +} + +/** + * 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(':') + + const networkType = NETWORK_TYPE?.[netType] + if (parts.length < 3 || !networkType) { + return null + } + + return { + type: networkType, + name: parts[0], + description: parts[3], + ...(id && { network: id }), + ...(extra && { netextra: 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 : '') + } + + const type = Object.keys(NETWORK_TYPE).find( + (key) => NETWORK_TYPE[key] === network?.type?.toLowerCase() + ) + + const result = `M|network|${network?.description ?? ''}| |${type ?? ''}:${ + network?.network ?? '' + }:${network?.netextra ?? ''}` + + return result +} + +/** + * Parses a formatted customInputs string back into an object. + * + * @param {string} customInputsString - The formatted customInputs string to parse. + * @returns {object | null} An object with properties describing the customInputs, or null if the string is invalid. + */ +const formatCustomInputString = (customInputsString) => { + const parts = customInputsString?.split('|') + if (!parts || parts.length < 5) { + return null + } + + const [name, mandatory, type, description, rangeOrList, defaultValue] = parts + + let defaultvaluerangemin, defaultvaluerangemax, defaultvaluelist + const isRange = ['range', 'range-float'].includes(type) + const isList = ['list', 'list-multiple'].includes(type) + + if (isRange) { + ;[defaultvaluerangemin, defaultvaluerangemax] = rangeOrList + .split('..') + .map(Number) + } else if (isList) { + defaultvaluelist = rangeOrList + } + + return { + name, + mandatory: mandatory === 'M', + type, + description, + ...(isRange && { defaultvaluerangemin, defaultvaluerangemax }), + ...(isList && { defaultvaluelist }), + defaultvalue: defaultValue, + } +} + +/** + * @param {object|string} attribute - User input + * @param {boolean} reverse - Reverse formatting? + * @returns {object|string} - Depending on reverse flag + */ +export const parseCustomInputString = (attribute, reverse = false) => { + if (reverse) { + const res = formatCustomInputString(attribute) + + return res + } + + const { + mandatory, + type, + description, + defaultvaluerangemin, + defaultvaluerangemax, + defaultvaluelist, + defaultvalue, + } = attribute + + const isList = ['list', 'list-multiple'].includes(type) + const isRange = ['range', 'range-float'].includes(type) + + return `${mandatory !== 'NO' ? 'M' : '0'}|${type ?? ''}|${ + description ?? '' + }|${ + (isRange + ? `${defaultvaluerangemin}..${defaultvaluerangemax}` + : isList + ? `${defaultvaluelist}` + : '') ?? '' + }|${defaultvalue ?? ''}` +} diff --git a/src/fireedge/src/client/utils/parser/parseVmTemplateContents.js b/src/fireedge/src/client/utils/parser/parseVmTemplateContents.js new file mode 100644 index 0000000000..86b48c29b6 --- /dev/null +++ b/src/fireedge/src/client/utils/parser/parseVmTemplateContents.js @@ -0,0 +1,163 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, 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 no-useless-escape */ +const formatNic = (nic, parent) => { + const [[NIC, NETWORK_ID]] = Object.entries(nic) + + return `${ + parent ? 'NIC_ALIAS' : 'NIC' + } = [\n NAME = \"${NIC}\",\n NETWORK_ID = \"$${ + NETWORK_ID !== undefined ? NETWORK_ID.toLowerCase() : '' + }\"${parent ? `,\n PARENT = \"${parent}\"` : ''} ]\n` +} + +const formatAlias = (fNics) => { + fNics?.map((fnic) => { + if (fnic?.alias) { + const parent = fNics?.find( + (nic) => nic?.NIC_NAME === fnic?.alias?.name + )?.NIC_ID + fnic.formatNic = formatNic({ [fnic?.NIC_ID]: fnic?.NIC_NAME }, parent) + } + + return '' + }) +} + +const formatSchedActions = (schedAction) => { + const { ACTION, TIME, DAYS, END_TYPE, END_VALUE, REPEAT, ID } = schedAction + const formattedProperties = [ + END_TYPE != null ? ` END_TYPE = \"${END_TYPE}\"` : '', + END_VALUE != null ? ` END_VALUE = \"${END_VALUE}\"` : '', + TIME != null ? ` TIME = \"${TIME}\"` : '', + ACTION != null ? ` ACTION = \"${ACTION}\"` : '', + ID != null ? ` ID = \"${ID}\"` : '', + DAYS != null ? ` DAYS = \"${DAYS}\"` : '', + REPEAT != null ? ` REPEAT = \"${REPEAT}\"` : '', + ] + .filter((line) => line) + .join(`,\n`) + + return ` SCHED_ACTION = [\n${formattedProperties} ]\n` +} +/* eslint-enable no-useless-escape */ + +const parseProperties = (section) => { + const properties = {} + const regex = /(\w+)\s*=\s*"([^"]*)"/g + let match + while ((match = regex.exec(section))) { + properties[match[1]] = match[2] + } + + return properties +} + +const parseSection = (section) => { + const headerMatch = section.match(/^(NIC|NIC_ALIAS|SCHED_ACTION)/) + if (!headerMatch) return null + + const header = headerMatch[0] + const content = parseProperties(section) + + return { header, content } +} + +const formatInstantiate = (contents) => { + const { vmTemplateContents, customAttrsValues } = contents + + const formatUserInputs = Object.entries(customAttrsValues) + ?.map(([input, value]) => `${input.toLowerCase()} = "${value}"`) + ?.join('\n') + ?.concat('\n') + + return vmTemplateContents + formatUserInputs +} + +/** + * @param {object} contents - Vm template contents + * @param {boolean} reverse - Reverse Vm template string? + * @param {boolean} instantiate - Instantiate dialog + * @returns {string} - Formatted Vm template content + */ +const formatVmTemplateContents = ( + contents, + reverse = false, + instantiate = false +) => { + if (!contents) { + return '' + } + + if (instantiate) { + return formatInstantiate(contents) + } + + if (reverse) { + const nics = [] + const schedActions = [] + const sections = contents.match( + /(NIC_ALIAS|NIC|SCHED_ACTION)\s*=\s*\[[^\]]+\]/g + ) + + if (!sections) return { networks: nics, schedActions } + + sections.forEach((section) => { + const parsedSection = parseSection(section) + if (!parsedSection) return + + const { header, content } = parsedSection + if (header === 'NIC' || header === 'NIC_ALIAS') { + nics.push(content) + } else if (header === 'SCHED_ACTION') { + schedActions.push(content) + } + }) + + return { networks: nics, schedActions } + } else { + const { networks, schedActions } = contents + if (!networks) { + return '' + } + + const formattedActions = schedActions?.map((action, index) => + formatSchedActions({ ...action, ID: index }) + ) + const formattedNics = networks + ?.filter((net) => net?.rowSelected) + ?.map((nic, index) => ({ + formatNic: formatNic({ + [`_NIC${index}`]: nic?.name, + }), + NIC_ID: `_NIC${index}`, + NIC_NAME: nic?.name, + ...(nic?.aliasIdx !== -1 && { alias: networks?.[nic?.aliasIdx] }), + })) + + formatAlias(formattedNics) + + const vmTemplateContents = formattedNics + ?.map((nic) => nic.formatNic) + .join('') + .concat(formattedActions?.join('') ?? '') + + return vmTemplateContents + } +} + +export default formatVmTemplateContents diff --git a/src/fireedge/src/server/routes/api/oneflow/index.js b/src/fireedge/src/server/routes/api/oneflow/index.js index 977c54ca40..5177eb0902 100644 --- a/src/fireedge/src/server/routes/api/oneflow/index.js +++ b/src/fireedge/src/server/routes/api/oneflow/index.js @@ -24,6 +24,7 @@ const { serviceAddAction, serviceAddScale, serviceAddRoleAction, + serviceAddRole, serviceAddSchedAction, serviceUpdateSchedAction, serviceDeleteSchedAction, @@ -45,6 +46,7 @@ const { SERVICE_ADD_ACTION, SERVICE_ADD_SCALE, SERVICE_ADD_ROLEACTION, + SERVICE_ADD_ROLE, SERVICE_ADD_SCHEDACTION, SERVICE_UPDATE_SCHEDACTION, SERVICE_DELETE_SCHEDACTION, @@ -72,6 +74,10 @@ const services = [ ...CommandsService[SERVICE_ADD_SCALE], action: serviceAddScale, }, + { + ...CommandsService[SERVICE_ADD_ROLE], + action: serviceAddRole, + }, { ...CommandsService[SERVICE_ADD_ROLEACTION], action: serviceAddRoleAction, diff --git a/src/fireedge/src/server/routes/api/oneflow/schemas.js b/src/fireedge/src/server/routes/api/oneflow/schemas.js index 031534b7f0..fc7919f1fa 100644 --- a/src/fireedge/src/server/routes/api/oneflow/schemas.js +++ b/src/fireedge/src/server/routes/api/oneflow/schemas.js @@ -135,6 +135,23 @@ const role = { }, } +const action = { + id: '/Action', + type: 'object', + properties: { + perform: { + type: 'string', + required: false, + }, + // Not required for some actions + params: { + type: 'object', + additionalProperties: true, + required: false, + }, + }, +} + const service = { type: 'object', properties: { @@ -191,6 +208,6 @@ const service = { }, } -const schemas = { role, service } +const schemas = { role, service, action } module.exports = schemas diff --git a/src/fireedge/src/server/routes/api/oneflow/service/functions.js b/src/fireedge/src/server/routes/api/oneflow/service/functions.js index c410027815..659bf41a79 100644 --- a/src/fireedge/src/server/routes/api/oneflow/service/functions.js +++ b/src/fireedge/src/server/routes/api/oneflow/service/functions.js @@ -185,11 +185,12 @@ const serviceAddAction = ( // validate if "action" is required const config = { method: POST, - path: '/service/{0}/action', + path: `/service/{0}/action`, user, password, request: params.id, - post: postAction, + // the oneflow server parses and looks for the action key + post: { action: postAction }, } oneFlowConnection( config, @@ -271,6 +272,64 @@ const serviceAddScale = ( } } +/** + * Add service role action. + * + * @param {object} res - http response + * @param {Function} next - express stepper + * @param {object} params - params + * @param {number} params.id - service ID + * @param {string} params.action - service action + * @param {string} params.role - service role + * @param {object} userData - user data + * @param {string} userData.user - username + * @param {string} userData.password - user password + */ +const serviceAddRole = ( + res = {}, + next = defaultEmptyFunction, + params = {}, + userData = {} +) => { + const { user, password } = userData + const { id, action: serviceAction } = params + if (Number.isInteger(parseInt(id, 10)) && serviceAction && user && password) { + const v = new Validator() + const postAction = parsePostData(serviceAction) + const valSchema = v.validate(postAction, action) + if (valSchema.valid) { + // validate if "action" is required + const config = { + method: POST, + path: '/service/{0}/role_action', + user, + password, + request: id, + post: { action: postAction }, + } + oneFlowConnection( + config, + (data) => success(next, res, data), + (data) => error(next, res, data) + ) + } else { + res.locals.httpCode = httpResponse( + internalServerError, + '', + `invalid schema ${returnSchemaError(valSchema.errors)}` + ) + next() + } + } else { + res.locals.httpCode = httpResponse( + methodNotAllowed, + '', + 'invalid action, id service or role' + ) + next() + } +} + /** * Add service role action. * @@ -306,11 +365,11 @@ const serviceAddRoleAction = ( // validate if "action" is required const config = { method: POST, - path: '/service/{0}/role/{1}', + path: '/service/{0}/role/{1}/action', user, password, request: [id, role], - post: postAction, + post: { action: postAction }, } oneFlowConnection( config, @@ -610,6 +669,7 @@ const serviceApi = { serviceDelete, serviceAddAction, serviceAddScale, + serviceAddRole, serviceAddRoleAction, serviceAddSchedAction, serviceUpdateSchedAction, diff --git a/src/fireedge/src/server/routes/api/oneflow/service/routes.js b/src/fireedge/src/server/routes/api/oneflow/service/routes.js index ebfbce28ad..53642f991c 100644 --- a/src/fireedge/src/server/routes/api/oneflow/service/routes.js +++ b/src/fireedge/src/server/routes/api/oneflow/service/routes.js @@ -27,6 +27,7 @@ const SERVICE_SHOW = 'service.show' const SERVICE_ADD_ACTION = 'service.addaction' const SERVICE_ADD_SCALE = 'service.addscale' const SERVICE_ADD_ROLEACTION = 'service.addroleaction' +const SERVICE_ADD_ROLE = 'service.addrole' const SERVICE_ADD_SCHEDACTION = 'service.addscheaction' const SERVICE_UPDATE_SCHEDACTION = 'service.updateschedaction' const SERVICE_DELETE_SCHEDACTION = 'service.deleteschedaction' @@ -36,6 +37,7 @@ const Actions = { SERVICE_SHOW, SERVICE_ADD_ACTION, SERVICE_ADD_SCALE, + SERVICE_ADD_ROLE, SERVICE_ADD_ROLEACTION, SERVICE_ADD_SCHEDACTION, SERVICE_UPDATE_SCHEDACTION, @@ -70,7 +72,7 @@ module.exports = { }, }, [SERVICE_ADD_SCALE]: { - path: `${basepath}/scale/:id`, + path: `${basepath}/:id/scale`, httpMethod: POST, auth: true, params: { @@ -82,8 +84,23 @@ module.exports = { }, }, }, + + [SERVICE_ADD_ROLE]: { + path: `${basepath}/:id/role_action`, + httpMethod: POST, + auth: true, + params: { + id: { + from: resource, + }, + action: { + from: postBody, + }, + }, + }, + [SERVICE_ADD_ROLEACTION]: { - path: `${basepath}/role_action/:id/:role`, + path: `${basepath}/:id/role/:role/action`, httpMethod: POST, auth: true, params: { diff --git a/src/fireedge/src/server/routes/api/oneflow/template/functions.js b/src/fireedge/src/server/routes/api/oneflow/template/functions.js index 6ded7cfb70..e01e0b049c 100644 --- a/src/fireedge/src/server/routes/api/oneflow/template/functions.js +++ b/src/fireedge/src/server/routes/api/oneflow/template/functions.js @@ -64,7 +64,7 @@ const error = (next = defaultEmptyFunction, res = {}, data = '') => { ) { res.locals.httpCode = httpResponse( internalServerError, - data && data.message + data && data?.response?.data ) next() } @@ -159,37 +159,60 @@ const serviceTemplateCreate = ( params = {}, userData = {} ) => { - const { user, password } = userData - if (params && params.template && user && password) { - const v = new Validator() - const template = parsePostData(params.template) - v.addSchema(role, '/Role') - const valSchema = v.validate(template, service) - if (valSchema.valid) { - const config = { - method: POST, - path: '/service_template', - user, - password, - post: template, + try { + const { user, password } = userData + + if (params && params.template && user && password) { + const v = new Validator() + const template = parsePostData(params.template) + + v.addSchema(role, '/Role') + const valSchema = v.validate(template, service) + + if (valSchema.valid) { + try { + const config = { + method: POST, + path: '/service_template', + user, + password, + post: template, + } + oneFlowConnection( + config, + (data) => success(next, res, data), + (data) => error(next, res, data) + ) + } catch (err) { + res.locals.httpCode = httpResponse( + internalServerError, + 'Error in service template creation', + `Unexpected error occurred: ${error.message}` + ) + } + } else { + res.locals.httpCode = httpResponse( + internalServerError, + 'Invalid schema', + `Invalid schema: ${returnSchemaError( + valSchema.errors + )}, Received template: ${JSON.stringify(template)}` + ) + next() } - oneFlowConnection( - config, - (data) => success(next, res, data), - (data) => error(next, res, data) - ) } else { res.locals.httpCode = httpResponse( - internalServerError, - '', - `invalid schema ${returnSchemaError(valSchema.errors)}` + methodNotAllowed, + 'Invalid service json', + `Invalid service json: Received params: ${JSON.stringify(params)}` ) + next() } - } else { + } catch (error) { res.locals.httpCode = httpResponse( - methodNotAllowed, - '', - 'invalid service json' + internalServerError, + 'Error in service template creation', + `Unexpected error occurred: ${error.message}` ) next() } diff --git a/src/flow/oneflow-server.rb b/src/flow/oneflow-server.rb index b2a581c3a5..d72b39596b 100644 --- a/src/flow/oneflow-server.rb +++ b/src/flow/oneflow-server.rb @@ -469,7 +469,8 @@ post '/service/:id/role_action' do when 'add_role' begin # Check that the JSON is valid - json_template = JSON.parse(opts['role']) + # Use directly if already parsed + json_template = opts['role'].is_a?(Hash) ? opts['role'] : JSON.parse(opts['role']) # Check the schema of the new template ServiceTemplate.validate_role(json_template)