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

F OpenNebula/one#5833: Add oneflow tabs to 6.8 (#2927)

Signed-off-by: Victor Hansson <vhansson@opennebula.io>
This commit is contained in:
vichansson 2024-02-07 21:00:31 +02:00 committed by GitHub
parent 8fd860a4eb
commit f5c2da57ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 7352 additions and 253 deletions

View File

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

View File

@ -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 (
<div {...rootProps} data-cy={`service-template-${ID}`}>
<div className={classes.main}>
@ -76,10 +52,6 @@ const ServiceCard = memo(
<Typography noWrap component="span" title={description}>
{NAME}
</Typography>
<span className={classes.labels}>
<WarningIcon title={description} />
<MultipleTags tags={uniqueLabels} />
</span>
</div>
<div className={classes.caption}>
<span data-cy="id">{`#${ID}`}</span>

View File

@ -54,9 +54,9 @@ const ServiceTemplateCard = memo(
TEMPLATE: {
BODY: {
description,
labels,
networks,
roles,
labels = {},
networks = {},
roles = {},
registration_time: regTime,
} = {},
},

View File

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

View File

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

View File

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

View File

@ -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 (
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
>
<Legend title={T.AdvancedParams} />
<FormProvider {...methods}>
<Stack
direction="row"
alignItems="flex-start"
gap="0.5rem"
component="form"
>
<FormWithSchema
cy={SECTION_ID}
fields={fields}
rootProps={{ sx: { m: 0 } }}
/>
</Stack>
</FormProvider>
<Divider />
</FormControl>
)
}
export default memo(AdvancedParamsSection)

View File

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

View File

@ -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 (
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
>
<Legend title={T.UserInputs} />
<FormProvider {...methods}>
<Stack
direction="row"
alignItems="flex-start"
gap="0.5rem"
component="form"
onSubmit={methods.handleSubmit(onSubmit)}
>
<FormWithSchema
cy={'extra-customAttributes'}
fields={fields}
rootProps={{ sx: { m: 0 } }}
/>
<Button
variant="contained"
type="submit"
color="secondary"
startIcon={<AddCircledOutline />}
data-cy={'extra-customAttributes'}
sx={{ mt: '1em' }}
>
<Translate word={T.Add} />
</Button>
</Stack>
</FormProvider>
<Divider />
<List>
{customattributes?.map(
({ id, name, defaultvalue, description, mandatory, type }, index) => {
const secondaryFields = [
description && `Description: ${description}`,
defaultvalue && `Default value: ${defaultvalue}`,
type && `Type: ${type}`,
mandatory && `Mandatory: ${mandatory ? 'Yes' : 'No'}`,
].filter(Boolean)
return (
<Box
key={id}
sx={{
border: '1px solid #ccc',
borderRadius: '4px',
marginBottom: '8px',
}}
>
<ListItem
secondaryAction={
<SubmitButton
onClick={() => remove(index)}
icon={<DeleteCircledOutline />}
/>
}
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
>
<ListItemText
primary={name}
primaryTypographyProps={{ variant: 'body1' }}
secondary={secondaryFields.join(' | ')}
/>
</ListItem>
</Box>
)
}
)}
</List>
</FormControl>
)
}
CustomAttributesSection.propTypes = {
stepId: PropTypes.string,
}
export default CustomAttributesSection

View File

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

View File

@ -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(
() => (
<Stack
display="grid"
gap="1em"
sx={{ gridTemplateColumns: { sm: '1fr', md: '1fr 1fr' } }}
>
<NetworkingSection stepId={STEP_ID} />
<CustomAttributesSection stepId={STEP_ID} />
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
>
<FormWithSchema
id={STEP_ID}
legend={T.AdvancedParams}
fields={ADVANCED_PARAMS_FIELDS}
rootProps={{ sx: { m: 0 } }}
/>
<Divider />
</FormControl>
<ScheduleActionsSection />
</Stack>
),
[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

View File

@ -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 (
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
>
<Legend title={T.Networks} />
<FormProvider {...methods}>
<Stack
direction="row"
alignItems="flex-start"
gap="0.5rem"
component="form"
onSubmit={methods.handleSubmit(onSubmit)}
>
<FormWithSchema
cy={'extra-networking'}
fields={fields}
rootProps={{ sx: { m: 0 } }}
/>
<Button
variant="contained"
type="submit"
color="secondary"
startIcon={<AddCircledOutline />}
data-cy={'extra-networking'}
sx={{ mt: '1em' }}
>
<Translate word={T.Add} />
</Button>
</Stack>
</FormProvider>
<Divider />
<List>
{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 (
<Box
key={id}
sx={{
border: '1px solid #ccc',
borderRadius: '4px',
marginBottom: '8px',
}}
>
<ListItem
secondaryAction={
<SubmitButton
onClick={() => remove(index)}
icon={<DeleteCircledOutline />}
/>
}
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
>
<ListItemText
primary={name}
primaryTypographyProps={{ variant: 'body1' }}
secondary={secondaryFields.join(' | ')}
/>
</ListItem>
</Box>
)
}
)}
</List>
</FormControl>
)
}
NetworkingSection.propTypes = {
stepId: PropTypes.string,
}
export default NetworkingSection

View File

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

View File

@ -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 (
<>
<Legend title={T.ScheduledActions} />
<Box sx={{ width: '100%', gridColumn: '1 / -1' }}>
<Stack flexDirection="row" gap="1em">
<CreateSchedButton
relative
onSubmit={handleCreateAction}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
<CharterButton relative onSubmit={handleCreateCharter} />
</Stack>
<Stack
pb="1em"
display="grid"
gridTemplateColumns="repeat(auto-fit, minmax(300px, 0.5fr))"
gap="1em"
mt="1em"
>
{scheduleActions?.map((schedule, index) => {
const { ID, NAME } = schedule
const fakeValues = { ...schedule, ID: index }
return (
<ScheduleActionCard
key={ID ?? NAME}
schedule={fakeValues}
actions={
<>
<UpdateSchedButton
relative
vm={{}}
schedule={fakeValues}
onSubmit={(newAction) => handleUpdate(newAction, index)}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
<DeleteSchedButton
schedule={fakeValues}
onSubmit={() => handleRemove(index)}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
</>
}
/>
)
})}
</Stack>
<Divider />
</Box>
</>
)
}
ScheduleActionsSection.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
oneConfig: PropTypes.object,
adminGroup: PropTypes.bool,
}
export default ScheduleActionsSection

