diff --git a/install.sh b/install.sh index 2d38b874715..4781393df7d 100755 --- a/install.sh +++ b/install.sh @@ -2973,6 +2973,7 @@ FIREEDGE_SUNSTONE_ETC="src/fireedge/etc/sunstone/sunstone-server.conf \ FIREEDGE_SUNSTONE_ETC_VIEW_ADMIN="src/fireedge/etc/sunstone/admin/vm-tab.yaml \ src/fireedge/etc/sunstone/admin/vm-template-tab.yaml \ + src/fireedge/etc/sunstone/admin/vm-group-tab.yaml \ src/fireedge/etc/sunstone/admin/marketplace-app-tab.yaml \ src/fireedge/etc/sunstone/admin/vnet-tab.yaml \ src/fireedge/etc/sunstone/admin/image-tab.yaml\ diff --git a/src/fireedge/etc/sunstone/admin/vm-group-tab.yaml b/src/fireedge/etc/sunstone/admin/vm-group-tab.yaml new file mode 100644 index 00000000000..2a8460d4a06 --- /dev/null +++ b/src/fireedge/etc/sunstone/admin/vm-group-tab.yaml @@ -0,0 +1,51 @@ +--- +# This file describe the information add actions available in the VM GROUP TEMPLATES tab + +# Resource + +resource_name: "VM-GROUP" + +# Actions - Which buttons are visible to operate over the resource + +actions: + create_dialog: true + update_dialog: true + enable: true + disable: true + chgrp: true + chown: true + delete: true + +# Filters - List of criteria to filter the resource + +filters: + label: true + state: true + + + +# Info Tabs - Which info tabs are used to show extended information + +info-tabs: + info: + enabled: true + information_panel: + enabled: true + actions: + rename: true + permissions_panel: + enabled: true + actions: + chmod: true + ownership_panel: + enabled: true + actions: + chown: true + cgrp: true + roles_panel: + enabled: true + roles-affinity_panel: + enabled: true + vms: + enabled: true + diff --git a/src/fireedge/src/client/apps/sunstone/routesOne.js b/src/fireedge/src/client/apps/sunstone/routesOne.js index 7bc39ebf8a8..cb05b7b1cc7 100644 --- a/src/fireedge/src/client/apps/sunstone/routesOne.js +++ b/src/fireedge/src/client/apps/sunstone/routesOne.js @@ -17,7 +17,8 @@ import { RefreshDouble as BackupIcon, Server as ClusterIcon, Db as DatastoreIcon, - Folder as FileIcon, + Archive as FileIcon, + Folder as VmGroupIcon, Group as GroupIcon, HardDrive as HostIcon, BoxIso as ImageIcon, @@ -69,6 +70,7 @@ const ServiceDetail = loadable( const VmTemplates = loadable(() => import('client/containers/VmTemplates'), { ssr: false, }) + const InstantiateVmTemplate = loadable( () => import('client/containers/VmTemplates/Instantiate'), { ssr: false } @@ -82,7 +84,19 @@ const VMTemplateDetail = loadable( { ssr: false } ) // const VrTemplates = loadable(() => import('client/containers/VrTemplates'), { ssr: false }) -// const VmGroups = loadable(() => import('client/containers/VmGroups'), { ssr: false }) +const VmGroups = loadable(() => import('client/containers/VmGroups'), { + ssr: false, +}) + +const CreateVmGroup = loadable( + () => import('client/containers/VmGroups/Create'), + { ssr: false } +) + +// const VmGroupDetail = loadable( +// () => import('client/containers/VmGroups/Detail'), +// { ssr: false } +// ) const ServiceTemplates = loadable( () => import('client/containers/ServiceTemplates'), @@ -242,6 +256,12 @@ export const PATH = { CREATE: `/${RESOURCE_NAMES.VM_TEMPLATE}/create`, DETAIL: `/${RESOURCE_NAMES.VM_TEMPLATE}/:id`, }, + VMGROUP: { + LIST: `/${RESOURCE_NAMES.VM_GROUP}`, + INSTANTIATE: `/${RESOURCE_NAMES.VM_GROUP}/instantiate`, + CREATE: `/${RESOURCE_NAMES.VM_GROUP}/create`, + DETAIL: `/${RESOURCE_NAMES.VM_GROUP}/:id`, + }, SERVICES: { LIST: `/${RESOURCE_NAMES.SERVICE_TEMPLATE}`, DETAIL: `/${RESOURCE_NAMES.SERVICE_TEMPLATE}/:id`, @@ -431,6 +451,28 @@ const ENDPOINTS = [ path: PATH.TEMPLATE.SERVICES.DETAIL, Component: ServiceTemplateDetail, }, + { + title: (_, state) => + state?.ID !== undefined ? T.UpdateVmGroup : T.CreateVmGroup, + description: (_, state) => + state?.ID !== undefined && `#${state.ID} ${state.NAME}`, + path: PATH.TEMPLATE.VMGROUP.CREATE, + Component: CreateVmGroup, + }, + { + title: T.VMGroups, + path: PATH.TEMPLATE.VMGROUP.LIST, + description: (params) => `#${params?.id}`, + icon: VmGroupIcon, + sidebar: true, + Component: VmGroups, + }, + // { + // title: T.VMGroup, + // description: (params) => `#${params?.id}`, + // path: PATH.TEMPLATE.VMGROUP.DETAIL, + // Component: VmGroupDetail, + // }, ], }, { diff --git a/src/fireedge/src/client/components/Cards/VmGroupCard.js b/src/fireedge/src/client/components/Cards/VmGroupCard.js new file mode 100644 index 00000000000..6f1e5da7ee7 --- /dev/null +++ b/src/fireedge/src/client/components/Cards/VmGroupCard.js @@ -0,0 +1,254 @@ +/* ------------------------------------------------------------------------- * + * 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 } from 'react' +import { Group, Lock } from 'iconoir-react' +import { + Typography, + Chip, + Box, + Tooltip, + Accordion, + AccordionSummary, + AccordionDetails, +} from '@mui/material' + +import { StatusCircle } from 'client/components/Status' +import { getState } from 'client/models/VmGroup' +import { T } from 'client/constants' + +/** + * VmGroupCard component to display vmgroup details. + * + * @param {object} props - Component props + * @param {object} props.vmgroup - VmGroup details + * @param {object} props.rootProps - Additional props for the root element + * @returns {Component} VmGroupCard component + */ +const VmGroupCard = ({ vmgroup, rootProps }) => { + const { ID, NAME, GNAME, LOCK, ROLES } = vmgroup + const { color: stateColor, name: stateName } = getState(LOCK) + + return ( + + + + + + {NAME} + + {LOCK && ( + + + + )} + + + {`#${ID}`} + + + + + + {GNAME} + + + + + + + + {ROLES?.ROLE && } + + + ) +} + +/** + * RolesComponent to display roles in accordions. + * + * @param {object} props - The props object. + * @param {Array} props.roles - The roles array. + * @returns {Component} Roles component. + */ +const RolesComponent = ({ roles }) => { + const rolesArray = Array.isArray(roles) ? roles : [roles] + + const affinedRoles = rolesArray.filter((role) => role?.POLICY === 'AFFINED') + const antiAffinedRoles = rolesArray.filter( + (role) => role?.POLICY === 'ANTI_AFFINED' + ) + + const renderRoles = (roleList) => ( + + {roleList.map((role, index) => { + if (!role?.ID || !role?.NAME) return null + + const tooltipContent = ( + + ID: {role.ID} + + {T.Name}: {role.NAME} + + {role.HOST_AFFINED && ( + + {T.HostAffined}: {role.HOST_AFFINED} + + )} + {role.HOST_ANTI_AFFINED && ( + + {T.HostAntiAffined}: {role.HOST_ANTI_AFFINED} + + )} + + ) + + return ( + + + + ) + })} + + ) + + return ( + + e.stopPropagation()} + elevation={1} + disabled={affinedRoles?.length <= 0} + sx={{ + mr: 1, + overflow: 'hidden', + borderRadius: '0 0 0.5rem 0.5rem', + '&:not(.Mui-expanded)': { + maxHeight: '48px', + }, + '&.Mui-expanded': { + maxHeight: 'none', + }, + '.MuiAccordionSummary-root': { + borderRadius: '0 0 0.5rem 0.5rem', + '&:hover': { + borderRadius: '0 0 0.5rem 0.5rem', + }, + }, + }} + > + + Affined Roles + + + {renderRoles(affinedRoles)} + + + e.stopPropagation()} + elevation={1} + disabled={antiAffinedRoles?.length <= 0} + sx={{ + mr: 1, + borderRadius: '0 0 0.5rem 0.5rem', + '&:not(.Mui-expanded)': { + maxHeight: '48px', + }, + '&.Mui-expanded': { + maxHeight: 'none', + }, + '.MuiAccordionSummary-root': { + borderRadius: '0 0 0.5rem 0.5rem', + '&:hover': { + borderRadius: '0 0 0.5rem 0.5rem', + }, + }, + }} + > + + Anti-Affined Roles + + {renderRoles(antiAffinedRoles)} + + + ) +} + +RolesComponent.propTypes = { + roles: PropTypes.oneOfType([ + PropTypes.arrayOf( + PropTypes.shape({ + POLICY: PropTypes.string.isRequired, + NAME: PropTypes.string.isRequired, + ID: PropTypes.string.isRequired, + }) + ), + PropTypes.object, + ]).isRequired, +} + +VmGroupCard.propTypes = { + vmgroup: PropTypes.shape({ + ID: PropTypes.string.isRequired, + NAME: PropTypes.string.isRequired, + GNAME: PropTypes.string.isRequired, + LOCK: PropTypes.object, + ROLES: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired, + }), + rootProps: PropTypes.object, +} + +VmGroupCard.displayName = 'VmGroupCard' + +export default VmGroupCard diff --git a/src/fireedge/src/client/components/Cards/index.js b/src/fireedge/src/client/components/Cards/index.js index 3f17651c49d..152f85e482c 100644 --- a/src/fireedge/src/client/components/Cards/index.js +++ b/src/fireedge/src/client/components/Cards/index.js @@ -41,6 +41,7 @@ import SnapshotCard from 'client/components/Cards/SnapshotCard' import TierCard from 'client/components/Cards/TierCard' import UserCard from 'client/components/Cards/UserCard' import VirtualDataCenterCard from 'client/components/Cards/VirtualDataCenterCard' +import VmGroupCard from 'client/components/Cards/VmGroupCard' import VirtualMachineCard from 'client/components/Cards/VirtualMachineCard' import VmTemplateCard from 'client/components/Cards/VmTemplateCard' import WavesCard from 'client/components/Cards/WavesCard' @@ -74,6 +75,7 @@ export { TierCard, UserCard, VirtualDataCenterCard, + VmGroupCard, VirtualMachineCard, VmTemplateCard, WavesCard, diff --git a/src/fireedge/src/client/components/Charts/MultiChart/helpers/scripts/dataProcessing.js b/src/fireedge/src/client/components/Charts/MultiChart/helpers/scripts/dataProcessing.js index b35059d9247..1cde6870d63 100644 --- a/src/fireedge/src/client/components/Charts/MultiChart/helpers/scripts/dataProcessing.js +++ b/src/fireedge/src/client/components/Charts/MultiChart/helpers/scripts/dataProcessing.js @@ -57,17 +57,16 @@ export const processDataForChart = ( * Used with all pool-like API requests to find the data array dynamically. * * @param {object} obj - The object to search within. + * @param {number} depth - The current depth of recursion. + * @param {number} maxDepth - The maximum depth to recurse to. * @returns {Array} - The found array or empty if not found. */ -const findFirstArray = (obj) => { +const findFirstArray = (obj, depth = 0, maxDepth = Infinity) => { + if (depth >= maxDepth) { + return [] + } + for (const value of Object.values(obj)) { - if (typeof value === 'object' && !Array.isArray(value)) { - for (const innerValue of Object.values(value)) { - if (typeof innerValue === 'object' && !Array.isArray(innerValue)) { - return [innerValue] - } - } - } if ( Array.isArray(value) && value.length > 0 && @@ -75,9 +74,12 @@ const findFirstArray = (obj) => { ) { return value } + if (typeof value === 'object') { - const result = findFirstArray(value) - if (result) return result + const result = findFirstArray(value, depth + 1, maxDepth) + if (result.length > 0) { + return result + } } } @@ -91,15 +93,17 @@ const findFirstArray = (obj) => { * @param {object} keyMap - An object that maps the keys in the API response to the desired output keys. * @param {Array} metricKeys - An array of keys to aggregate for the metrics. * @param {Function} labelingFunction - A function to generate the label for the dataset. + * @param {number} depth - Depth of recursion when finding data array. * @returns {object} - The transformed dataset. */ export const transformApiResponseToDataset = ( apiResponse, keyMap, metricKeys, - labelingFunction + labelingFunction, + depth = 0 ) => { - const dataArray = findFirstArray(apiResponse.data || apiResponse) + const dataArray = findFirstArray(apiResponse, depth) const flattenObject = (obj, prefix = '') => Object.keys(obj).reduce((acc, k) => { diff --git a/src/fireedge/src/client/components/FormStepper/index.js b/src/fireedge/src/client/components/FormStepper/index.js index 74cdea1e0fd..bbddc81306f 100644 --- a/src/fireedge/src/client/components/FormStepper/index.js +++ b/src/fireedge/src/client/components/FormStepper/index.js @@ -108,27 +108,27 @@ const FormStepper = ({ steps: initialSteps = [], schema, onSubmit }) => { formState: { errors }, setError, } = useFormContext() - const { isLoading } = useGeneral() - const [steps, setSteps] = useState(initialSteps) - const [disabledSteps, setDisabledSteps] = useState({}) - const dispatch = useDispatch() - - const currentState = useSelector((state) => state) // Used to control the default visibility of a step useEffect(() => { const newState = initialSteps.reduce( (accSteps, { id, defaultDisabled }) => { + if ( + !defaultDisabled || + typeof defaultDisabled.condition !== 'function' + ) { + return { ...accSteps, [id]: false } + } + const result = - defaultDisabled && Array.isArray(defaultDisabled.statePaths) && - typeof defaultDisabled.condition === 'function' + defaultDisabled.statePaths.length > 0 ? defaultDisabled.condition( ...defaultDisabled.statePaths.map((path) => get(currentState, path) ) ) - : false + : defaultDisabled.condition() return { ...accSteps, [id]: result } }, @@ -139,6 +139,13 @@ const FormStepper = ({ steps: initialSteps = [], schema, onSubmit }) => { setDisabledSteps(newState) }, []) + const { isLoading } = useGeneral() + const [steps, setSteps] = useState(initialSteps) + const [disabledSteps, setDisabledSteps] = useState({}) + const dispatch = useDispatch() + + const currentState = useSelector((state) => state) + const disableStep = useCallback((stepIds, shouldDisable) => { const ids = Array.isArray(stepIds) ? stepIds : [stepIds] diff --git a/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/General/index.js b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/General/index.js new file mode 100644 index 00000000000..ab8afee2f6e --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/General/index.js @@ -0,0 +1,58 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); you may * + * not use this file except in compliance with the License. You may obtain * + * a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + * ------------------------------------------------------------------------- */ +import PropTypes from 'prop-types' +import FormWithSchema from 'client/components/Forms/FormWithSchema' +import { T } from 'client/constants' +import { SCHEMA, NAME_FIELD, DESCRIPTION_FIELD } from './schema' + +export const STEP_ID = 'general' + +const Content = ({ isUpdate }) => ( + +) + +/** + * General VmGroup configuration. + * + * @param {object} data - VmGroup data + * @returns {object} General configuration step + */ +const General = (data) => { + const isUpdate = data?.ID + + return { + id: STEP_ID, + label: T.General, + resolver: SCHEMA, + optionsValidate: { abortEarly: false }, + content: () => Content({ isUpdate }), + } +} + +General.propTypes = { + data: PropTypes.object, + setFormData: PropTypes.func, +} + +Content.propTypes = { isUpdate: PropTypes.bool } +export default General diff --git a/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/General/schema.js b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/General/schema.js new file mode 100644 index 00000000000..6ec30950e25 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/General/schema.js @@ -0,0 +1,48 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); you may * + * not use this file except in compliance with the License. You may obtain * + * a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + * ------------------------------------------------------------------------- */ +import { INPUT_TYPES, 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') + .default(() => undefined), + grid: { md: 12 }, +} + +const SCHEMA = getObjectSchemaFromFields([NAME_FIELD, DESCRIPTION_FIELD]) + +export { SCHEMA, NAME_FIELD, DESCRIPTION_FIELD } diff --git a/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/RoleToRole/affinityGroup.js b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/RoleToRole/affinityGroup.js new file mode 100644 index 00000000000..54517f8bf42 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/RoleToRole/affinityGroup.js @@ -0,0 +1,150 @@ +/* ------------------------------------------------------------------------- * + * 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 { + Box, + Chip, + Tooltip, + IconButton, + Typography, + CardActions, +} from '@mui/material' +import { DeleteCircledOutline } from 'iconoir-react' +import { Component } from 'react' + +/** + * AffinityGroup component displays the affinity groups and their descriptions. + * It allows for the deletion of both groups and individual roles within them. + * + * @param {object} props - The props that are passed to this component. + * @param {string} props.groupType - The type of group, either 'AFFINED' or 'ANTI_AFFINED'. + * @param {Array} props.groups - The list of groups, each an array of roles. + * @param {Function} props.onDeleteGroup - Callback function for deleting an entire group. + * @param {Function} props.onDeleteRole - Callback function for deleting a single role from a group. + * @returns {Component} A component displaying affinity groups. + */ +export const AffinityGroup = ({ + groupType, + groups, + onDeleteGroup, + onDeleteRole, +}) => { + const isAffined = groupType === 'AFFINED' + + const description = isAffined + ? 'Affined groups improve performance and communication by placing related VM roles together on the same host. Ideal for roles that require high interactivity and shared resources. ' + : 'Anti-Affined groups enhance reliability and fault tolerance by distributing VM roles across different hosts. Suitable for roles that need isolation to prevent resource contention and single points of failure.' + + const useCases = isAffined + ? [ + 'Database clusters requiring shared storage.', + 'High-performance computing with intensive data exchange.', + 'Real-time processing applications demanding low-latency communication.', + ] + : [ + 'Operational VMs separated from backup VMs.', + 'Diverse application servers to prevent simultaneous failures.', + 'Resource-heavy VMs spread out to avoid performance bottlenecks.', + ] + + return ( + <> + {groups.length === 0 ? ( + + + {description} + + + Potential Use Cases: + + {useCases.map((useCase, index) => ( + {useCase} + ))} + + + + ) : ( + groups.map( + (group, groupIndex) => + group?.length >= 2 && ( + + + onDeleteGroup(groupIndex, groupType)} + sx={{ color: 'error.main', padding: '4px', ml: '4px' }} + > + + + + + {group.map((role, roleIndex) => ( + + onDeleteRole(roleIndex, groupIndex, groupType) + } + variant="outlined" + sx={{ + ml: 1, + }} + /> + ))} + + ) + ) + )} + > + ) +} + +AffinityGroup.propTypes = { + groupType: PropTypes.oneOf(['AFFINED', 'ANTI_AFFINED']).isRequired, + groups: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)).isRequired, + onDeleteGroup: PropTypes.func.isRequired, + onDeleteRole: PropTypes.func.isRequired, +} diff --git a/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/RoleToRole/affinityPanel.js b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/RoleToRole/affinityPanel.js new file mode 100644 index 00000000000..58cb266e413 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/RoleToRole/affinityPanel.js @@ -0,0 +1,297 @@ +/* ------------------------------------------------------------------------- * + * 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, Component, useLayoutEffect } from 'react' +import { + Typography, + ToggleButton, + ToggleButtonGroup, + List, + ListItem, + ListItemText, + Checkbox, + Button, + Card, + CardContent, + CardActions, + Grid, + Box, +} from '@mui/material' +import { Group } from 'iconoir-react' +import { AffinityGroup } from './affinityGroup' + +/** + * Role Affinity Panel component for managing roles. + * + * @param {object} props - The props object + * @param {Array} props.roles - The list of roles + * @param {Array} props.affinedGroups - Shared list of affined groups + * @param {Array} props.antiAffinedGroups - Shared list of anti-affined groups + * @param {Function} props.onGroupsChange - Callback handler for setting form group values + * @returns {Component} The rendered component. + */ +const RoleAffinityPanel = ({ + roles, + affinedGroups, + antiAffinedGroups, + onGroupsChange, +}) => { + const [affinityType, setAffinityType] = useState('AFFINED') + const [selectedRoles, setSelectedRoles] = useState([]) + + const handleAffinityTypeChange = (_event, newAffinityType) => { + if (newAffinityType !== null) { + setAffinityType(newAffinityType) + setSelectedRoles([]) + } + } + const handleRoleSelect = (role) => { + setSelectedRoles((prevSelectedRoles) => { + const isSelected = prevSelectedRoles.includes(role) + + return isSelected + ? prevSelectedRoles.filter((r) => r !== role) + : [...prevSelectedRoles, role] + }) + } + + const handleAddGroup = () => { + const newGroup = [...selectedRoles] + if (affinityType === 'AFFINED') { + const newAffinedGroups = [...affinedGroups, newGroup] + onGroupsChange(newAffinedGroups, antiAffinedGroups) + } else { + const newAntiAffinedGroups = [...antiAffinedGroups, newGroup] + onGroupsChange(affinedGroups, newAntiAffinedGroups) + } + setSelectedRoles([]) + } + + const handleDeleteGroup = (groupIndex, type) => { + if (type === 'AFFINED') { + const newAffinedGroups = affinedGroups.filter( + (_, index) => index !== groupIndex + ) + onGroupsChange(newAffinedGroups, antiAffinedGroups) + } else { + const newAntiAffinedGroups = antiAffinedGroups.filter( + (_, index) => index !== groupIndex + ) + onGroupsChange(affinedGroups, newAntiAffinedGroups) + } + } + + const handleDeleteRoleFromGroup = (roleIndex, groupIndex, type) => { + const updateGroup = (groups) => + groups.map((group, index) => { + if (index === groupIndex) { + const updatedGroup = group.filter((_, idx) => idx !== roleIndex) + + return updatedGroup + } + + return group + }) + + if (type === 'AFFINED') { + const newAffinedGroups = updateGroup(affinedGroups) + newAffinedGroups?.[groupIndex]?.length < 2 + ? handleDeleteGroup(groupIndex, type) + : onGroupsChange(newAffinedGroups, antiAffinedGroups) + } else { + const newAntiAffinedGroups = updateGroup(antiAffinedGroups) + newAntiAffinedGroups?.[groupIndex]?.length < 2 + ? handleDeleteGroup(groupIndex, type) + : onGroupsChange(affinedGroups, newAntiAffinedGroups) + } + } + + const filteredRoles = roles.filter( + (role) => role.POLICY === affinityType || role.POLICY === 'None' + ) + + const filterGroupsByPolicy = (groups, policy) => { + const roleLookup = Object.fromEntries( + roles.map((role) => [role.NAME, role.POLICY]) + ) + + return groups?.map((group) => + group?.filter((role) => ['None', policy].includes(roleLookup[role])) + ) + } + + useLayoutEffect(() => { + const filteredAffinedGroups = filterGroupsByPolicy(affinedGroups, 'AFFINED') + const filteredAntiAffinedGroups = filterGroupsByPolicy( + antiAffinedGroups, + 'ANTI_AFFINED' + ) + + onGroupsChange(filteredAffinedGroups, filteredAntiAffinedGroups) + }, []) + + return ( + + + + + + Affined + Anti-Affined + + + {filteredRoles.map((role, index) => ( + handleRoleSelect(role.NAME)} + sx={{ + py: 1, + my: 0.5, + border: '1px solid', + borderColor: 'divider', + borderRadius: '4px', + }} + > + + + + ))} + + + + } + disabled={selectedRoles.length < 2} + onClick={handleAddGroup} + size="large" + fullWidth + > + Add Group + + + + + + + + + + + Affined Groups + + + + + + + + + + + + Anti-Affined Groups + + + + + + + + + + + ) +} + +RoleAffinityPanel.propTypes = { + roles: PropTypes.arrayOf( + PropTypes.shape({ + NAME: PropTypes.string.isRequired, + POLICY: PropTypes.string.isRequired, + }) + ).isRequired, + affinedGroups: PropTypes.array.isRequired, + antiAffinedGroups: PropTypes.array.isRequired, + onGroupsChange: PropTypes.func.isRequired, +} + +RoleAffinityPanel.defaultProps = { + roles: [], +} + +export default RoleAffinityPanel diff --git a/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/RoleToRole/index.js b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/RoleToRole/index.js new file mode 100644 index 00000000000..53208cffaf0 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/RoleToRole/index.js @@ -0,0 +1,71 @@ +/* ------------------------------------------------------------------------- * + * 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 { useFormContext } from 'react-hook-form' +import { useState } from 'react' +import { SCHEMA } from './schema' +import RoleAffinityPanel from './affinityPanel' +export const STEP_ID = 'role-to-role' + +const Content = () => { + const { getValues, setValue } = useFormContext() + const { AFFINED_GROUPS, ANTI_AFFINED_GROUPS } = getValues(STEP_ID) + const [affinedGroups, setAffinedGroups] = useState(AFFINED_GROUPS ?? []) + const [antiAffinedGroups, setAntiAffinedGroups] = useState( + ANTI_AFFINED_GROUPS ?? [] + ) + const definedRoles = getValues('role-definition') + const handleGroupsChange = (newAffinedGroups, newAntiAffinedGroups) => { + setAffinedGroups(newAffinedGroups?.filter((group) => group?.length >= 2)) + setAntiAffinedGroups( + newAntiAffinedGroups?.filter((group) => group?.length >= 2) + ) + + setValue(STEP_ID, { + AFFINED_GROUPS: newAffinedGroups, + ANTI_AFFINED_GROUPS: newAntiAffinedGroups, + }) + } + + return ( + + ) +} + +/** + * Role to role definition configuration. + * + * @returns {object} Role to role configuration step + */ +const RoleToRole = () => ({ + id: STEP_ID, + label: 'Role affinity', + resolver: SCHEMA, + optionsValidate: { abortEarly: false }, + content: Content, +}) + +RoleToRole.propTypes = { + data: PropTypes.array, + setFormData: PropTypes.func, +} + +export default RoleToRole diff --git a/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/RoleToRole/schema.js b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/RoleToRole/schema.js new file mode 100644 index 00000000000..663fbdb1a1b --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/RoleToRole/schema.js @@ -0,0 +1,47 @@ +/* ------------------------------------------------------------------------- * + * 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, array, string } from 'yup' + +/** @type {object} Role Group schema */ +const ROLE_GROUP_SCHEMA = object().shape({ + AFFINED_GROUPS: array() + .of( + array().of( + string().test( + 'is-string', + 'Malformed affined group(s) definition', + (value) => typeof value === 'string' + ) + ) + ) + .notRequired() + .default(() => []), + ANTI_AFFINED_GROUPS: array() + .of( + array().of( + string().test( + 'is-string', + 'Malformed anti-affined group(s) definition', + (value) => typeof value === 'string' + ) + ) + ) + .notRequired() + .default(() => []), +}) + +/** @type {object} Roles schema for the step */ +export const SCHEMA = ROLE_GROUP_SCHEMA diff --git a/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/hostAffinityPanel.js b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/hostAffinityPanel.js new file mode 100644 index 00000000000..8cf2a0db88a --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/hostAffinityPanel.js @@ -0,0 +1,233 @@ +/* ------------------------------------------------------------------------- * + * 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, useMemo, useCallback, Component } from 'react' +import PropTypes from 'prop-types' +import { + Box, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TablePagination, + Checkbox, + Button, + ToggleButton, + ToggleButtonGroup, + Typography, + Paper, + Tooltip, +} from '@mui/material' +import { useLazyGetHostsQuery } from 'client/features/OneApi/host' +import { HOST_STATES } from 'client/constants' +import { useGeneralApi } from 'client/features/General' + +/** + * HostAffinityPanel 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 HostAffinityPanel component. + */ +const HostAffinityPanel = ({ roles, selectedRoleIndex, onChange }) => { + const { enqueueError } = useGeneralApi() + const [hosts, setHosts] = useState([]) + const [fetch, { data, error }] = useLazyGetHostsQuery() + const [selectedHostIds, setSelectedHostIds] = useState([]) + const [affinityType, setAffinityType] = useState('Affined') + + const formatKey = useCallback( + (type) => 'HOST_' + type?.toUpperCase()?.split('-')?.join('_'), + [] + ) + + const handleSubmit = () => { + const affinityKey = + affinityType === 'Affined' ? 'HOST_AFFINED' : 'HOST_ANTI_AFFINED' + onChange(affinityKey, selectedHostIds) + setSelectedHostIds([]) + } + + const processHosts = useMemo( + () => + data?.filter((host) => { + const role = roles?.[selectedRoleIndex] + const antiAffinityType = + affinityType === 'Affined' ? 'Anti-Affined' : 'Affined' + + return ( + !role?.[formatKey(antiAffinityType)]?.includes(host.ID) && + !role?.[formatKey(affinityType)]?.includes(host.ID) + ) + }) ?? [], + [data, roles, selectedRoleIndex, affinityType, formatKey] + ) + + useEffect(() => { + fetch() + }, [fetch]) + + useEffect(() => { + if (error) { + enqueueError(`Error fetching host data: ${error?.message ?? error}`) + } + }, [error, enqueueError]) + + useEffect(() => { + setHosts(processHosts) + setSelectedHostIds([]) + }, [processHosts]) + + const handleHostSelect = (hostId) => { + setSelectedHostIds((prevSelectedHostIds) => { + const isSelected = prevSelectedHostIds.includes(hostId) + if (isSelected) { + return prevSelectedHostIds.filter((id) => id !== hostId) + } else { + return [...prevSelectedHostIds, hostId] + } + }) + } + + const [page, setPage] = useState(0) + const [rowsPerPage, setRowsPerPage] = useState(10) + + const handleChangePage = (event, newPage) => { + setPage(newPage) + } + + const handleChangeRowsPerPage = (event) => { + setRowsPerPage(parseInt(event.target.value, 10)) + setPage(0) + } + + const isDisabled = + roles?.[selectedRoleIndex]?.NAME === '' || + roles?.[selectedRoleIndex]?.NAME === undefined + + return ( + + + Host Affinity + + + !!newValue && setAffinityType(newValue)} + aria-label="affinity type" + sx={{ marginBottom: 2 }} + > + + Affined + + + Anti-Affined + + + + + + Add + + + + + + + + + + ID + Name + Cluster + Status + + + + {hosts + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((host) => ( + !isDisabled && handleHostSelect(host.ID)} + role="checkbox" + aria-checked={selectedHostIds.includes(host.ID)} + tabIndex={-1} + > + + + + {host.ID} + {host.NAME} + {host.CLUSTER} + {HOST_STATES[+host?.STATE ?? 0].name} + + ))} + + + + + + ) +} + +HostAffinityPanel.propTypes = { + roles: PropTypes.arrayOf( + PropTypes.shape({ + NAME: PropTypes.string, + POLICY: PropTypes.string, + }) + ), + selectedRoleIndex: PropTypes.number, + onChange: PropTypes.func.isRequired, +} + +export default HostAffinityPanel diff --git a/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/index.js b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/index.js new file mode 100644 index 00000000000..51397001a69 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/index.js @@ -0,0 +1,140 @@ +/* ------------------------------------------------------------------------- * + * 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 './schema' +import RoleVmVmPanel from './rolesPanel' +import RoleColumn from './rolesColumn' +import HostAffinityPanel from './hostAffinityPanel' +import RoleSummary from './roleSummary' + +export const STEP_ID = 'role-definition' + +const Content = () => { + const { getValues, setValue, reset } = useFormContext() + const [selectedRoleIndex, setSelectedRoleIndex] = useState(0) + const defaultRole = [{ NAME: '', POLICY: 'None' }] + const watchedRoles = useWatch({ + name: STEP_ID, + defaultValue: defaultRole, + }) + const definedRoles = getValues(STEP_ID) + useEffect(() => { + if (definedRoles) { + reset({ [STEP_ID]: definedRoles ?? defaultRole }) + } + }, []) // Set the form initial values + const [roles, setRoles] = useState(getValues(STEP_ID)) + + useEffect(() => { + setRoles(watchedRoles) + }, [definedRoles, watchedRoles]) + const handleChangeRoles = (updatedRoles) => { + setValue(STEP_ID, updatedRoles) + } + + const handleHostAffinityChange = useCallback( + (affinityKey, hostIds) => { + const updatedRoles = [...roles] + const existingHostIds = + updatedRoles?.[selectedRoleIndex]?.[affinityKey] || [] + + const combinedHostIds = Array.from( + new Set([...existingHostIds, ...hostIds]) + ) + + updatedRoles[selectedRoleIndex] = { + ...updatedRoles[selectedRoleIndex], + [affinityKey]: combinedHostIds, + } + + handleChangeRoles(updatedRoles) + }, + [roles, selectedRoleIndex, handleChangeRoles] + ) + + const handleRemoveAffinity = (affinityType, hostId) => { + const updatedRoles = [...roles] + const updatedRole = { ...updatedRoles[selectedRoleIndex] } + updatedRole[affinityType] = updatedRole[affinityType].filter( + (id) => id !== hostId + ) + updatedRoles[selectedRoleIndex] = updatedRole + handleChangeRoles(updatedRoles) + } + + return ( + + + + + + + + + + + + + + + ) +} + +/** + * Role definition configuration. + * + * @returns {object} Roles definition configuration step + */ +const RoleDefinition = () => ({ + id: STEP_ID, + label: 'Role Definition', + resolver: SCHEMA, + optionsValidate: { abortEarly: false }, + content: Content, +}) +RoleDefinition.propTypes = { + data: PropTypes.array, + setFormData: PropTypes.func, +} + +export default RoleDefinition diff --git a/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/roleSummary.js b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/roleSummary.js new file mode 100644 index 00000000000..0c1f6590c1e --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/roleSummary.js @@ -0,0 +1,191 @@ +/* ------------------------------------------------------------------------- * + * 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, + List, + ListItem, + ListItemText, + IconButton, + Tooltip, +} from '@mui/material' +import PropTypes from 'prop-types' +import { Cancel, InfoEmpty } from 'iconoir-react' +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. + * @param {Function} props.onRemoveAffinity - Function to call when removing an affinity from a role. + * @returns {Component} - Role summary component. + */ +const RoleSummary = ({ role, selectedRoleIndex, onRemoveAffinity }) => { + const handleRemoveAffinity = (affinityType, hostId) => () => { + onRemoveAffinity(affinityType, hostId) + } + const formatPolicy = (rolePolicy) => + rolePolicy === undefined + ? 'Set a VM affinity policy.' + : rolePolicy === 'AFFINED' + ? T.Affined + : rolePolicy === 'ANTI_AFFINED' + ? T.AntiAffined + : T.None + + return ( + + + + #{selectedRoleIndex + 1 ?? 0} Role Configuration + + + + Name: {role?.NAME || 'Enter a name for this role.'} + + + + Policy: {formatPolicy(role?.POLICY)} + + + {role?.HOST_AFFINED && role.HOST_AFFINED.length > 0 ? ( + <> + + Affined Hosts: + + + {role.HOST_AFFINED.map((hostId, index) => ( + + + + + + + ))} + + > + ) : ( + + No affined hosts. Assign a set of hosts where the VMs of this role + can be allocated. + + + + + )} + + {role?.HOST_ANTI_AFFINED && role.HOST_ANTI_AFFINED.length > 0 ? ( + <> + + Anti-Affined Hosts: + + + {role.HOST_ANTI_AFFINED.map((hostId, index) => ( + + + + + + + ))} + + > + ) : ( + + No anti-affined hosts. Assign a set of hosts where the VMs of this + role can't be allocated. + + + + + )} + + + + VM Group Configuration: + + Define roles and placement constraints. + Optimize performance and fault tolerance. + Manage multi-VM applications efficiently. + + + + + ) +} + +RoleSummary.propTypes = { + role: PropTypes.oneOfType([ + PropTypes.shape({ + NAME: PropTypes.string, + POLICY: PropTypes.oneOf(['AFFINED', 'ANTI_AFFINED', 'None', undefined]), + HOST_AFFINED: PropTypes.arrayOf(PropTypes.number), + HOST_ANTI_AFFINED: PropTypes.arrayOf(PropTypes.number), + }), + PropTypes.array, + PropTypes.object, + ]), + selectedRoleIndex: PropTypes.number, + onRemoveAffinity: PropTypes.func.isRequired, +} + +export default RoleSummary diff --git a/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/rolesColumn.js b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/rolesColumn.js new file mode 100644 index 00000000000..687d683e8c9 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/rolesColumn.js @@ -0,0 +1,153 @@ +/* ------------------------------------------------------------------------- * + * 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. + * @returns {Component} - Role columns component + */ +const RoleColumn = ({ + roles, + onChange, + selectedRoleIndex, + setSelectedRoleIndex, +}) => { + const handleAddRole = useCallback(() => { + const newRole = { NAME: '', POLICY: 'None' } + onChange([...roles, newRole]) + setSelectedRoleIndex(roles.length) + }, [roles, onChange]) + + const handleRemoveRole = useCallback( + (indexToRemove) => { + const updatedRoles = roles.filter((_, index) => index !== indexToRemove) + onChange(updatedRoles) + if (selectedRoleIndex === indexToRemove) { + setSelectedRoleIndex(null) + } + }, + [roles, onChange, selectedRoleIndex] + ) + + return ( + + + + Add Role + + + + {Array.isArray(roles) && + roles.length > 0 && + roles.map((role, index) => ( + setSelectedRoleIndex(index)} + key={index} + sx={{ + my: 0.5, + 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', + }, + }} + > + { + event.stopPropagation() + handleRemoveRole(index) + }} + data-cy={`delete-role-${index}`} + sx={{ mr: 1.5 }} + > + + + + {role?.NAME || 'New Role'} + + + ))} + + + + + ) +} + +RoleColumn.propTypes = { + roles: PropTypes.arrayOf(PropTypes.object).isRequired, + onChange: PropTypes.func.isRequired, + selectedRoleIndex: PropTypes.number, + setSelectedRoleIndex: PropTypes.func.isRequired, +} + +RoleColumn.defaultProps = { + roles: [], +} + +export default RoleColumn diff --git a/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/rolesPanel.js b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/rolesPanel.js new file mode 100644 index 00000000000..28438d791e1 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/rolesPanel.js @@ -0,0 +1,100 @@ +/* ------------------------------------------------------------------------- * + * 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, + TextField, + Select, + MenuItem, + Typography, + FormControl, + InputLabel, +} from '@mui/material' + +/** + * 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 handleRoleChange = useCallback( + (updatedRole) => { + const updatedRoles = [...roles] + updatedRoles[selectedRoleIndex] = updatedRole + onChange(updatedRoles) + }, + [roles, onChange, selectedRoleIndex] + ) + + const handleInputChange = (event) => { + const { name, value } = event.target + handleRoleChange({ ...roles[selectedRoleIndex], [name]: value }) + } + + return ( + + + Role Details + + + + + + + VM-VM Affinity + + None + Affined + Anti-Affined + + + + + + ) +} + +RoleVmVmPanel.propTypes = { + roles: PropTypes.arrayOf( + PropTypes.shape({ + NAME: PropTypes.string, + POLICY: PropTypes.oneOf([undefined, 'None', 'AFFINED', 'ANTI_AFFINED']), + }) + ), + onChange: PropTypes.func.isRequired, + selectedRoleIndex: PropTypes.number, +} + +export default RoleVmVmPanel diff --git a/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/schema.js b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/schema.js new file mode 100644 index 00000000000..dd7dc8960c1 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/Roles/schema.js @@ -0,0 +1,104 @@ +/* ------------------------------------------------------------------------- * + * 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, mixed } 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(() => undefined), + grid: { md: 12 }, +} + +/** @type {Field} VM-VM Affinity field for role */ +const POLICY_FIELD = { + name: 'POLICY', + label: 'VM-VM Affinity', + type: INPUT_TYPES.SELECT, + validation: string() + .required('No valid policy selected') + .default(() => 'None'), + grid: { md: 12 }, + values: [ + { text: 'None', value: 'None' }, + { text: 'Affined', value: 'AFFINED' }, + { text: 'Anti-Affined', value: 'ANTI_AFFINED' }, + ], +} + +const AFFINED_FIELD = { + name: 'HOST_AFFINED', + validation: array() + .of(mixed().notRequired()) + .default(() => []), +} + +const ANTI_AFFINED_FIELD = { + name: 'HOST_ANTI_AFFINED', + validation: array() + .of(mixed().notRequired()) + .default(() => []), +} + +/** @type {object} Role schema */ +const ROLE_SCHEMA = object().shape({ + NAME: ROLE_NAME_FIELD.validation, + POLICY: POLICY_FIELD.validation, + HOST_AFFINED: AFFINED_FIELD.validation, + HOST_ANTI_AFFINED: ANTI_AFFINED_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( + '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, POLICY_FIELD, ROLE_SCHEMA] diff --git a/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/index.js b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/index.js new file mode 100644 index 00000000000..115d74250ca --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/Steps/index.js @@ -0,0 +1,124 @@ +/* ------------------------------------------------------------------------- * + * 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/VmGroup/CreateForm/Steps/General' +import RoleDefinition, { + STEP_ID as ROLE_DEFINITION_ID, +} from 'client/components/Forms/VmGroup/CreateForm/Steps/Roles' +import RoleToRole, { + STEP_ID as ROLE_TO_ROLE_ID, +} from 'client/components/Forms/VmGroup/CreateForm/Steps/RoleToRole' + +import { createSteps } from 'client/utils' + +const Steps = createSteps([General, RoleDefinition, RoleToRole], { + transformInitialValue: (VmGroupTemplate, schema) => { + const accessor = VmGroupTemplate?.TEMPLATE + const affinedGroups = Array.isArray(accessor?.AFFINED) + ? accessor?.AFFINED + : [accessor?.AFFINED] + const antiAffinedGroups = Array.isArray(accessor?.ANTI_AFFINED) + ? accessor?.ANTI_AFFINED + : [accessor?.ANTI_AFFINED] + const definedRoles = Array.isArray(VmGroupTemplate?.ROLES?.ROLE) + ? VmGroupTemplate?.ROLES?.ROLE + : [VmGroupTemplate?.ROLES?.ROLE] + const knownTemplate = schema.cast( + { + [GENERAL_ID]: { ...VmGroupTemplate }, + + [ROLE_DEFINITION_ID]: definedRoles.map((role) => ({ + ...role, + HOST_AFFINED: role?.HOST_AFFINED?.split(',').map((r) => r.trim()), + HOST_ANTI_AFFINED: role?.HOST_ANTI_AFFINED?.split(',').map((r) => + r.trim() + ), + })), + [ROLE_TO_ROLE_ID]: { + AFFINED_GROUPS: affinedGroups?.map((role) => role?.split(',')), + + ANTI_AFFINED_GROUPS: antiAffinedGroups?.map((role) => + role?.split(',') + ), + }, + }, + { + stripUnknown: true, + } + ) + + return knownTemplate + }, + + transformBeforeSubmit: (formData) => { + const { + [GENERAL_ID]: generalData, + [ROLE_DEFINITION_ID]: roleDefinitionData, + [ROLE_TO_ROLE_ID]: roleToRoleData, + } = formData + + return { + NAME: generalData.NAME, + DESCRIPTION: generalData.DESCRIPTION, + ROLE: roleDefinitionData.map( + ({ HOST_AFFINED, HOST_ANTI_AFFINED, ...role }) => ({ + ...role, + ...(HOST_AFFINED && + HOST_AFFINED?.length > 0 && { + HOST_AFFINED: HOST_AFFINED.join(', ') ?? [], + }), + ...(HOST_ANTI_AFFINED && + HOST_ANTI_AFFINED?.length > 0 && { + HOST_ANTI_AFFINED: HOST_ANTI_AFFINED.join(', ') ?? [], + }), + }) + ), + TEMPLATE: { + AFFINED: + roleToRoleData?.AFFINED_GROUPS?.filter( + (group, index, self) => + group !== null && + group !== undefined && + index === + self.findIndex( + (otherGroup) => + otherGroup.length === group.length && + otherGroup.every((item) => group.includes(item)) + ) + )?.map((group) => + group.filter((item) => typeof item === 'string').join(',') + ) ?? [], + ANTI_AFFINED: + roleToRoleData?.ANTI_AFFINED_GROUPS?.filter( + (group, index, self) => + group !== null && + group !== undefined && + index === + self.findIndex( + (otherGroup) => + otherGroup.length === group.length && + otherGroup.every((item) => group.includes(item)) + ) + )?.map((group) => + group.filter((item) => typeof item === 'string').join(',') + ) ?? [], + }, + } + }, +}) + +export default Steps diff --git a/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/index.js b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/index.js new file mode 100644 index 00000000000..f769518b2f9 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmGroup/CreateForm/index.js @@ -0,0 +1,16 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); you may * + * not use this file except in compliance with the License. You may obtain * + * a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + * ------------------------------------------------------------------------- */ +export { default } from 'client/components/Forms/VmGroup/CreateForm/Steps' diff --git a/src/fireedge/src/client/components/Forms/VmGroup/index.js b/src/fireedge/src/client/components/Forms/VmGroup/index.js new file mode 100644 index 00000000000..4d5ef8071ff --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmGroup/index.js @@ -0,0 +1,27 @@ +/* ------------------------------------------------------------------------- * + * 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 { AsyncLoadForm, ConfigurationProps } from 'client/components/HOC' +import { CreateStepsCallback } from 'client/utils/schema' + +/** + * @param {ConfigurationProps} configProps - Configuration + * @returns {Component|CreateStepsCallback} Asynchronous loaded form + */ +const CreateForm = (configProps) => + AsyncLoadForm({ formPath: 'VmGroup/CreateForm' }, configProps) + +export { CreateForm } diff --git a/src/fireedge/src/client/components/Tables/VmGroups/actions.js b/src/fireedge/src/client/components/Tables/VmGroups/actions.js new file mode 100644 index 00000000000..177360b3124 --- /dev/null +++ b/src/fireedge/src/client/components/Tables/VmGroups/actions.js @@ -0,0 +1,151 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); you may * + * not use this file except in compliance with the License. You may obtain * + * a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + * ------------------------------------------------------------------------- */ +import { Typography } from '@mui/material' +import { AddCircledOutline, Trash } from 'iconoir-react' +import { useMemo } from 'react' +import { useHistory } from 'react-router-dom' + +import { useViews } from 'client/features/Auth' +import { + useLockVMGroupMutation, + useUnlockVMGroupMutation, + useRemoveVMGroupMutation, +} from 'client/features/OneApi/vmGroup' + +import { + createActions, + GlobalAction, +} from 'client/components/Tables/Enhanced/Utils' + +import { PATH } from 'client/apps/sunstone/routesOne' +import { Translate } from 'client/components/HOC' +import { RESOURCE_NAMES, T, VMGROUP_ACTIONS } from 'client/constants' + +const ListVmGroupNames = ({ rows = [] }) => + rows?.map?.(({ id, original }) => { + const { ID, NAME } = original + + return ( + + {`#${ID} ${NAME}`} + + ) + }) + +const MessageToConfirmAction = (rows, description) => ( + <> + + {description && } + + > +) + +MessageToConfirmAction.displayName = 'MessageToConfirmAction' + +/** + * Generates the actions to operate resources on VmGroup table. + * + * @returns {GlobalAction} - Actions + */ +const Actions = () => { + const history = useHistory() + const { view, getResourceView } = useViews() + const [enable] = useUnlockVMGroupMutation() + const [remove] = useRemoveVMGroupMutation() + const [disable] = useLockVMGroupMutation() + + return useMemo( + () => + createActions({ + filters: getResourceView(RESOURCE_NAMES.VM_GROUP)?.actions, + actions: [ + { + accessor: VMGROUP_ACTIONS.CREATE_DIALOG, + tooltip: T.Create, + icon: AddCircledOutline, + action: () => history.push(PATH.TEMPLATE.VMGROUP.CREATE), + }, + { + accessor: VMGROUP_ACTIONS.UPDATE_DIALOG, + label: T.Update, + tooltip: T.Update, + selected: { max: 1 }, + color: 'secondary', + action: (rows) => { + const vmGroupTemplate = rows?.[0]?.original ?? {} + const path = PATH.TEMPLATE.VMGROUP.CREATE + + history.push(path, vmGroupTemplate) + }, + }, + { + accessor: VMGROUP_ACTIONS.ENABLE, + label: T.Enable, + tooltip: T.Enable, + color: 'secondary', + selected: { min: 1 }, + dataCy: `vmgroup_${VMGROUP_ACTIONS.ENABLE}`, + action: async (rows) => { + const ids = rows?.map?.(({ original }) => original?.ID) + await Promise.all(ids.map((id) => enable(id))) + }, + }, + { + accessor: VMGROUP_ACTIONS.DISABLE, + label: T.Disable, + tooltip: T.Disable, + color: 'secondary', + selected: { min: 1 }, + dataCy: `vmgroup_${VMGROUP_ACTIONS.DISABLE}`, + action: async (rows) => { + const ids = rows?.map?.(({ original }) => original?.ID) + await Promise.all(ids.map((id) => disable(id))) + }, + }, + { + accessor: VMGROUP_ACTIONS.DELETE, + tooltip: T.Delete, + icon: Trash, + color: 'error', + selected: { min: 1 }, + dataCy: `vmgroup_${VMGROUP_ACTIONS.DELETE}`, + options: [ + { + isConfirmDialog: true, + dialogProps: { + title: T.Delete, + dataCy: `modal-${VMGROUP_ACTIONS.DELETE}`, + children: MessageToConfirmAction, + }, + onSubmit: (rows) => async () => { + const ids = rows?.map?.(({ original }) => original?.ID) + await Promise.all(ids.map((id) => remove({ id }))) + }, + }, + ], + }, + ], + }), + [view] + ) +} + +export default Actions diff --git a/src/fireedge/src/client/components/Tables/VmGroups/columns.js b/src/fireedge/src/client/components/Tables/VmGroups/columns.js new file mode 100644 index 00000000000..79ec6daeaad --- /dev/null +++ b/src/fireedge/src/client/components/Tables/VmGroups/columns.js @@ -0,0 +1,28 @@ +/* ------------------------------------------------------------------------- * + * 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 [ + { Header: 'ID', accessor: 'ID', sortType: 'number' }, + { Header: 'UID', accessor: 'UID', sortType: 'number' }, + { Header: 'GID', accessor: 'GID', sortType: 'number' }, + { Header: 'Name', accessor: 'NAME' }, + { Header: 'UName', accessor: 'UNAME' }, + { Header: 'GName', accessor: 'GNAME' }, + { Header: 'Permissions', accessor: 'PERMISSIONS' }, + { Header: 'Lock', accessor: 'LOCK' }, + { Header: 'Roles', accessor: 'ROLES' }, + { Header: 'Template', accessor: 'TEMPLATE' }, +] diff --git a/src/fireedge/src/client/components/Tables/VmGroups/index.js b/src/fireedge/src/client/components/Tables/VmGroups/index.js new file mode 100644 index 00000000000..f99819cf250 --- /dev/null +++ b/src/fireedge/src/client/components/Tables/VmGroups/index.js @@ -0,0 +1,67 @@ +/* ------------------------------------------------------------------------- * + * 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, ReactElement } from 'react' + +import { useViews } from 'client/features/Auth' +import { useGetVMGroupsQuery } from 'client/features/OneApi/vmGroup' + +import EnhancedTable, { createColumns } from 'client/components/Tables/Enhanced' +import VmGroupColumns from 'client/components/Tables/VmGroups/columns' +import VmGroupRow from 'client/components/Tables/VmGroups/row' +import { RESOURCE_NAMES } from 'client/constants' + +const DEFAULT_DATA_CY = 'vmgroups' + +/** + * @param {object} props - Props + * @returns {ReactElement} VmGroups table + */ +const VmGroupsTable = (props) => { + const { rootProps = {}, searchProps = {}, ...rest } = props ?? {} + rootProps['data-cy'] ??= DEFAULT_DATA_CY + searchProps['data-cy'] ??= `search-${DEFAULT_DATA_CY}` + + const { view, getResourceView } = useViews() + const { data = [], isFetching, refetch } = useGetVMGroupsQuery() + + const columns = useMemo( + () => + createColumns({ + filters: getResourceView(RESOURCE_NAMES.VM_GROUP)?.filters, + columns: VmGroupColumns, + }), + [view] + ) + + return ( + data, [data])} + rootProps={rootProps} + searchProps={searchProps} + refetch={refetch} + isLoading={isFetching} + getRowId={(row) => String(row.ID)} + RowComponent={VmGroupRow} + {...rest} + /> + ) +} + +VmGroupsTable.propTypes = { ...EnhancedTable.propTypes } +VmGroupsTable.displayName = 'VmGroupsTable' + +export default VmGroupsTable diff --git a/src/fireedge/src/client/components/Tables/VmGroups/row.js b/src/fireedge/src/client/components/Tables/VmGroups/row.js new file mode 100644 index 00000000000..d35abff6702 --- /dev/null +++ b/src/fireedge/src/client/components/Tables/VmGroups/row.js @@ -0,0 +1,32 @@ +/* ------------------------------------------------------------------------- * + * 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 jsdoc/require-jsdoc */ +import PropTypes from 'prop-types' + +import { VmGroupCard } from 'client/components/Cards' + +const Row = ({ original, value, ...props }) => ( + +) + +Row.propTypes = { + original: PropTypes.object, + value: PropTypes.object, + isSelected: PropTypes.bool, + handleClick: PropTypes.func, +} + +export default Row diff --git a/src/fireedge/src/client/components/Tables/index.js b/src/fireedge/src/client/components/Tables/index.js index d74572c9879..bbe15d18c8d 100644 --- a/src/fireedge/src/client/components/Tables/index.js +++ b/src/fireedge/src/client/components/Tables/index.js @@ -34,6 +34,7 @@ import SkeletonTable from 'client/components/Tables/Skeleton' import UsersTable from 'client/components/Tables/Users' import VirtualizedTable from 'client/components/Tables/Virtualized' import VmsTable from 'client/components/Tables/Vms' +import VmGroupsTable from 'client/components/Tables/VmGroups' import VmTemplatesTable from 'client/components/Tables/VmTemplates' import VNetworksTable from 'client/components/Tables/VNetworks' import VNetworkTemplatesTable from 'client/components/Tables/VNetworkTemplates' @@ -66,6 +67,7 @@ export { UsersTable, VDCsTable, VmsTable, + VmGroupsTable, VmTemplatesTable, VNetworksTable, VNetworkTemplatesTable, diff --git a/src/fireedge/src/client/components/Tabs/Common/Roles.js b/src/fireedge/src/client/components/Tabs/Common/Roles.js new file mode 100644 index 00000000000..025d652d968 --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/Common/Roles.js @@ -0,0 +1,187 @@ +/* ------------------------------------------------------------------------- * + * 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 makeStyles from '@mui/styles/makeStyles' +import { Tr, Translate } from 'client/components/HOC' +import { PrettyVmGroupRole, T } from 'client/constants' +import PropTypes from 'prop-types' +import { ReactElement, memo, useMemo } from 'react' +import { v4 as uuidv4 } from 'uuid' + +import { Box, List, ListItem, Paper, Typography, styled } from '@mui/material' + +const Title = styled(ListItem)(({ theme }) => ({ + fontWeight: theme.typography.fontWeightBold, + borderBottom: `1px solid ${theme.palette.divider}`, +})) + +const Item = styled(ListItem)(({ theme }) => ({ + gap: '1em', + '& > *': { + flex: '1 1 50%', + overflow: 'hidden', + minHeight: '100%', + }, + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, +})) + +const useStyles = makeStyles({ + container: { + gridColumn: '1 / -1', + }, + item: { + '& > *:first-child': { + flex: '1 1 20%', + }, + }, +}) + +const RolesVmGroupsTable = memo(({ title, roles }) => { + const classes = useStyles() + + return ( + + + {title && ( + + {typeof title === 'string' ? ( + {Tr(title)} + ) : ( + title + )} + + )} + + + + + + ) +}) + +RolesVmGroupsTable.propTypes = { + title: PropTypes.any, + roles: PropTypes.arrayOf( + PropTypes.shape({ + ID: PropTypes.string, + NAME: PropTypes.string, + HOST_AFFINITY: PropTypes.string, + HOST_ANTI_AFFINED: PropTypes.string, + POLICY: PropTypes.string, + }) + ), +} + +RolesVmGroupsTable.displayName = 'RolesVmGroupsTable' + +export const VmGroupRoles = memo(({ parentKey = '', roles }) => { + const COLUMNS = useMemo( + () => [T.Name, T.HostAffined, T.HostAntiAffined, T.VmAffinity], + [] + ) + + return ( + + {COLUMNS.map((col) => ( + + + + ))} + {roles.map((role) => ( + + ))} + + ) +}) + +VmGroupRoles.propTypes = { + parentKey: PropTypes.string, + id: PropTypes.string, + roles: PropTypes.array, + actions: PropTypes.node, +} + +VmGroupRoles.displayName = 'VmGroupRole' + +export const VmGroupRole = memo(({ role = {}, 'data-cy': parentCy }) => { + /** @type {PrettyVmGroupRole} */ + const { + NAME = '', + HOST_AFFINED = '', + HOST_ANTI_AFFINED = '', + POLICY = '', + } = role + + /** + * @param {object} role - role. + * @param {string} role.text - role text + * @param {string} role.dataCy - role data-cy + * @returns {ReactElement} role line + */ + const renderLine = ({ text, dataCy }) => ( + + {text} + + ) + + /** + * @param {string} policy - Policy identifier. + * @returns {string} formatted policy identifier. + */ + const formatPolicy = (policy) => policy.split('_').join(' ').toUpperCase() + + return ( + <> + {[ + { text: String(NAME), dataCy: 'name' }, + { text: String(HOST_AFFINED), dataCy: 'hostaffinity' }, + { + text: String(HOST_ANTI_AFFINED), + dataCy: 'hostantiaffinity', + }, + + { text: String(formatPolicy(POLICY)), dataCy: 'vmaffinity' }, + ].map(renderLine)} + > + ) +}) + +VmGroupRole.propTypes = { + role: PropTypes.object, + 'data-cy': PropTypes.string, +} + +VmGroupRole.displayName = 'VmGroupRole' + +export default RolesVmGroupsTable diff --git a/src/fireedge/src/client/components/Tabs/Common/RolesAffinity.js b/src/fireedge/src/client/components/Tabs/Common/RolesAffinity.js new file mode 100644 index 00000000000..4c15037ac44 --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/Common/RolesAffinity.js @@ -0,0 +1,183 @@ +/* ------------------------------------------------------------------------- * + * 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 makeStyles from '@mui/styles/makeStyles' +import { Tr, Translate } from 'client/components/HOC' +import { T } from 'client/constants' +import PropTypes from 'prop-types' +import { ReactElement, memo, useMemo } from 'react' +import { v4 as uuidv4 } from 'uuid' + +import { Box, List, ListItem, Paper, Typography, styled } from '@mui/material' + +const Title = styled(ListItem)(({ theme }) => ({ + fontWeight: theme.typography.fontWeightBold, + borderBottom: `1px solid ${theme.palette.divider}`, +})) + +const Item = styled(ListItem)(({ theme }) => ({ + gap: '1em', + '& > *': { + flex: '1 1 50%', + overflow: 'hidden', + minHeight: '100%', + }, + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, +})) + +const useStyles = makeStyles({ + container: { + gridColumn: '1 / -1', + }, + item: { + '& > *:first-child': { + flex: '1 1 20%', + }, + }, +}) + +const RolesAffinityVmGroupsTable = memo(({ title, roles = [] }) => { + const classes = useStyles() + + return ( + + + {title && ( + + {typeof title === 'string' ? ( + {Tr(title)} + ) : ( + title + )} + + )} + + + + + + ) +}) + +RolesAffinityVmGroupsTable.propTypes = { + title: PropTypes.any, + roles: PropTypes.oneOfType([ + PropTypes.arrayOf( + PropTypes.shape({ + AFFINED: PropTypes.string, + ANTI_AFFINED: PropTypes.string, + }) + ), + PropTypes.object, + ]), +} + +RolesAffinityVmGroupsTable.displayName = 'RolesAffinityVmGroupsTable' + +export const VmGroupAffinityRoles = memo(({ parentKey = '', roles }) => { + const COLUMNS = useMemo(() => [T.Roles, T.VmAffinity], []) + const groupTypes = ['AFFINED', 'ANTI_AFFINED'] + + return ( + + {COLUMNS.map((col) => ( + + + + ))} + {groupTypes?.map((TYPE, index) => ( + + ))} + + ) +}) + +VmGroupAffinityRoles.propTypes = { + parentKey: PropTypes.string, + id: PropTypes.string, + roles: PropTypes.array, + actions: PropTypes.node, +} + +VmGroupAffinityRoles.displayName = 'VmGroupAffinityRole' + +export const VmGroupAffinityRole = memo( + ({ role = {}, 'data-cy': parentCy }) => { + /** + * @param {object} item - item. + * @param {string} item.text - item text + * @param {string} item.dataCy - item data-cy + * @returns {ReactElement} item line + */ + const renderLine = ({ text, dataCy }) => ( + + {text} + + ) + + /** + * @param {string} policy - Policy identifier. + * @returns {string} formatted policy identifier. + */ + const formatPolicy = (policy) => policy.split('_').join(' ') + + return ( + <> + {Object.entries(role).flatMap(([TYPE, ROLES]) => { + const groups = Array.isArray(ROLES) ? ROLES : [ROLES] + + return groups.map((group, groupIndex) => [ + renderLine({ + text: group, + dataCy: `${TYPE.toLowerCase()}-${groupIndex}-group`, + }), + renderLine({ + text: formatPolicy(TYPE), + dataCy: `${TYPE.toLowerCase()}-${groupIndex}-type`, + }), + ]) + })} + > + ) + } +) + +VmGroupAffinityRole.propTypes = { + role: PropTypes.object, + 'data-cy': PropTypes.string, +} + +VmGroupAffinityRole.displayName = 'VmGroupAffinityRole' + +export default RolesAffinityVmGroupsTable diff --git a/src/fireedge/src/client/components/Tabs/Common/index.js b/src/fireedge/src/client/components/Tabs/Common/index.js index 5d7cad83cc4..3c9255ac5ff 100644 --- a/src/fireedge/src/client/components/Tabs/Common/index.js +++ b/src/fireedge/src/client/components/Tabs/Common/index.js @@ -18,7 +18,17 @@ import List from 'client/components/Tabs/Common/List' import Ownership from 'client/components/Tabs/Common/Ownership' import Permissions from 'client/components/Tabs/Common/Permissions' import RulesSecGroupsTable from 'client/components/Tabs/Common/RulesSecGroups' +import RolesVmGroupsTable from 'client/components/Tabs/Common/Roles' +import RolesAffinityVmGroupsTable from 'client/components/Tabs/Common/RolesAffinity' export * from 'client/components/Tabs/Common/Attribute' -export { AttributePanel, List, Ownership, Permissions, RulesSecGroupsTable } +export { + AttributePanel, + List, + Ownership, + Permissions, + RulesSecGroupsTable, + RolesVmGroupsTable, + RolesAffinityVmGroupsTable, +} diff --git a/src/fireedge/src/client/components/Tabs/User/Showback.js b/src/fireedge/src/client/components/Tabs/User/Showback.js index 63c53971dfb..76176d146fe 100644 --- a/src/fireedge/src/client/components/Tabs/User/Showback.js +++ b/src/fireedge/src/client/components/Tabs/User/Showback.js @@ -72,7 +72,7 @@ const topMetricNames = { } const commonStyles = { - minHeight: '250px', + minHeight: '350px', width: '100%', position: 'relative', marginTop: 2, @@ -251,7 +251,7 @@ const ShowbackInfoTab = ({ id }) => { - + { + const { + information_panel: informationPanel, + permissions_panel: permissionsPanel, + ownership_panel: ownershipPanel, + roles_panel: rolesPanel, + 'roles-affinity_panel': rolesAffinityPanel, + } = tabProps + + const [changePermissions] = useChangeVMGroupPermissionsMutation() + const [changeOwnership] = useChangeVMGroupOwnershipMutation() + const { data: vmgroup = {} } = useGetVMGroupQuery({ id }) + const { UNAME, UID, GNAME, GID, ROLES, PERMISSIONS, TEMPLATE } = vmgroup + + const handleChangeOwnership = async (newOwnership) => { + await changeOwnership({ id, ...newOwnership }) + } + + const handleChangePermission = async (newPermission) => { + await changePermissions({ id, ...newPermission }) + } + + const getActions = (actions) => Helper.getActionsAvailable(actions) + + return ( + + {informationPanel?.enabled && ( + + )} + {permissionsPanel?.enabled && ( + + )} + {ownershipPanel?.enabled && ( + + )} + {rolesPanel?.enabled && ( + + )} + {rolesAffinityPanel?.enabled && ( + + )} + + ) +} + +VmGroupInfoTab.propTypes = { + tabProps: PropTypes.object, + id: PropTypes.string, +} + +VmGroupInfoTab.displayName = 'VmGroupInfoTab' + +export default VmGroupInfoTab diff --git a/src/fireedge/src/client/components/Tabs/VmGroup/Info/information.js b/src/fireedge/src/client/components/Tabs/VmGroup/Info/information.js new file mode 100644 index 00000000000..2df16c019a5 --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/VmGroup/Info/information.js @@ -0,0 +1,79 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); you may * + * not use this file except in compliance with the License. You may obtain * + * a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + * ------------------------------------------------------------------------- */ +import { ReactElement } from 'react' +import PropTypes from 'prop-types' + +import { List } from 'client/components/Tabs/Common' +import { useRenameVMGroupMutation } from 'client/features/OneApi/vmGroup' + +import { T, VMGROUP_ACTIONS, VmGroup } from 'client/constants' + +/** + * Renders mainly information tab. + * + * @param {object} props - Props + * @param {VmGroup} props.template - Template + * @param {string[]} props.actions - Available actions to information tab + * @returns {ReactElement} Information tab + */ +const InformationPanel = ({ template = {}, actions }) => { + const [renameTemplate] = useRenameVMGroupMutation() + + const { ID, NAME, LOCK, TEMPLATE = {} } = template + const { DESCRIPTION } = TEMPLATE ?? '' + + const handleRename = async (_, newName) => { + await renameTemplate({ id: ID, name: newName }) + } + + const info = [ + { name: T.ID, value: ID, dataCy: 'id' }, + { + name: T.Name, + value: NAME, + canEdit: actions?.includes?.(VMGROUP_ACTIONS.RENAME), + handleEdit: handleRename, + dataCy: 'name', + }, + { + name: T.Locked, + value: LOCK?.LOCKED ? 'Yes' : 'No', + dataCy: 'locked', + }, + DESCRIPTION && { + name: T.Description, + value: DESCRIPTION, + dataCy: 'description', + }, + ].filter(Boolean) + + return ( + + ) +} + +InformationPanel.displayName = 'InformationPanel' + +InformationPanel.propTypes = { + actions: PropTypes.arrayOf(PropTypes.string), + template: PropTypes.object, +} + +export default InformationPanel diff --git a/src/fireedge/src/client/components/Tabs/VmGroup/Vms/index.js b/src/fireedge/src/client/components/Tabs/VmGroup/Vms/index.js new file mode 100644 index 00000000000..8d55e2fe9ca --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/VmGroup/Vms/index.js @@ -0,0 +1,136 @@ +/* ------------------------------------------------------------------------- * + * 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 { LoadingDisplay } from 'client/components/LoadingState' +import { MultiChart } from 'client/components/Charts' +import { + transformApiResponseToDataset, + filterDataset, +} from 'client/components/Charts/MultiChart/helpers/scripts' +import { useGetVmsQuery } from 'client/features/OneApi/vm' +import { useGetVMGroupQuery } from 'client/features/OneApi/vmGroup' +import { Box } from '@mui/material' +import { Component } from 'react' +import { useGeneralApi } from 'client/features/General' +import { VM_STATES } from 'client/constants' +const keyMap = { + ID: 'ID', + NAME: 'NAME', + UNAME: 'OWNER', + GNAME: 'GROUP', + STATE: 'STATUS', +} + +const DataGridColumns = [ + { field: 'ID', headerName: 'ID', flex: 1 }, + { field: 'NAME', headerName: 'Name', flex: 1 }, + { field: 'OWNER', headerName: 'Owner', flex: 1 }, + { field: 'GROUP', headerName: 'Group', flex: 1 }, + { + field: 'STATUS', + headerName: 'State', + flex: 1, + valueFormatter: (params) => + VM_STATES?.[+params?.value]?.name?.split('_')?.join(' ') ?? 'UNKNOWN', + }, +] + +const commonStyles = { + minHeight: '250px', + width: '100%', + position: 'relative', + marginTop: 2, +} + +/** + * VmsInfoTab component displays showback information for a user. + * + * @param {object} props - Component properties. + * @param {string} props.id - User ID. + * @returns {Component} Rendered component. + */ +const VmsInfoTab = ({ id }) => { + const { enqueueError } = useGeneralApi() + const roleData = useGetVMGroupQuery({ id })?.data?.ROLES?.ROLE + const includedVms = [ + ...new Set( + (Array.isArray(roleData) ? roleData : [roleData]) + .flatMap((role) => role?.VMS) + .filter(Boolean) + ), + ] + + includedVms?.isError && + enqueueError('Failed to fetch vm groups, displaying all VMs') + const startMonth = -2 + const startYear = -2 + const endMonth = -2 + const endYear = -2 + + const queryData = useGetVmsQuery({ + startMonth, + startYear, + endMonth, + endYear, + }) + + const metricKeys = ['ID'] + + const isLoading = queryData.isLoading + let error + + let filteredResult + + if (!isLoading && queryData.isSuccess) { + const transformedResult = transformApiResponseToDataset( + queryData, + keyMap, + metricKeys + ) + error = transformedResult.error + + filteredResult = filterDataset(transformedResult.dataset, ({ ID }) => + includedVms.includes(ID) + )?.dataset + } + + if (isLoading || error) { + return + } + + return ( + + + + + + ) +} + +VmsInfoTab.propTypes = { + tabProps: PropTypes.object, + id: PropTypes.string, +} + +VmsInfoTab.displayName = 'VmsInfoTab' + +export default VmsInfoTab diff --git a/src/fireedge/src/client/components/Tabs/VmGroup/index.js b/src/fireedge/src/client/components/Tabs/VmGroup/index.js new file mode 100644 index 00000000000..6d73e527277 --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/VmGroup/index.js @@ -0,0 +1,64 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); you may * + * not use this file except in compliance with the License. You may obtain * + * a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + * ------------------------------------------------------------------------- */ +import { Alert, LinearProgress } from '@mui/material' +import PropTypes from 'prop-types' +import { memo, useMemo } from 'react' + +import { RESOURCE_NAMES } from 'client/constants' +import { useViews } from 'client/features/Auth' +import { useGetVMGroupQuery } from 'client/features/OneApi/vmGroup' +import { getAvailableInfoTabs } from 'client/models/Helper' + +import Tabs from 'client/components/Tabs' +import Info from 'client/components/Tabs/VmGroup//Info' +import Vms from 'client/components/Tabs/VmGroup//Vms' + +const getTabComponent = (tabName) => + ({ + info: Info, + vms: Vms, + }[tabName]) + +const VMGroupTabs = memo(({ id }) => { + const { view, getResourceView } = useViews() + const { isError, status, error, data } = useGetVMGroupQuery({ id }) + + const tabsAvailable = useMemo(() => { + const resource = RESOURCE_NAMES.VM_GROUP + const infoTabs = getResourceView(resource)?.['info-tabs'] ?? {} + + return getAvailableInfoTabs(infoTabs, getTabComponent, id) + }, [view, id]) + + if (isError) { + return ( + + {error.data} + + ) + } + + if (status[0] === 'fulfilled' || id === data?.ID) { + return + } + + return +}) + +VMGroupTabs.propTypes = { id: PropTypes.string.isRequired } +VMGroupTabs.displayName = 'VMGroupTabs' + +export default VMGroupTabs diff --git a/src/fireedge/src/client/constants/index.js b/src/fireedge/src/client/constants/index.js index 51273cba1a7..4baac1fdb6e 100644 --- a/src/fireedge/src/client/constants/index.js +++ b/src/fireedge/src/client/constants/index.js @@ -174,6 +174,7 @@ export const RESOURCE_NAMES = { VDC: 'virtual-data-center', VROUTER: 'virtual-router', VM_TEMPLATE: 'vm-template', + VM_GROUP: 'vm-group', VM: 'vm', VN_TEMPLATE: 'network-template', VNET: 'virtual-network', @@ -206,4 +207,5 @@ export * from 'client/constants/userInput' export * from 'client/constants/vdc' export * from 'client/constants/vm' export * from 'client/constants/vmTemplate' +export * from 'client/constants/vmGroup' export * from 'client/constants/zone' diff --git a/src/fireedge/src/client/constants/translates.js b/src/fireedge/src/client/constants/translates.js index df5282b84e8..1ada8ff0c92 100644 --- a/src/fireedge/src/client/constants/translates.js +++ b/src/fireedge/src/client/constants/translates.js @@ -860,6 +860,15 @@ module.exports = { MemoryResizeMode: 'Memory resize mode', MemorySlots: 'Memory slots', /* VM Template schema - VM Group */ + UpdateVmGroup: 'Update VM Group', + CreateVmGroup: 'Create VM group', + HostAffined: 'Host Affined', + HostAntiAffined: 'Host Anti-Affined', + Affined: 'Affined', + AntiAffined: 'Anti-Affined', + Policy: 'Policy', + VmAffinity: 'VM Affinity', + RolesAffinity: 'Roles Affinity', AssociateToVMGroup: 'Associate VM to a VM Group', /* VM Template schema - vCenter */ vCenterTemplateRef: 'vCenter Template reference', diff --git a/src/fireedge/src/client/constants/vmGroup.js b/src/fireedge/src/client/constants/vmGroup.js new file mode 100644 index 00000000000..b0cd2596d89 --- /dev/null +++ b/src/fireedge/src/client/constants/vmGroup.js @@ -0,0 +1,49 @@ +/* ------------------------------------------------------------------------- * + * 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' +import * as STATES from 'client/constants/states' +import COLOR from 'client/constants/color' + +/** + * @typedef VMGROUP + * @property {string|number} ID - Id + * @property {string} NAME - Name + * @property {object} TEMPLATE - Template information + */ + +export const VMGROUP_STATES = [ + { + name: STATES.ENABLED, + shortName: 'on', + color: COLOR.success.main, + }, + { + name: STATES.DISABLED, + shortName: 'off', + color: COLOR.error.dark, + }, +] + +export const VMGROUP_ACTIONS = { + CREATE_DIALOG: 'create_dialog', + UPDATE_DIALOG: 'update_dialog', + DELETE: 'delete', + CHANGE_GROUP: ACTIONS.CHANGE_GROUP, + CHANGE_OWNER: ACTIONS.CHANGE_OWNER, + ENABLE: 'enable', + DISABLE: 'disable', + RENAME: ACTIONS.RENAME, +} diff --git a/src/fireedge/src/client/containers/VmGroups/Create.js b/src/fireedge/src/client/containers/VmGroups/Create.js new file mode 100644 index 00000000000..e09b9f9ea7e --- /dev/null +++ b/src/fireedge/src/client/containers/VmGroups/Create.js @@ -0,0 +1,222 @@ +/* ------------------------------------------------------------------------- * + * 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, useCallback, useEffect } from 'react' +import { useHistory, useLocation } from 'react-router' +import { + DefaultFormStepper, + SkeletonStepsForm, +} from 'client/components/FormStepper' +import { CreateForm } from 'client/components/Forms/VmGroup' +import { + useAddVMGroupRoleMutation, + useAllocateVMGroupMutation, + useDeleteVMGroupRoleMutation, + useGetVMGroupQuery, + useUpdateVMGroupMutation, + useUpdateVMGroupRoleMutation, +} from 'client/features/OneApi/vmGroup' +import { jsonToXml } from 'client/models/Helper' +import { PATH } from 'client/apps/sunstone/routesOne' +import { useGeneralApi } from 'client/features/General' +import { isEqual } from 'lodash' +import { isDevelopment } from 'client/utils' + +/** + * Compares two role objects while ignoring the ID property of the original role. + * + * @param {object} originalRole - The original role object. + * @param {object} updatedRole - The updated role object. + * @returns {boolean} - True if the roles are equal (ignoring the ID), false otherwise. + */ +const compareRoles = (originalRole, updatedRole) => { + const originalCopy = { ...originalRole } + delete originalCopy.ID + + return isEqual(originalCopy, updatedRole) +} + +/** + * Processes the role differences and returns categorized roles. + * + * @param {Array} originalRoles - The original roles. + * @param {Array} newRoles - The updated roles. + * @returns {object} - An object containing arrays of added, updated, and removed roles. + */ +const categorizeRoles = (originalRoles, newRoles) => { + const wrappedOriginalRoles = Array.isArray(originalRoles) + ? originalRoles + : [originalRoles] + + const wrappedNewRoles = Array.isArray(newRoles) ? newRoles : [newRoles] + + const originalMap = new Map( + wrappedOriginalRoles?.map((role) => [role.NAME, role]) + ) + const updatedMap = new Map(wrappedNewRoles?.map((role) => [role.NAME, role])) + + const addedRoles = [] + const updatedRoles = [] + const removedRoles = [] + + for (const [name, updatedRole] of updatedMap) { + const originalRole = originalMap?.get(name) + if (!originalRole) { + addedRoles?.push(updatedRole) + } else if (!compareRoles(originalRole, updatedRole)) { + updatedRoles?.push({ original: originalRole, updated: updatedRole }) + } + originalMap?.delete(name) + } + + for (const removedRole of originalMap?.values()) { + removedRoles?.push(removedRole) + } + + return { addedRoles, updatedRoles, removedRoles } +} + +/** + * Displays the creation form for a VmGroup. + * + * @returns {Component} VmGroup form. + */ +function CreateVmGroup() { + const history = useHistory() + const { state: { ID: templateId, NAME } = {} } = useLocation() + const { enqueueSuccess, enqueueError } = useGeneralApi() + const [createVmGroup] = useAllocateVMGroupMutation() + const [updateVmGroup] = useUpdateVMGroupMutation() + const [addNewRole] = useAddVMGroupRoleMutation() + const [updateRoleProperty] = useUpdateVMGroupRoleMutation() + const [removeRoleProperty] = useDeleteVMGroupRoleMutation() + const updatedRoleProperties = [ + 'POLICY', + 'NAME', + 'HOST_AFFINED', + 'HOST_ANTI_AFFINED', + ] + + const { data, error } = useGetVMGroupQuery( + { id: templateId, extended: true }, + { skip: templateId === undefined } + ) + + useEffect(() => { + if (error) { + enqueueError(`Failed to fetch VM Group data: ${error.message}`) + } + }, [error]) + + const findDifferencesAndUpdate = useCallback( + async (vmGroupID, original, updated, props) => { + const { addedRoles, updatedRoles, removedRoles } = categorizeRoles( + original, + updated + ) + + const addRolesPromises = addedRoles.map((addedRole) => + addNewRole({ + id: vmGroupID, + template: jsonToXml({ ROLE: addedRole }), + }) + ) + + const updateRolesPromises = updatedRoles.map( + ({ original: originalRole, updated: updatedRole }) => + updatedRoleProperties.reduce(async (_acc, key) => { + if (updatedRole[key] !== originalRole[key]) { + await updateRoleProperty({ + id: vmGroupID, + roleId: originalRole.ID, + template: jsonToXml({ ROLE: updatedRole }), + }) + } + }, Promise.resolve()) + ) + + await Promise.all([...addRolesPromises, ...updateRolesPromises]) + + // eslint-disable-next-line react/prop-types + const { TEMPLATE, ...rest } = props + await updateVmGroup({ + id: templateId, + template: jsonToXml({ ...rest, ...TEMPLATE }), + }).unwrap() + + const removeRolesPromises = removedRoles.map((removedRole) => + removeRoleProperty({ id: vmGroupID, roleId: removedRole.ID }) + ) + + await Promise.all(removeRolesPromises) + }, + [addNewRole, updateRoleProperty, removeRoleProperty, updateVmGroup] + ) + + const onSubmit = useCallback( + async (props) => { + try { + // eslint-disable-next-line react/prop-types + const { TEMPLATE, ...rest } = props + if (!templateId) { + const newVmGroupId = await createVmGroup({ + template: jsonToXml({ ...rest, ...TEMPLATE }), + }).unwrap() + history.push(PATH.TEMPLATE.VMGROUP.LIST) + enqueueSuccess(`VM group created - #${newVmGroupId}`) + } else { + const originalRoles = data?.ROLES?.ROLE + // eslint-disable-next-line react/prop-types + const newRoles = props?.ROLE + await findDifferencesAndUpdate( + templateId, + originalRoles, + newRoles, + props + ) + + history.push(PATH.TEMPLATE.VMGROUP.LIST) + enqueueSuccess(`VM group updated - #${templateId} ${NAME}`) + } + } catch (error) { + isDevelopment() && console.error(`Error in VM group form: ${error}`) + } + }, + [ + templateId, + createVmGroup, + history, + enqueueSuccess, + findDifferencesAndUpdate, + enqueueError, + NAME, + ] + ) + + return templateId && !data ? ( + + ) : ( + } + > + {(config) => } + + ) +} + +export default CreateVmGroup diff --git a/src/fireedge/src/client/containers/VmGroups/index.js b/src/fireedge/src/client/containers/VmGroups/index.js new file mode 100644 index 00000000000..b51c11b79c2 --- /dev/null +++ b/src/fireedge/src/client/containers/VmGroups/index.js @@ -0,0 +1,161 @@ +/* ------------------------------------------------------------------------- * + * 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, useState, memo } from 'react' +import PropTypes from 'prop-types' +import GotoIcon from 'iconoir-react/dist/Pin' +import RefreshDouble from 'iconoir-react/dist/RefreshDouble' +import Cancel from 'iconoir-react/dist/Cancel' +import { Typography, Box, Stack, Chip } from '@mui/material' +import { Row } from 'react-table' + +import { + useLazyGetVMGroupQuery, + useUpdateVMGroupMutation, +} from 'client/features/OneApi/vmGroup' +import { VmGroupsTable } from 'client/components/Tables' +import VmGroupTabs from 'client/components/Tabs/VmGroup' +import VmGroupActions from 'client/components/Tables/VmGroups/actions' +import SplitPane from 'client/components/SplitPane' +import MultipleTags from 'client/components/MultipleTags' +import { SubmitButton } from 'client/components/FormControl' +import { Tr } from 'client/components/HOC' +import { T, VmGroup } from 'client/constants' + +/** + * Displays a list of VmGroups with a split pane between the list and selected row(s). + * + * @returns {ReactElement} VmGroups list and selected row(s) + */ +function VmGroups() { + const [selectedRows, onSelectedRowsChange] = useState(() => []) + const actions = VmGroupActions() + + const hasSelectedRows = selectedRows?.length > 0 + const moreThanOneSelected = selectedRows?.length > 1 + + return ( + + {({ getGridProps, GutterComponent }) => ( + + + + {hasSelectedRows && ( + <> + + {moreThanOneSelected ? ( + + ) : ( + selectedRows[0]?.toggleRowSelected(false)} + /> + )} + > + )} + + )} + + ) +} + +/** + * Displays details of an VmGroup. + * + * @param {VmGroup} user - VmGroup to display + * @param {Function} [gotoPage] - Function to navigate to a page of an VmGroup + * @param {Function} [unselect] - Function to unselect a VmGroup + * @returns {ReactElement} VmGroup details + */ +const InfoTabs = memo(({ user, gotoPage, unselect }) => { + const [get, { data: lazyData, isFetching }] = useLazyGetVMGroupQuery() + const id = lazyData?.ID ?? user.ID + const name = lazyData?.NAME ?? user.NAME + + return ( + + + + {`#${id} | ${name}`} + + + } + tooltip={Tr(T.Refresh)} + isSubmitting={isFetching} + onClick={() => get({ id })} + /> + {typeof gotoPage === 'function' && ( + } + tooltip={Tr(T.LocateOnTable)} + onClick={() => gotoPage()} + /> + )} + {typeof unselect === 'function' && ( + } + tooltip={Tr(T.Close)} + onClick={() => unselect()} + /> + )} + + + + ) +}) + +InfoTabs.propTypes = { + user: PropTypes.object, + gotoPage: PropTypes.func, + unselect: PropTypes.func, +} + +InfoTabs.displayName = 'InfoTabs' + +/** + * Displays a list of tags that represent the selected rows. + * + * @param {Row[]} tags - Row(s) to display as tags + * @returns {ReactElement} List of tags + */ +const GroupedTags = memo(({ tags = [] }) => ( + + ( + toggleRowSelected(false)} + /> + ))} + /> + +)) + +GroupedTags.propTypes = { tags: PropTypes.array } +GroupedTags.displayName = 'GroupedTags' + +export default VmGroups diff --git a/src/fireedge/src/client/features/General/actions.js b/src/fireedge/src/client/features/General/actions.js index e48b18ed483..b552f2367da 100644 --- a/src/fireedge/src/client/features/General/actions.js +++ b/src/fireedge/src/client/features/General/actions.js @@ -20,6 +20,7 @@ export const changeZone = createAction('Change zone') export const changeLoading = createAction('Change loading') export const changeAppTitle = createAction('Change App title') export const setSelectedIds = createAction('Set selected IDs') +export const setUpdateDialog = createAction('Set update dialog') export const updateDisabledSteps = createAction('Set disabled steps') export const dismissSnackbar = createAction('Dismiss snackbar') diff --git a/src/fireedge/src/client/features/General/hooks.js b/src/fireedge/src/client/features/General/hooks.js index d09434ddb3b..7efddaa7588 100644 --- a/src/fireedge/src/client/features/General/hooks.js +++ b/src/fireedge/src/client/features/General/hooks.js @@ -31,8 +31,9 @@ export const useGeneralApi = () => { changeLoading: (isLoading) => dispatch(actions.changeLoading(isLoading)), changeAppTitle: (appTitle) => dispatch(actions.changeAppTitle(appTitle)), changeZone: (zone) => dispatch(actions.changeZone(zone)), - uploadSnackbar: (percent) => dispatch(actions.setUploadSnackbar(percent)), + setUpdateDialog: (updateDialog) => + dispatch(actions.setUpdateDialog(updateDialog)), // dismiss all if no key has been defined dismissSnackbar: (key) => diff --git a/src/fireedge/src/client/features/General/slice.js b/src/fireedge/src/client/features/General/slice.js index 77e1359d495..3313b109b33 100644 --- a/src/fireedge/src/client/features/General/slice.js +++ b/src/fireedge/src/client/features/General/slice.js @@ -27,6 +27,7 @@ const initial = { withGroupSwitcher: false, isLoading: false, isFixMenu: false, + isUpdateDialog: false, upload: 0, notifications: [], selectedIds: [], @@ -73,6 +74,10 @@ const slice = createSlice({ .addCase(actions.updateDisabledSteps, (state, { payload }) => { state.disabledSteps = payload }) + .addCase(actions.setUpdateDialog, (state, { payload }) => { + state.isUpdateDialog = !!payload + }) + /* UPLOAD NOTIFICATION */ .addCase(actions.setUploadSnackbar, (state, { payload }) => ({ ...state, diff --git a/src/fireedge/src/client/features/OneApi/index.js b/src/fireedge/src/client/features/OneApi/index.js index 9bbcab226ca..75bf5ab7c0c 100644 --- a/src/fireedge/src/client/features/OneApi/index.js +++ b/src/fireedge/src/client/features/OneApi/index.js @@ -36,7 +36,7 @@ const ONE_RESOURCES = { USER: 'USER', VDC: 'VDC', VM: 'VM', - VMGROUP: 'VMGROUP', + VMGROUP: 'VM_GROUP', VNET: 'VNET', VNTEMPLATE: 'VNTEMPLATE', VROUTER: 'VROUTER', diff --git a/src/fireedge/src/client/features/OneApi/vmGroup.js b/src/fireedge/src/client/features/OneApi/vmGroup.js index e229e2ab32e..599bef2b3ab 100644 --- a/src/fireedge/src/client/features/OneApi/vmGroup.js +++ b/src/fireedge/src/client/features/OneApi/vmGroup.js @@ -20,6 +20,12 @@ import { ONE_RESOURCES_POOL, } from 'client/features/OneApi' import { FilterFlag } from 'client/constants' +import { + updateNameOnResource, + updateOwnershipOnResource, + updatePermissionOnResource, + updateTemplateOnResource, +} from 'client/features/OneApi/common' const { VMGROUP } = ONE_RESOURCES const { VMGROUP_POOL } = ONE_RESOURCES_POOL @@ -69,15 +75,326 @@ const vmGroupApi = oneApi.injectEndpoints({ return { params, command } }, transformResponse: (data) => data?.VM_GROUP ?? {}, - providesTags: (_, __, arg) => ({ type: VMGROUP, id: arg }), + providesTags: (_, __, arg) => [{ type: VMGROUP, id: arg.id }], + }), + /** + * Adds a role to a already defined vm group. + * + * @param {string|number} id - VM group id + * @param {string} template - VM group role template + * @returns {number} VM group id + * @throws Fails when response isn't code 200 + */ + addVMGroupRole: builder.mutation({ + query: (params) => { + const name = Actions.VM_GROUP_ROLEADD + const command = { name, ...Commands[name] } + + return { params, command } + }, + invalidatesTags: (_, __, { id }) => [{ type: VMGROUP, id }], + }), + /** + * Updates a already defined role in an existing vm group. + * + * @param {string|number} id - VM group id + * @param {string|number} roleId - Update role id + * @param {string} template - Updated role template + * @returns {number} VM role ID + */ + updateVMGroupRole: builder.mutation({ + query: (params) => { + const name = Actions.VM_GROUP_ROLEUPDATE + const command = { name, ...Commands[name] } + + return { params, command } + }, + invalidatesTags: (_, __, { id }) => [{ type: VMGROUP, id }], + }), + /** + * Deletes a role from a vm group. + * + * @param {string|number} id - Vm group id + * @param {string|number} roleId - Delete role id + * @returns {number} Deleted role id + */ + deleteVMGroupRole: builder.mutation({ + query: (params) => { + const name = Actions.VM_GROUP_ROLEDELETE + const command = { name, ...Commands[name] } + + return { params, command } + }, + invalidatesTags: (_, __, { id }) => [{ type: VMGROUP, id }], + }), + lockVMGroup: builder.mutation({ + /** + * Locks a VM group. + * + * @param {string|number} id - VM group id + * @returns {number} VM group id + * @throws Fails when response isn't code 200 + */ + query: (id) => { + const name = Actions.VM_GROUP_LOCK + const command = { name, ...Commands[name] } + + return { params: { id }, command } + }, + invalidatesTags: (_, __, id) => [{ type: VMGROUP, id }, VMGROUP_POOL], + }), + unlockVMGroup: builder.mutation({ + /** + * Unlocks a VM group. + * + * @param {string|number} id - VM group id + * @returns {number} VM group id + * @throws Fails when response isn't code 200 + */ + query: (id) => { + const name = Actions.VM_GROUP_UNLOCK + const command = { name, ...Commands[name] } + + return { params: { id }, command } + }, + invalidatesTags: (_, __, id) => [{ type: VMGROUP, id }, VMGROUP_POOL], + }), + renameVMGroup: builder.mutation({ + /** + * Renames a VM group. + * + * @param {object} params - Request parameters + * @param {string|number} params.id - VM group id + * @param {string} params.name - The new name + * @returns {number} VM group id + * @throws Fails when response isn't code 200 + */ + query: (params) => { + const name = Actions.VM_GROUP_RENAME + const command = { name, ...Commands[name] } + + return { params, command } + }, + invalidatesTags: (_, __, { id }) => [{ type: VMGROUP, id }, VMGROUP_POOL], + async onQueryStarted(params, { dispatch, queryFulfilled }) { + try { + const patchVMGroup = dispatch( + vmGroupApi.util.updateQueryData( + 'getVMGroup', + { id: params.id }, + updateNameOnResource(params) + ) + ) + + const patchVMGroups = dispatch( + vmGroupApi.util.updateQueryData( + 'getVMGroups', + undefined, + updateNameOnResource(params) + ) + ) + + queryFulfilled.catch(() => { + patchVMGroup.undo() + patchVMGroups.undo() + }) + } catch {} + }, + }), + changeVMGroupOwnership: builder.mutation({ + /** + * Changes the ownership of a VM group. + * If set to `-1`, the user or group aren't changed. + * + * @param {object} params - Request parameters + * @param {string|number} params.id - VM group id + * @param {string|number|'-1'} [params.userId] - User id + * @param {string|number|'-1'} [params.groupId] - Group id + * @returns {number} VM group id + * @throws Fails when response isn't code 200 + */ + query: (params) => { + const name = Actions.VM_GROUP_CHOWN + const command = { name, ...Commands[name] } + + return { params, command } + }, + invalidatesTags: (_, __, id) => [{ type: VMGROUP, id }], + async onQueryStarted(params, { getState, dispatch, queryFulfilled }) { + try { + const patchVMGroup = dispatch( + vmGroupApi.util.updateQueryData( + 'getVMGroup', + { id: params.id }, + updateOwnershipOnResource(getState(), params) + ) + ) + + const patchVMGroups = dispatch( + vmGroupApi.util.updateQueryData( + 'getVMGroups', + undefined, + updateOwnershipOnResource(getState(), params) + ) + ) + + queryFulfilled.catch(() => { + patchVMGroup.undo() + patchVMGroups.undo() + }) + } catch {} + }, + }), + changeVMGroupPermissions: builder.mutation({ + /** + * Changes the permission bits of a VM group. + * Any permisisons set to -1 will not be changed. + * + * @param {object} params - Request parameters + * @param {string} params.id - Virtual machine id + * @param {string|number|'-1'} params.ownerUse - User use + * @param {string|number|'-1'} params.ownerManage - User manage + * @param {string|number|'-1'} params.ownerAdmin - User administrator + * @param {string|number|'-1'} params.groupUse - Group use + * @param {string|number|'-1'} params.groupManage - Group manage + * @param {string|number|'-1'} params.groupAdmin - Group administrator + * @param {string|number|'-1'} params.otherUse - Other use + * @param {string|number|'-1'} params.otherManage - Other manage + * @param {string|number|'-1'} params.otherAdmin - Other administrator + * @returns {number} Virtual machine id + * @throws Fails when response isn't code 200 when response isn't code 200 + */ + query: (params) => { + const name = Actions.VM_GROUP_CHMOD + const command = { name, ...Commands[name] } + + return { params, command } + }, + invalidatesTags: (_, __, { id }) => [{ type: VMGROUP, id }], + async onQueryStarted(params, { dispatch, queryFulfilled }) { + try { + const patchVMGroup = dispatch( + vmGroupApi.util.updateQueryData( + 'getVMGroup', + { id: params.id }, + updatePermissionOnResource(params) + ) + ) + + const patchVMGroups = dispatch( + vmGroupApi.util.updateQueryData( + 'getVMGroups', + undefined, + updatePermissionOnResource(params) + ) + ) + + queryFulfilled.catch(() => { + patchVMGroup.undo() + patchVMGroups.undo() + }) + } catch {} + }, + }), + updateVMGroup: builder.mutation({ + /** + * Replaces the VMGroup template contents. + * If set to `-1`, the user or group aren't changed. + * + * @param {object} params - Request params + * @param {number|string} params.id - Template id + * @param {string} params.template - The new template contents + * @param {0|1} params.replace + * - Update type: + * ``0``: Replace the whole template. + * ``1``: Merge new template with the existing one. + * @returns {number} VM group id + * @throws Fails when response isn't code 200 + */ + query: (params) => { + const name = Actions.VM_GROUP_UPDATE + const command = { name, ...Commands[name] } + + return { params, command } + }, + invalidatesTags: (_, __, { id }) => [{ type: VMGROUP, id }], + async onQueryStarted(params, { dispatch, queryFulfilled }) { + try { + const patchVMGroup = dispatch( + vmGroupApi.util.updateQueryData( + 'getVMGroup', + { id: params.id }, + updateTemplateOnResource(params) + ) + ) + + const patchVMGroups = dispatch( + vmGroupApi.util.updateQueryData( + 'getVMGroups', + undefined, + updateTemplateOnResource(params) + ) + ) + + queryFulfilled.catch(() => { + patchVMGroup.undo() + patchVMGroups.undo() + }) + } catch {} + }, + }), + removeVMGroup: builder.mutation({ + /** + * Deletes the VMGroup from the pool. + * + * @param {object} params - Request params + * @param {number|string} params.id - VMGroup id + * @returns {number} VM group id + * @throws Fails when response isn't code 200 + */ + query: (params) => { + const name = Actions.VM_GROUP_DELETE + const command = { name, ...Commands[name] } + + return { params, command } + }, + invalidatesTags: (_, __, id) => [{ type: VMGROUP, id }, VMGROUP_POOL], + }), + + allocateVMGroup: builder.mutation({ + /** + * Allocates a new VMGroup in OpenNebula. + * + * @param {object} params - Request params + * @param {string} params.template - A string containing the template on syntax XML + * @returns {number} VM group id + * @throws Fails when response isn't code 200 + */ + query: (params) => { + const name = Actions.VM_GROUP_ALLOCATE + const command = { name, ...Commands[name] } + + return { params, command } + }, }), }), }) - export const { // Queries useGetVMGroupsQuery, useLazyGetVMGroupsQuery, useGetVMGroupQuery, useLazyGetVMGroupQuery, + // Mutations + useAllocateVMGroupMutation, + useRemoveVMGroupMutation, + useUpdateVMGroupMutation, + useChangeVMGroupOwnershipMutation, + useChangeVMGroupPermissionsMutation, + useRenameVMGroupMutation, + useLockVMGroupMutation, + useUnlockVMGroupMutation, + useAddVMGroupRoleMutation, + useDeleteVMGroupRoleMutation, + useUpdateVMGroupRoleMutation, } = vmGroupApi diff --git a/src/fireedge/src/client/models/VmGroup.js b/src/fireedge/src/client/models/VmGroup.js new file mode 100644 index 00000000000..7ca623100d3 --- /dev/null +++ b/src/fireedge/src/client/models/VmGroup.js @@ -0,0 +1,25 @@ +/* ------------------------------------------------------------------------- * + * 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 { VMGROUP_STATES } from 'client/constants' + +/** + * Returns information about VmGroups state. + * + * @param {object} VmGroup - VmGroup object + * @param {number} VmGroup.LOCKED - Defines lock status. + * @returns {VMGROUP_STATES.StateInfo} - User state object + */ +export const getState = ({ LOCKED = '0' } = {}) => VMGROUP_STATES[LOCKED] diff --git a/src/fireedge/src/server/utils/constants/commands/vmgroup.js b/src/fireedge/src/server/utils/constants/commands/vmgroup.js index 9af5397afab..fb9e8342d7a 100644 --- a/src/fireedge/src/server/utils/constants/commands/vmgroup.js +++ b/src/fireedge/src/server/utils/constants/commands/vmgroup.js @@ -20,6 +20,9 @@ const { } = require('../defaults') const VM_GROUP_ALLOCATE = 'vmgroup.allocate' +const VM_GROUP_ROLEADD = 'vmgroup.roleadd' +const VM_GROUP_ROLEDELETE = 'vmgroup.roledelete' +const VM_GROUP_ROLEUPDATE = 'vmgroup.roleupdate' const VM_GROUP_DELETE = 'vmgroup.delete' const VM_GROUP_UPDATE = 'vmgroup.update' const VM_GROUP_CHMOD = 'vmgroup.chmod' @@ -32,6 +35,9 @@ const VM_GROUP_POOL_INFO = 'vmgrouppool.info' const Actions = { VM_GROUP_ALLOCATE, + VM_GROUP_ROLEADD, + VM_GROUP_ROLEDELETE, + VM_GROUP_ROLEUPDATE, VM_GROUP_DELETE, VM_GROUP_UPDATE, VM_GROUP_CHMOD, @@ -56,6 +62,51 @@ module.exports = { }, }, }, + // inspected + [VM_GROUP_ROLEADD]: { + httpMethod: PUT, + params: { + id: { + from: resource, + default: 0, + }, + template: { + from: postBody, + default: '', + }, + }, + }, + // inspected + [VM_GROUP_ROLEDELETE]: { + httpMethod: DELETE, + params: { + id: { + from: resource, + default: 0, + }, + roleId: { + from: postBody, + default: -1, + }, + }, + }, // inspected + [VM_GROUP_ROLEUPDATE]: { + httpMethod: PUT, + params: { + id: { + from: resource, + default: 0, + }, + roleId: { + from: postBody, + default: -1, + }, + template: { + from: postBody, + default: '', + }, + }, + }, [VM_GROUP_DELETE]: { // inspected httpMethod: DELETE, @@ -157,8 +208,8 @@ module.exports = { default: 0, }, name: { - from: postBody, - defaul: '', + from: query, + default: '', }, }, },