View File

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

View File

@ -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 }) => (
<FormWithSchema
id={STEP_ID}
cy={`${STEP_ID}`}
fields={[
{ ...NAME_FIELD, fieldProps: { disabled: !!isUpdate } },
DESCRIPTION_FIELD,
]}
/>
)
/**
* General Service Template configuration.
*
* @param {object} data - Service Template data
* @returns {object} General configuration step
*/
const General = (data) => {
const isUpdate = data?.ID
return {
id: STEP_ID,
label: T.General,
resolver: SCHEMA,
optionsValidate: { abortEarly: false },
content: () => Content({ isUpdate }),
}
}
General.propTypes = {
data: PropTypes.object,
setFormData: PropTypes.func,
}
Content.propTypes = { isUpdate: PropTypes.bool }
export default General

View File

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

View File

@ -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 (
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
>
<Accordion>
<AccordionSummary
aria-controls="panel-content"
id="panel-header"
sx={{
backgroundColor: palette?.background?.paper,
filter: 'brightness(90%)',
}}
>
<Typography variant="body1">{T.AdvancedParams}</Typography>
</AccordionSummary>
<AccordionDetails>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
width: '100%',
}}
>
<Box component="form" sx={{ flexGrow: 1, display: 'flex' }}>
<FormWithSchema
id={stepId}
cy={'extra-networking'}
fields={fields}
rootProps={{ sx: { m: 0 } }}
/>
</Box>
</Box>
</AccordionDetails>
</Accordion>
</FormControl>
)
}
AdvancedParametersSection.propTypes = {
selectedRoleIndex: PropTypes.number,
stepId: PropTypes.string,
roleConfigs: PropTypes.object,
onChange: PropTypes.func,
}
export default AdvancedParametersSection

View File

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

View File

@ -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 (
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
>
<Accordion>
<AccordionSummary
aria-controls="panel-content"
id="panel-header"
data-cy="roleconfig-elasticitypolicies-accordion"
sx={{
backgroundColor: palette?.background?.paper,
filter: 'brightness(90%)',
}}
>
<Typography variant="body1">{T.ElasticityPolicies}</Typography>
</AccordionSummary>
<AccordionDetails>
<FormProvider {...methods}>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
width: '100%',
}}
>
<Box
component="form"
onSubmit={methods.handleSubmit(onSubmit)}
sx={{
width: '100%',
}}
>
<FormWithSchema fields={fields} rootProps={{ sx: { m: 0 } }} />
<Button
variant="contained"
size="large"
type="submit"
color="secondary"
startIcon={<AddCircledOutline />}
data-cy={'roleconfig-elasticitypolicies'}
sx={{ width: '100%', mt: 2 }}
>
<Translate word={T.Add} />
</Button>
</Box>
</Box>
<List sx={{ mt: 2 }}>
{currentPolicies.map(
(
{
TYPE,
ADJUST,
MIN,
COOLDOWN,
PERIOD_NUMBER,
PERIOD,
EXPRESSION,
},
index
) => {
const secondaryFields = [
`Expression: ${EXPRESSION}`,
`Adjust: ${ADJUST}`,
`Cooldown: ${COOLDOWN}`,
`Period: ${PERIOD}`,
`#: ${PERIOD_NUMBER}`,
]
if (MIN !== undefined && TYPE === 'PERCENTAGE_CHANGE') {
secondaryFields.push(`Min: ${MIN}`)
}
return (
<Box
key={index}
sx={{
border: '1px solid #ccc',
borderRadius: '4px',
marginBottom: '6px',
}}
>
<ListItem
secondaryAction={
<SubmitButton
onClick={() => remove(index)}
icon={<DeleteCircledOutline />}
/>
}
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
>
<ListItemText
primary={ELASTICITY_TYPES?.[TYPE]}
primaryTypographyProps={{ variant: 'body1' }}
secondary={secondaryFields.join(' | ')}
/>
</ListItem>
</Box>
)
}
)}
</List>
</FormProvider>
</AccordionDetails>
</Accordion>
</FormControl>
)
}
ElasticityPoliciesSection.propTypes = {
stepId: PropTypes.string,
selectedRoleIndex: PropTypes.number,
}
export default ElasticityPoliciesSection

View File

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

View File

@ -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 (
<Box>
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
>
<FormWithSchema fields={fields} rootProps={{ sx: { m: 0 } }} />
</FormControl>
</Box>
)
}
MinMaxVms.propTypes = {
stepId: PropTypes.string,
selectedRoleIndex: PropTypes.number,
}
export default MinMaxVms

View File

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

View File

@ -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 (
<FormControl
component="fieldset"
sx={{ width: '100%', gridColumn: '1 / -1' }}
>
<Accordion>
<AccordionSummary
aria-controls="panel-content"
id="panel-header"
data-cy="roleconfig-scheduledpolicies-accordion"
sx={{
backgroundColor: palette?.background?.paper,
filter: 'brightness(90%)',
}}
>
<Typography variant="body1">{T.ScheduledPolicies}</Typography>
</AccordionSummary>
<AccordionDetails>
<FormProvider {...methods}>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
width: '100%',
}}
>
<Box
component="form"
onSubmit={methods.handleSubmit(onSubmit)}
sx={{ width: '100%' }}
>
<FormWithSchema fields={fields} rootProps={{ sx: { m: 0 } }} />
<Button
variant="contained"
size="large"
type="submit"
color="secondary"
startIcon={<AddCircledOutline />}
data-cy={'roleconfig-scheduledpolicies'}
sx={{ width: '100%', mt: 2 }}
>
<Translate word={T.Add} />
</Button>
</Box>
</Box>
<List>
{currentPolicies.map(
(
{ TIMEFORMAT, SCHEDTYPE, ADJUST, MIN, TIMEEXPRESSION },
index
) => {
const secondaryFields = [
`Time Expression: ${TIMEEXPRESSION}`,
`Adjust: ${ADJUST}`,
`Time Format: ${TIMEFORMAT}`,
]
if (MIN !== undefined) {
secondaryFields?.push(`Min: ${MIN}`)
}
return (
<Box
key={index}
sx={{
border: '1px solid #ccc',
borderRadius: '4px',
marginBottom: '6px',
}}
>
<ListItem
secondaryAction={
<SubmitButton
onClick={() => remove(index)}
icon={<DeleteCircledOutline />}
/>
}
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
>
<ListItemText
primary={SCHED_TYPES?.[SCHEDTYPE]}
primaryTypographyProps={{ variant: 'body1' }}
secondary={secondaryFields.join(' | ')}
/>
</ListItem>
</Box>
)
}
)}
</List>
</FormProvider>
</AccordionDetails>
</Accordion>
</FormControl>
)
}
ScheduledPoliciesSection.propTypes = {
stepId: PropTypes.string,
selectedRoleIndex: PropTypes.number,
}
export default ScheduledPoliciesSection

View File

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

View File

@ -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 = (
<Grid mt={2} container>
{!standaloneModal && (
<Grid item xs={2.2}>
<RoleColumn
roles={roles}
selectedRoleIndex={selectedRoleIndex}
setSelectedRoleIndex={setSelectedRoleIndex}
disableModify={true}
onChange={handleConfigChange}
/>
</Grid>
)}
<Grid item xs={standaloneModal ? 12 : 7}>
<Box
margin={1}
sx={{
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
}}
>
{!standaloneModal && (
<RoleNetwork
stepId={STEP_ID}
selectedRoleIndex={selectedRoleIndex}
totalRoles={roles?.length}
/>
)}
<Box mt={2}>
<Legend title={T.RoleElasticity} />
{standaloneModal && (
<Box>
<FormWithSchema
fields={[STANDALONE_NAME_FIELD, STANDALONE_CARDINALITY_FIELD]}
/>
</Box>
)}
{standaloneModal && (
<Box>
<VmTemplatesPanel
roles={[standaloneRole]}
selectedRoleIndex={selectedRoleIndex}
onChange={HANDLE_VM_SELECT_STANDALONE_ROLE}
/>
</Box>
)}
<MinMaxSection
stepId={STEP_ID}
selectedRoleIndex={selectedRoleIndex}
/>
<ElasticityPoliciesSection
stepId={STEP_ID}
selectedRoleIndex={selectedRoleIndex}
/>
<ScheduledPoliciesSection
stepId={STEP_ID}
selectedRoleIndex={selectedRoleIndex}
/>
</Box>
<Box mt={2}>
<Legend title={T.Extra} />
<AdvancedParametersSection
stepId={STEP_ID}
roleConfigs={roleConfigs.current?.[selectedRoleIndex]}
onChange={handleConfigChange}
/>
</Box>
{standaloneModal && (
<Box>
<Button
variant="contained"
size="large"
type="submit"
color="secondary"
startIcon={<AddCircledOutline />}
data-cy={'roleconfig-addrole'}
onClick={handleAddRoleClick}
sx={{ width: '100%', mt: 2 }}
>
<Translate word={T.AddRole} />
</Button>
</Box>
)}
</Box>
</Grid>
{!standaloneModal && (
<Grid item xs={2.8}>
<RoleSummary
role={roles?.[selectedRoleIndex] ?? []}
selectedRoleIndex={selectedRoleIndex}
/>
</Grid>
)}
</Grid>
)
return standaloneModal ? (
<FormProvider {...formMethods}>{ComponentContent}</FormProvider>
) : (
ComponentContent
)
}
Content.propTypes = {
standaloneModal: PropTypes.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

View File

@ -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) => (
<Checkbox
checked={params?.row?.rowSelected || false}
onChange={() => handleSelectRow(params?.row)}
inputProps={{
'data-cy': `role-config-network-${params?.row?.idx}`,
}}
/>
),
},
{
field: 'network',
disableColumnMenu: true,
flex: 1,
headerName: 'Network',
width: 150,
},
{
field: 'aliasToggle',
disableColumnMenu: true,
sortable: false,
headerName: 'NIC Alias',
width: 110,
renderCell: (params) =>
params?.row?.rowSelected && (
<Checkbox
checked={params?.row?.aliasSelected || false}
onChange={() => handleSelectAlias(params?.row)}
inputProps={{
'data-cy': `role-config-network-alias-${params?.row?.idx}`,
}}
/>
),
},
{
field: 'alias',
disableColumnMenu: true,
flex: 1,
headerName: 'Alias',
width: 200,
renderCell: (params) =>
params?.row?.aliasSelected && (
<Autocomplete
id={`role-config-network-alias-name-${params?.row?.id}`}
options={networks
?.filter((net, index) => {
const fieldArray = getValues(fieldArrayLocation)?.[index]
return (
net?.name !== params?.row?.network &&
fieldArray?.rowSelected &&
fieldArray?.aliasIdx === -1
)
})
?.map((net) => net?.name)}
renderOption={(props, option) => (
<li
{...props}
data-cy={`role-config-network-aliasname-option-${option}`}
>
{option}
</li>
)}
renderInput={(props) => <TextField {...props} label="NIC" />}
onChange={(_event, value) => handleSetAlias(params?.row, value)}
value={
getValues(fieldArrayLocation)?.[params?.row?.aliasIdx]?.name ??
null
}
data-cy={`role-config-network-aliasname-${params?.row?.idx}`}
sx={{ width: '100%', height: '100%' }}
/>
),
},
],
[networks, fieldArrayLocation]
)
return (
<Box>
<Legend title={T.RoleNetworks} />
<DataGrid
className={classes.root}
rows={fields}
columns={columns}
localeText={{ noRowsLabel: 'No networks have been defined' }}
autoHeight
rowsPerPageOptions={[5, 10, 25, 50, 100]}
disableSelectionOnClick
/>
</Box>
)
}
RoleNetwork.propTypes = {
networks: PropTypes.array,
roleConfigs: PropTypes.object,
stepId: PropTypes.string,
selectedRoleIndex: PropTypes.number,
}
export default RoleNetwork

View File

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

View File

@ -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 (
<Grid mt={2} container>
<Grid item xs={2.2}>
<RoleColumn
roles={roles}
onChange={handleChangeRoles}
selectedRoleIndex={selectedRoleIndex}
setSelectedRoleIndex={setSelectedRoleIndex}
/>
</Grid>
<Grid item xs={7}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '1em',
overflow: 'auto',
}}
>
<RoleVmVmPanel
roles={roles}
onChange={handleRoleChange}
selectedRoleIndex={selectedRoleIndex}
/>
<VmTemplatesPanel
roles={roles}
selectedRoleIndex={selectedRoleIndex}
onChange={handleRoleChange}
/>
</Box>
</Grid>
<Grid item xs={2.8}>
<RoleSummary
role={roles?.[selectedRoleIndex] ?? []}
selectedRoleIndex={selectedRoleIndex}
/>
</Grid>
</Grid>
)
}
/**
* 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

View File

@ -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 }) => (
<Card
elevation={2}
sx={{
height: '100%',
maxHeight: '630px',
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
}}
>
<CardContent
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
gap: '1em',
}}
>
<Typography variant="h6" component="div" gutterBottom>
#{selectedRoleIndex + 1 ?? 0} Role Configuration
</Typography>
<Typography
variant="body2"
color={role?.NAME ? 'text.primary' : 'text.disabled'}
gutterBottom
>
Name: {role?.NAME || 'Enter a name for this role.'}
</Typography>
<Typography
variant="body2"
color={
role?.CARDINALITY === undefined ||
role?.CARDINALITY === 'None' ||
+role?.CARDINALITY < 1
? 'text.disabled'
: 'text.primary'
}
gutterBottom
>
{T.NumberOfVms}: {role?.CARDINALITY}
</Typography>
{role?.SELECTED_VM_TEMPLATE_ID ? (
<>
<Typography
variant="body2"
color={
role?.SELECTED_VM_TEMPLATE_ID === undefined ||
role?.SELECTED_VM_TEMPLATE_ID === 'None' ||
role?.SELECTED_VM_TEMPLATE_ID?.length < 1
? 'text.disabled'
: 'text.primary'
}
gutterBottom
>
VM Template ID: {role?.SELECTED_VM_TEMPLATE_ID}
</Typography>
</>
) : (
<Typography variant="body2" color="text.disabled" gutterBottom>
Select a VM template.
</Typography>
)}
<Divider />
<Typography
variant="body2"
color={role?.NETWORKS ? 'text.primary' : 'text.disabled'}
gutterBottom
>
Networks: {role?.NETWORKS || 'Select a network for this role.'}
</Typography>
<Typography color={'text.primary'} sx={{ fontSize: 16 }} gutterBottom>
Role Elasticity
</Typography>
<Typography
variant="body2"
color={role?.MINVMS ? 'text.primary' : 'text.disabled'}
gutterBottom
>
Min VMs:
{role?.MINVMS || ' Minimum number of VMs for elasticity adjustments.'}
</Typography>
<Typography
variant="body2"
color={role?.MAXVMS ? 'text.primary' : 'text.disabled'}
gutterBottom
>
Max VMs:
{role?.MAXVMS || ' Maximum number of VMs for elasticity adjustments.'}
</Typography>
<Typography
variant="body2"
color={role?.MAXVMS ? 'text.primary' : 'text.disabled'}
gutterBottom
>
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.'}
</Typography>
<Typography
color={role?.ELASTICITYPOLICIES ? 'text.primary' : 'text.disabled'}
sx={{ fontSize: 14 }}
gutterBottom
>
Elasticity Policies
</Typography>
<Typography
variant="body2"
color={
role?.ELASTICITYPOLICIES?.TYPE ? 'text.primary' : 'text.disabled'
}
gutterBottom
>
Type:
{role?.ELASTICITYPOLICIES?.TYPE || ' Adjustment type'}
</Typography>
<Typography
variant="body2"
color={
role?.ELASTICITYPOLICIES?.ADJUST ? 'text.primary' : 'text.disabled'
}
gutterBottom
>
Adjust:
{role?.ELASTICITYPOLICIES?.ADJUST || ' Positive or negative adjustment'}
</Typography>
</CardContent>
<CardActions sx={{ p: 2, pt: 0 }}>
<Typography
variant="subtitle2"
color="textSecondary"
sx={{ opacity: 0.7 }}
>
<strong>VM Group Configuration:</strong>
<ul>
<li>Define roles and placement constraints.</li>
<li>Optimize performance and fault tolerance.</li>
<li>Manage multi-VM applications efficiently.</li>
</ul>
</Typography>
</CardActions>
</Card>
)
RoleSummary.propTypes = {
role: PropTypes.oneOfType([
PropTypes.shape({
NAME: PropTypes.string,
POLICY: PropTypes.oneOf(['AFFINED', 'ANTI_AFFINED', 'None', undefined]),
HOST_AFFINED: PropTypes.arrayOf(PropTypes.number),
HOST_ANTI_AFFINED: PropTypes.arrayOf(PropTypes.number),
}),
PropTypes.array,
PropTypes.object,
]),
selectedRoleIndex: PropTypes.number,
onRemoveAffinity: PropTypes.func.isRequired,
}
export default RoleSummary

View File

@ -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 (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
pt: 2,
height: '100%',
maxHeight: '630px',
}}
>
<Box
sx={{
borderRight: 1,
pr: 2,
width: '100%',
}}
>
{!disableModify && (
<Button
variant="contained"
color="primary"
onClick={handleAddRole}
size="large"
data-cy="add-role"
>
Add Role
</Button>
)}
<Box
sx={{
maxHeight: '90%',
overflowY: 'auto',
}}
>
<List>
{Array.isArray(roles) &&
roles.length > 0 &&
roles.map((role, index) => (
<ListItem
button
selected={index === selectedRoleIndex}
onClick={() => setSelectedRoleIndex(index)}
key={index}
sx={{
my: 0.5,
minHeight: '43.5px',
border: '1px solid',
borderColor: 'divider',
borderRadius: '4px',
overflowX: 'hidden',
bgcolor:
index === selectedRoleIndex
? 'action.selected'
: 'inherit',
'&.Mui-selected, &.Mui-selected:hover': {
bgcolor: 'action.selected',
},
'&:hover': {
bgcolor: 'action.hover',
},
}}
data-cy={`role-column-${index}`}
>
{!disableModify && (
<IconButton
aria-label="delete"
onClick={(event) => {
event.stopPropagation()
handleRemoveRole(index)
}}
data-cy={`delete-role-${index}`}
sx={{ mr: 1.5 }}
>
<Cancel />
</IconButton>
)}
<div
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{role?.NAME || 'New Role'}
</div>
</ListItem>
))}
</List>
</Box>
</Box>
</Box>
)
}
RoleColumn.propTypes = {
roles: PropTypes.arrayOf(PropTypes.object).isRequired,
onChange: PropTypes.func.isRequired,
selectedRoleIndex: PropTypes.number,
setSelectedRoleIndex: PropTypes.func.isRequired,
disableModify: PropTypes.bool,
}
RoleColumn.defaultProps = {
roles: [],
}
export default RoleColumn

View File

@ -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 (
<Box p={2}>
<Box sx={{ flex: 1 }}>
<Typography variant="h6">Role Details</Typography>
<Box sx={{ mb: 2 }}>
<TextField
label="Role Name"
name="NAME"
value={roles?.[selectedRoleIndex]?.NAME ?? ''}
onChange={handleInputChange}
disabled={!roles?.[selectedRoleIndex]}
inputProps={{ 'data-cy': `role-name-${selectedRoleIndex}` }}
fullWidth
/>
</Box>
<Box sx={{ mb: 2 }}>
<TextField
type="number"
label={T.NumberOfVms}
value={roles?.[selectedRoleIndex]?.CARDINALITY ?? 1}
onChange={handleInputChange}
name="CARDINALITY"
InputProps={{
inputProps: {
min: 0,
'data-cy': `role-cardinality-${selectedRoleIndex}`,
},
}}
fullWidth
/>
</Box>
</Box>
</Box>
)
}
RoleVmVmPanel.propTypes = {
roles: PropTypes.arrayOf(
PropTypes.shape({
NAME: PropTypes.string,
CARDINALITY: PropTypes.number,
})
),
onChange: PropTypes.func.isRequired,
selectedRoleIndex: PropTypes.number,
}
export default RoleVmVmPanel

View File

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

View File

@ -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 (
<Box
sx={{
my: 2,
p: 2,
borderRadius: 2,
maxHeight: '40%',
}}
>
<Typography variant="h6" gutterBottom>
VM Templates
</Typography>
<Paper sx={{ overflow: 'auto', marginBottom: 2 }}>
<Table>
<TableHead>
<TableRow>
<TableCell padding="checkbox"></TableCell>
<TableCell>ID</TableCell>
<TableCell>Name</TableCell>
<TableCell>Owner</TableCell>
<TableCell>Group</TableCell>
<TableCell>Registration time</TableCell>
</TableRow>
</TableHead>
<TableBody>
{vmTemplates
?.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
?.map((vmTemplate) => (
<TableRow
key={vmTemplate.ID}
hover
sx={{
'&:hover': {
filter: 'brightness(85%)',
cursor: 'pointer',
},
}}
onClick={() =>
onChange({
...roles[selectedRoleIndex],
SELECTED_VM_TEMPLATE_ID: [vmTemplate.ID],
})
}
name="SELECTED_VM_TEMPLATE_ID"
role="checkbox"
aria-checked={templateID.includes(vmTemplate.ID)}
style={{
backgroundColor: templateID?.includes(vmTemplate.ID)
? theme?.palette?.action?.selected
: theme?.palette.action?.disabledBackground,
}}
data-cy={`role-vmtemplate-${vmTemplate.ID}`}
tabIndex={-1}
>
<TableCell padding="checkbox">
<Checkbox checked={templateID.includes(vmTemplate.ID)} />
</TableCell>
<TableCell>{vmTemplate.ID}</TableCell>
<TableCell>{vmTemplate.NAME}</TableCell>
<TableCell>{vmTemplate.UNAME}</TableCell>
<TableCell>{vmTemplate.GNAME}</TableCell>
<TableCell>
{convertTimestampToDate(vmTemplate.REGTIME)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
<TablePagination
rowsPerPageOptions={[10, 25, 50]}
component="div"
count={vmTemplates?.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</Box>
)
}
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

View File

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

View File

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

View File

@ -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 (
<Box mt={2}>
<Legend title={'Add Charters Values Configuration'} />
<Box sx={{ width: '100%', gridColumn: '1 / -1' }}>
<Stack flexDirection="row" gap="1em">
<CreateSchedButton
relative
onSubmit={handleCreateAction}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
<CharterButton relative onSubmit={handleCreateCharter} />
</Stack>
<Stack
pb="1em"
display="grid"
gridTemplateColumns="repeat(auto-fit, minmax(300px, 0.5fr))"
gap="1em"
mt="1em"
>
{scheduleActions?.map((schedule, index) => {
const { ID, NAME } = schedule
const fakeValues = { ...schedule, ID: index }
return (
<ScheduleActionCard
key={ID ?? NAME}
schedule={fakeValues}
actions={
<>
<UpdateSchedButton
relative
vm={{}}
schedule={fakeValues}
onSubmit={(newAction) => handleUpdate(newAction, index)}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
<DeleteSchedButton
schedule={fakeValues}
onSubmit={() => handleRemove(index)}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
</>
}
/>
)
})}
</Stack>
<Divider />
</Box>
</Box>
)
}
ScheduleActionsSection.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
oneConfig: PropTypes.object,
adminGroup: PropTypes.bool,
}
export const STEP_ID = 'charter'
const Content = () => useMemo(() => <ScheduleActionsSection />, [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

View File

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

View File

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

View File

@ -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 (
<Box>
<Grid container spacing={2}>
<Grid item xs={6}>
<TextField
select
label="Network ID"
onChange={(e) => {
setTableIndex(e.target.selectedIndex)
}}
fullWidth
InputProps={{
inputProps: { 'data-cy': `select-${STEP_ID}-id` },
}}
variant="outlined"
>
{Object.keys(networkInfo).map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</TextField>
</Grid>
<Grid item xs={6}>
<TextField
select
label="Network Type"
value={tableType}
onChange={(e) => setTableType(e.target.value)}
fullWidth
InputProps={{
inputProps: { 'data-cy': `select-${STEP_ID}-type` },
}}
variant="outlined"
>
{Object.entries(NETWORK_TYPES).map(([key, value]) => (
<option key={key} value={key}>
{value}
</option>
))}
</TextField>
</Grid>
</Grid>
<FormWithSchema cy={`${STEP_ID}`} fields={fields} />
</Box>
)
}
Content.propTypes = {
dataTemplate: PropTypes.object,
isUpdate: PropTypes.bool,
}
/**
* @param {object} template - Service Template
* @returns {object} - Step
*/
const Network = (template) => ({
id: STEP_ID,
label: T.Network,
resolver: createNetworkSchema(),
optionsValidate: { abortEarly: false },
defaultDisabled: {
condition: () => {
const disableStep = !template?.dataTemplate?.TEMPLATE?.BODY?.networks
return disableStep
},
},
content: () => Content(template),
})
Network.propTypes = {
data: PropTypes.object,
setFormData: PropTypes.func,
}
export default Network

View File

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

View File

@ -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 (
<Box>
<FormWithSchema
id={STEP_ID}
cy={`${STEP_ID}`}
fields={fieldsForCurrentPage}
/>
<Stack spacing={2} alignItems="center" mt={2}>
<Pagination
count={pageCount}
page={currentPage}
onChange={handlePageChange}
/>
</Stack>
</Box>
)
}
/**
* 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<Typography
key={`service-template-${id}`}
variant="inherit"
component="span"
display="block"
>
{`#${ID} ${NAME}`}
</Typography>
)
})
const SubHeader = (rows) => <ListServiceTemplateNames rows={rows} />
const MessageToConfirmAction = (rows, description) => (
<>
<ListServiceTemplateNames rows={rows} />
{description && <Translate word={description} />}
<Translate word={T.DoYouWantProceed} />
</>
)
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

View File

@ -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 (
<Typography
key={`vm-template-${id}`}
variant="inherit"
component="span"
display="block"
>
{`#${ID} ${NAME}`}
</Typography>
)
})
const SubHeader = (rows) => <ListVmTemplateNames rows={rows} />
const MessageToConfirmAction = (rows, description) => (
<>
<ListVmTemplateNames rows={rows} />
{description && <Translate word={description} />}
<Translate word={T.DoYouWantProceed} />
</>
)
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 (
<ServiceTemplatesTable
disableGlobalSort
disableRowSelect
classes={classes}
onRowClick={redirectToInstantiate}
/>
)
},
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

View File

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

View File

@ -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 (
<Box>
{options?.button?.type === 'icon' ? (
<IconButton
aria-controls="customized-menu"
aria-haspopup="true"
variant="contained"
color="primary"
onClick={handleClick}
{...options?.button}
sx={{
...options?.button?.sx,
}}
>
{options?.button?.icon ?? <NavArrowDown />}
</IconButton>
) : (
<Button
aria-controls="customized-menu"
aria-haspopup="true"
variant="contained"
color="primary"
onClick={handleClick}
endIcon={items.length > 1 ? <NavArrowDown /> : null}
{...options?.button}
>
{options?.button?.title || ''}
</Button>
)}
<Menu
id="customized-menu"
anchorEl={anchorEl}
keepMounted
open={open}
onClose={handleClose}
{...options?.menu}
>
{items.map(({ name, onClick }, index) => (
<MenuItem
key={index}
onClick={() => {
onClick()
handleClose()
}}
{...options?.menuItem}
>
{name}
</MenuItem>
))}
</Menu>
</Box>
)
} else {
return options?.singleButton?.type === 'icon' ? (
<IconButton
aria-controls="customized-menu"
aria-haspopup="true"
variant="contained"
color="primary"
onClick={handleClick}
{...options?.button}
sx={{
...options?.singleButton?.sx,
}}
>
{options?.singleButton?.icon ?? <NavArrowDown />}
</IconButton>
) : (
<Button
variant="contained"
color="primary"
onClick={(event) => handleClick(event, items.onClick)}
startIcon={items.icon ? <items.icon /> : null}
{...options?.singleButton}
sx={{
...options?.singleButton?.sx,
}}
>
{options?.singleButton?.title || items.name || ''}
</Button>
)
}
}
ButtonGenerator.propTypes = {
items: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
options: PropTypes.object,
}

View File

@ -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 (
<Stack
display="grid"
@ -58,6 +101,7 @@ const ServiceInfoTab = ({ tabProps = {}, id }) => {
{permissionsPanel?.enabled && (
<Permissions
actions={getActions(permissionsPanel?.actions)}
handleEdit={handleChangePermission}
ownerUse={PERMISSIONS.OWNER_U}
ownerManage={PERMISSIONS.OWNER_M}
ownerAdmin={PERMISSIONS.OWNER_A}
@ -72,6 +116,7 @@ const ServiceInfoTab = ({ tabProps = {}, id }) => {
{ownershipPanel?.enabled && (
<Ownership
actions={getActions(ownershipPanel?.actions)}
handleEdit={handleChangeOwnership}
userId={UID}
userName={UNAME}
groupId={GID}

View File

@ -21,7 +21,8 @@ import { List } from 'client/components/Tabs/Common'
import { StatusCircle, StatusChip } from 'client/components/Status'
import { getState } from 'client/models/Service'
import { timeToString, booleanToString } from 'client/models/Helper'
import { T, Service } from 'client/constants'
import { T, Service, VM_TEMPLATE_ACTIONS } from 'client/constants'
import { useServiceAddActionMutation } from 'client/features/OneApi/service'
/**
* Renders mainly information tab.
@ -32,6 +33,7 @@ import { T, Service } from 'client/constants'
* @returns {ReactElement} Information tab
*/
const InformationPanel = ({ service = {}, actions }) => {
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: (

View File

@ -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 (
<Stack gap="0.5em" p="1em" bgcolor="background.default">
{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 (
<Typography
key={`message-${timestamp}`}
noWrap
variant="body2"
color={isError ? 'error' : 'textPrimary'}
const sortedLogs = useMemo(() => {
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 (
<Box
sx={{
p: '1em',
height: '100%',
width: '100%',
}}
>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={2}
mb={2}
alignItems="center"
>
<TextField
fullWidth
label="Search"
variant="outlined"
size="small"
onChange={(e) => setFilter(e.target.value)}
/>
<FormControl fullWidth size="small">
<InputLabel id="severity-select-label">Severity</InputLabel>
<Select
labelId="severity-select-label"
label="Severity"
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value)}
>
{`${time.toFormat('ff')} [${severity}] ${message}`}
</Typography>
)
})}
</Stack>
<MenuItem value="ALL">All Severities</MenuItem>
{Object.entries(severityDisplayNames).map(([key, name]) => (
<MenuItem key={key} value={key}>
{name}
</MenuItem>
))}
</Select>
</FormControl>
<ButtonGroup size="small" variant="outlined">
{Object.entries(sortOptions).map(([key, name]) => (
<Button
key={key}
onClick={() => handleSortChange(key)}
variant={sortType === key ? 'contained' : 'outlined'}
>
{name}
</Button>
))}
</ButtonGroup>
<FormControlLabel
control={
<Switch
checked={sortAscending}
onChange={() => setSortAscending(!sortAscending)}
/>
}
label={sortAscending ? 'Asc' : 'Desc'}
/>
</Stack>
<Box
sx={{
p: 3,
bgcolor: 'background.paper',
boxShadow: 2,
borderRadius: '4px',
width: '100%',
height: '100%',
maxHeight: '85%',
overflow: 'hidden',
}}
>
<Stack
spacing={1}
sx={{
overflowY: 'auto',
maxHeight: 'calc(100% - 70px)',
}}
>
{paginatedLogs.map(({ severity, message, timestamp }, index) => {
const time = timeFromMilliseconds(+timestamp)
return (
<Typography
key={`message-${timestamp}-${index}`}
variant="body2"
sx={{
color:
severity === SERVICE_LOG_SEVERITY.ERROR
? 'error.dark'
: severity === SERVICE_LOG_SEVERITY.INFO
? 'info.dark'
: 'text.primary',
fontWeight:
severity === SERVICE_LOG_SEVERITY.ERROR ? 'bold' : 'normal',
backgroundColor:
severity === SERVICE_LOG_SEVERITY.ERROR
? 'error.light'
: severity === SERVICE_LOG_SEVERITY.INFO
? 'info.light'
: 'success.light',
p: 1,
borderRadius: '4px',
}}
>
{`${time.toFormat('ff')} [${
severityDisplayNames[severity]
}] ${message}`}
</Typography>
)
})}
</Stack>
</Box>
<Pagination
sx={{ mt: 2, mb: 2 }}
count={Math.ceil(filteredLogs.length / PAGE_SIZE)}
page={page}
onChange={(_e, value) => setPage(value)}
variant="outlined"
shape="rounded"
size="medium"
/>
</Box>
)
}
LogTab.propTypes = { tabProps: PropTypes.object, id: PropTypes.string }
LogTab.displayName = 'RolesTab'
LogTab.displayName = 'Roles'
export default LogTab

View File

@ -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 }) => (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<RoleAddDialog
standaloneModal
standaloneModalCallback={(params) => {
handleAddRole(params)
onClose()
}}
/>
</Dialog>
)
/* 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 (
<Box
display="grid"
gridTemplateColumns="repeat(4, 1fr)"
padding="1em"
bgcolor="background.default"
>
{COLUMNS.map((col) => (
<Typography key={col} noWrap variant="subtitle1" padding="0.5em">
<Translate word={col} />
</Typography>
))}
<Box display="flex" flexDirection="column" padding="1em" width="100%">
<Box
display="flex"
flexDirection="row"
alignItems="stretch"
justifyContent="space-between"
width="100%"
marginBottom="2em"
>
<Box display="flex" gap="2em" marginRight="2em">
<>
<ButtonGenerator
items={{
name: 'Add Role',
onClick: handleOpenAddRole,
icon: Plus,
}}
options={{
singleButton: {
sx: {
fontSize: '0.95rem',
padding: '6px 8px',
minWidth: '80px',
minHeight: '30px',
maxHeight: '40px',
whiteSpace: 'nowrap',
},
'data-cy': 'AddRole',
},
}}
/>
<AddRoleDialog open={isAddRoleOpen} onClose={handleCloseAddRole} />
</>
<ButtonGenerator
items={{
name: 'Scale',
onClick: handleOpenScale,
icon: Plus,
}}
options={{
singleButton: {
disabled: !selectedRoles?.length > 0,
sx: {
fontSize: '0.95rem',
padding: '6px 12px',
minWidth: '80px',
minHeight: '30px',
maxHeight: '40px',
},
},
}}
/>
<ScaleDialog
open={isScaleDialogOpen}
onClose={handleCloseScale}
onScale={handleScaleRole}
roleName={roles?.[selectedRoles?.[0]]?.name}
/>
</Box>
<Box display="flex" gap="1em">
<ButtonGenerator
items={{
onClick: () => handleAddRoleAction('resume'),
}}
options={{
singleButton: {
disabled: !selectedRoles?.length > 0,
startIcon: <PlayOutline />,
sx: {
fontSize: 20,
padding: '0px 8px',
},
title: null,
},
}}
/>
<ButtonGenerator
items={[
{
name: 'Suspend',
onClick: () =>
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: <SystemShut />,
endIcon: <NavArrowDown />,
sx: {
fontSize: 20,
padding: '8px 16px',
},
title: null,
},
}}
/>
<ButtonGenerator
items={[
{
name: 'Stop',
onClick: () => handleAddRoleAction('stop'),
},
{
name: 'Undeploy',
onClick: () => handleAddRoleAction('undeploy'),
},
{
name: 'Undeploy Hard',
onClick: () => handleAddRoleAction('undeploy-hard'),
},
]}
options={{
button: {
disabled: !selectedRoles?.length > 0,
startIcon: <TransitionRight />,
endIcon: <NavArrowDown />,
sx: {
fontSize: 20,
padding: '8px 16px',
},
title: null,
},
}}
/>
<ButtonGenerator
items={[
{
name: 'Reboot',
onClick: () => handleAddRoleAction('reboot'),
},
{
name: 'Reboot Hard',
onClick: () => handleAddRoleAction('reboot-hard'),
},
]}
options={{
button: {
disabled: !selectedRoles?.length > 0,
startIcon: <Refresh />,
endIcon: <NavArrowDown />,
sx: {
fontSize: 20,
padding: '8px 16px',
marginRight: '1em',
},
title: null,
},
}}
/>
<ButtonGenerator
items={[
{
name: 'Terminate',
onClick: () => handleAddRoleAction('terminate'),
},
{
name: 'Terminate Hard',
onClick: () => handleAddRoleAction('terminate-hard'),
},
]}
options={{
button: {
disabled: !selectedRoles?.length > 0,
startIcon: <Trash />,
endIcon: <NavArrowDown />,
sx: {
fontSize: 20,
padding: '8px 16px',
marginLeft: '2em',
},
title: null,
},
}}
/>
</Box>
</Box>
{roles.map((role, idx) => (
<Box
key={`role-${role.name ?? idx}`}
display="contents"
padding="0.5em"
// hover except for the circular progress component
sx={{ '&:hover > *: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)}
>
<RoleComponent role={role} />
<RoleComponent
role={role}
selected={isSelected(idx)}
status={role?.state}
/>
{activeRole.idx === idx && (
<Box
padding="20px"
marginLeft="20px"
paddingTop="10px"
border="1px solid rgba(0, 0, 0, 0.12)"
borderRadius="4px"
width="calc(100% - 20px)"
height="calc(100% - 20px)"
minHeight="500px"
onClick={(event) => event.stopPropagation()}
>
<VmsTable
globalActions={filteredActions}
filterData={roleVms?.[activeRole?.roleName]}
filterLoose={false}
/>
</Box>
)}
</Box>
))}
</Box>
@ -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 (
<>
<Typography {...commonProps} data-cy="name">
{name}
</Typography>
<Typography {...commonProps} data-cy="cardinality">
{cardinality}
</Typography>
{isLoading ? (
<CircularProgress color="secondary" size={20} />
) : (
<Link
{...commonProps}
color="secondary"
component={RouterLink}
to={linkToVmTemplate}
>
{`#${template?.ID} ${template?.NAME}`}
</Link>
)}
<Typography {...commonProps} data-cy="parents">
{parents?.join?.()}
</Typography>
</>
<Box
display="flex"
flexDirection="row"
alignItems="flex-start"
padding="0.5em"
marginY="0.25em"
borderRadius="8px"
boxShadow={1}
sx={(theme) => ({
'&:hover': { boxShadow: 2, transition: 'box-shadow 0.3s' },
bgcolor: theme.palette.background,
filter: selected ? 'brightness(100%)' : 'brightness(90%)',
})}
>
<Box mr={2} mt={-1} alignSelf="start">
<StatusCircle
color={getRoleState(status)?.color || 'red'}
tooltip={getRoleState(status)?.name}
/>
</Box>
<Box>
<Typography variant="subtitle1" mb={1}>
{name}
</Typography>
<Typography variant="body1" mb={1}>
VM Template ID: {templateId}
</Typography>
<Typography variant="body1">Cardinality: {cardinality}</Typography>
</Box>
</Box>
)
})
RoleComponent.propTypes = { role: PropTypes.object }
RoleComponent.propTypes = {
role: PropTypes.object,
selected: PropTypes.bool,
status: PropTypes.number,
}
RoleComponent.displayName = 'RoleComponent'
export default RolesTab

View File

@ -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 (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
<Box padding={4}>
<Typography variant="h6">Scale</Typography>
<form onSubmit={handleSubmit(onSubmit)}>
<TextField
margin="normal"
fullWidth
label="Number of VMs"
type="number"
{...register('numberOfVms', {
required: 'Number of VMs is required',
})}
error={!!errors.numberOfVms}
helperText={errors.numberOfVms?.message}
/>
<FormControlLabel
control={<Switch {...register('force')} />}
label="Force"
/>
<Button
type="submit"
fullWidth
variant="contained"
color="primary"
sx={{ mt: 2, fontSize: '1rem' }}
>
Scale
</Button>
</form>
</Box>
</Dialog>
)
}
ScaleDialog.propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onScale: PropTypes.func.isRequired,
roleName: PropTypes.string,
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ? (
<SkeletonStepsForm />
) : (
<CreateForm
initialValues={dataTemplate}
stepProps={{
dataTemplate,
}}
onSubmit={onSubmit}
fallback={<SkeletonStepsForm />}
>
{(config) => <DefaultFormStepper {...config} />}
</CreateForm>
)
}
export default CreateServiceTemplate

View File

@ -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 ? (
<SkeletonStepsForm />
) : (
<InstantiateForm
initialValues={dataTemplate}
stepProps={{
dataTemplate,
}}
onSubmit={onSubmit}
fallback={<SkeletonStepsForm />}
>
{(config) => <DefaultFormStepper {...config} />}
</InstantiateForm>
)
}
export default CreateServiceTemplate

View File

@ -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() {
<SplitPane gridTemplateRows="1fr auto 1fr">
{({ getGridProps, GutterComponent }) => (
<Box height={1} {...(hasSelectedRows && getGridProps())}>
<ServiceTemplatesTable onSelectedRowsChange={onSelectedRowsChange} />
<ServiceTemplatesTable
onSelectedRowsChange={onSelectedRowsChange}
globalActions={actions}
/>
{hasSelectedRows && (
<>
@ -56,6 +61,7 @@ function ServiceTemplates() {
) : (
<InfoTabs
id={selectedRows[0]?.original?.ID}
template={selectedRows[0]?.original}
gotoPage={selectedRows[0]?.gotoPage}
unselect={() => selectedRows[0]?.toggleRowSelected(false)}
/>

View File

@ -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() {
<SplitPane gridTemplateRows="1fr auto 1fr">
{({ getGridProps, GutterComponent }) => (
<Box height={1} {...(hasSelectedRows && getGridProps())}>
<ServicesTable onSelectedRowsChange={onSelectedRowsChange} />
<ServicesTable
onSelectedRowsChange={onSelectedRowsChange}
globalActions={actions}
/>
{hasSelectedRows && (
<>
@ -55,7 +60,7 @@ function Services() {
<GroupedTags tags={selectedRows} />
) : (
<InfoTabs
id={selectedRows[0]?.original?.ID}
service={selectedRows[0]?.original}
gotoPage={selectedRows[0]?.gotoPage}
unselect={() => selectedRows[0]?.toggleRowSelected(false)}
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ?? ''}`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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