1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-02-04 17:47:00 +03:00

F OpenNebula/one#5422: Add actions to vm tabs (#1363)

This commit is contained in:
Sergio Betanzos 2021-07-19 10:30:14 +02:00 committed by GitHub
parent 974a91bc7b
commit fe457891bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 855 additions and 317 deletions

View File

@ -45,28 +45,34 @@ info-tabs:
enabled: true
information_panel:
enabled: true
permissions_panel:
enabled: true
ownership_panel:
enabled: true
vcenter_information_panel:
vcenter_panel:
enabled: true
parent: USER_TEMPLATE
prefix: VCENTER_
lxc_information_panel:
actions:
add: true
edit: true
delete: true
nsx_panel:
enabled: true
general_attributes_panel:
actions:
add: true
edit: true
delete: true
monitoring_panel:
enabled: true
parent: USER_TEMPLATE
monitor_panel:
actions:
add: false
edit: false
delete: false
attributes_panel:
enabled: true
parent: MONITORING
actions:
add: true
edit: true
delete: true
monitoring:
enabled: true

View File

@ -84,18 +84,30 @@ info-tabs:
actions:
chown: true
chgrp: true
vcenter_information_panel:
vcenter_panel:
enabled: true
parent: USER_TEMPLATE
prefix: VCENTER_
lxc_information_panel:
actions:
add: true
edit: true
delete: true
lxc_panel:
enabled: true
general_attributes_panel:
actions:
add: true
edit: true
delete: true
monitoring_panel:
enabled: true
parent: USER_TEMPLATE
monitor_panel:
actions:
add: false
edit: false
delete: false
attributes_panel:
enabled: true
parent: MONITORING
actions:
add: true
edit: true
delete: true
capacity:
enabled: true

View File

@ -78,24 +78,36 @@ info-tabs:
permissions_panel:
enabled: true
actions:
chmod: true
chmod: false
ownership_panel:
enabled: true
actions:
chown: true
chgrp: true
vcenter_information_panel:
vcenter_panel:
enabled: true
parent: USER_TEMPLATE
prefix: VCENTER_
lxc_information_panel:
actions:
add: false
edit: false
delete: false
lxc_panel:
enabled: true
general_attributes_panel:
actions:
add: false
edit: false
delete: false
monitoring_panel:
enabled: true
parent: USER_TEMPLATE
monitor_panel:
actions:
add: false
edit: false
delete: false
attributes_panel:
enabled: true
parent: MONITORING
actions:
add: true
edit: false
delete: false
capacity:
enabled: true

View File

@ -17,7 +17,7 @@ import React, { memo } from 'react'
import PropTypes from 'prop-types'
import { Card, CardHeader, Fade, makeStyles } from '@material-ui/core'
import { Tr } from 'client/components/HOC/Translate'
import { Tr } from 'client/components/HOC'
import { T } from 'client/constants'
const useStyles = makeStyles(theme => ({

View File

@ -28,7 +28,7 @@ import {
} from '@material-ui/core'
import { Cancel as CancelIcon } from 'iconoir-react'
import { SubmitButton } from 'client/components/FormControl'
import { Action } from 'client/components/Cards/SelectCard'
import { Tr } from 'client/components/HOC'
import { T } from 'client/constants'
@ -90,11 +90,12 @@ const DialogConfirmation = memo(
</DialogContent>
{handleAccept && (
<DialogActions>
<SubmitButton
<Action
color='secondary'
data-cy='dg-accept-button'
onClick={handleAccept}
label={Tr(T.Accept)}
handleClick={handleAccept}
icon={false}
label={T.Accept}
{...acceptButtonProps}
/>
</DialogActions>

View File

@ -21,7 +21,7 @@ import { Autocomplete } from '@material-ui/lab'
import { Controller } from 'react-hook-form'
import { ErrorHelper } from 'client/components/FormControl'
import { Tr } from 'client/components/HOC/Translate'
import { Tr } from 'client/components/HOC'
const AutocompleteController = memo(
({ control, cy, name, label, multiple, values, error, fieldProps }) => (

View File

@ -25,7 +25,7 @@ import {
import { Controller } from 'react-hook-form'
import { ErrorHelper } from 'client/components/FormControl'
import { Tr } from 'client/components/HOC/Translate'
import { Tr } from 'client/components/HOC'
const CheckboxController = memo(
({ control, cy, name, label, tooltip, error, fieldProps }) => (

View File

@ -18,7 +18,7 @@ import { string } from 'prop-types'
import { Box, makeStyles, Typography } from '@material-ui/core'
import { WarningCircledOutline as WarningIcon } from 'iconoir-react'
import { Tr } from 'client/components/HOC/Translate'
import { Tr } from 'client/components/HOC'
const useStyles = makeStyles(theme => ({
root: {

View File

@ -20,7 +20,7 @@ import { TextField, MenuItem } from '@material-ui/core'
import { Controller } from 'react-hook-form'
import { ErrorHelper } from 'client/components/FormControl'
import { Tr } from 'client/components/HOC/Translate'
import { Tr } from 'client/components/HOC'
const SelectController = memo(
({ control, cy, name, label, multiple, native, values, error, fieldProps }) => {

View File

@ -20,7 +20,7 @@ import { Typography, TextField, Slider, FormHelperText, Grid } from '@material-u
import { Controller } from 'react-hook-form'
import { ErrorHelper } from 'client/components/FormControl'
import { Tr } from 'client/components/HOC/Translate'
import { Tr } from 'client/components/HOC'
const SliderController = memo(
({ control, cy, name, label, error, fieldProps }) => (

View File

@ -77,7 +77,10 @@ const SidebarCollapseItem = ({ label, routes, icon: Icon }) => {
SidebarCollapseItem.propTypes = {
label: PropTypes.string.isRequired,
icon: PropTypes.node,
icon: PropTypes.oneOfType([
PropTypes.node,
PropTypes.object
]),
routes: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string,

View File

@ -17,6 +17,7 @@ import * as React from 'react'
import PropTypes from 'prop-types'
import {
AddSquare as AddIcon,
Edit as EditIcon,
Trash as DeleteIcon,
Check as AcceptIcon,
@ -61,6 +62,12 @@ ActionButton.propTypes = {
handleClick: PropTypes.func.isRequired
}
/**
* @param {ActionButtonProps} props - Action button props
* @returns {React.JSXElementConstructor} Action button with props
*/
const Add = props => <ActionButton action='add' icon={AddIcon} {...props}/>
/**
* @param {ActionButtonProps} props - Action button props
* @returns {React.JSXElementConstructor} Action button with props
@ -88,8 +95,9 @@ const Cancel = props => <ActionButton action='cancel' icon={CancelIcon} {...prop
export {
getAttributeCy,
ActionButton,
Edit,
Delete,
Add,
Accept,
Cancel
Cancel,
Delete,
Edit
}

View File

@ -0,0 +1,149 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, 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 React from 'react'
import PropTypes from 'prop-types'
import { makeStyles, Typography } from '@material-ui/core'
import { useDialog } from 'client/hooks'
import { DialogConfirmation } from 'client/components/Dialogs'
import { Tr } from 'client/components/HOC'
import { Actions, Inputs } from 'client/components/Tabs/Common/Attribute'
const useStyles = makeStyles({
wrapper: {
display: 'flex',
alignItems: 'center',
'& > *:first-child': {
flexGrow: 1
}
},
select: {
textOverflow: 'ellipsis'
}
})
const Attribute = React.memo(({
canDelete,
canEdit,
handleEdit,
handleDelete,
handleGetOptionList,
name,
value,
valueInOptionList
}) => {
const classes = useStyles()
const [isEditing, setIsEditing] = React.useState(() => false)
const [options, setOptions] = React.useState(() => [])
const { display, show, hide } = useDialog()
const inputRef = React.createRef()
const handleEditAttribute = async () => {
await handleEdit?.(inputRef.current.value)
setIsEditing(false)
}
const handleCancel = () => {
setIsEditing(false)
}
const handleActiveEditForm = async () => {
const response = await handleGetOptionList?.() ?? []
const isFormatValid = response?.every?.(({ text, value } = {}) => !!text && !!value)
if (!handleGetOptionList || isFormatValid) {
setOptions(response)
setIsEditing(true)
}
}
const handleDeleteAttribute = async () => {
await handleDelete?.(name)
hide()
}
return (
<>
<Typography noWrap variant='body2' title={Tr(name)}>
{Tr(name)}
</Typography>
<div className={classes.wrapper}>
{isEditing ? (
<>
{handleGetOptionList ? (
<Inputs.Select
name={name}
value={valueInOptionList}
ref={inputRef}
options={options} />
) : (
<Inputs.Text name={name} value={value} ref={inputRef} />
)}
<Actions.Accept name={name} handleClick={handleEditAttribute} />
<Actions.Cancel name={name} handleClick={handleCancel} />
</>
) : (
<>
<Typography
noWrap
variant='body2'
title={typeof value === 'string' ? value : undefined}
>
{value}
</Typography>
{canEdit && (
<Actions.Edit name={name} handleClick={handleActiveEditForm} />
)}
{canDelete && (
<Actions.Delete name={name} handleClick={show} />
)}
</>
)}
{display && (
<DialogConfirmation
title={`Delete attribute: ${name}`}
handleAccept={handleDeleteAttribute}
handleCancel={hide}
>
<p>Are you sure?</p>
</DialogConfirmation>
)}
</div>
</>
)
})
export const AttributePropTypes = {
canDelete: PropTypes.bool,
canEdit: PropTypes.bool,
handleEdit: PropTypes.func,
handleDelete: PropTypes.func,
handleGetOptionList: PropTypes.func,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object
]).isRequired,
valueInOptionList: PropTypes.string
}
Attribute.propTypes = AttributePropTypes
Attribute.displayName = 'Attribute'
export default Attribute

View File

@ -15,10 +15,9 @@
* ------------------------------------------------------------------------- */
import * as React from 'react'
import PropTypes from 'prop-types'
import { makeStyles, TextField } from '@material-ui/core'
import * as Actions from 'client/components/Tabs/Common/Attribute/Actions'
import { Actions } from 'client/components/Tabs/Common/Attribute'
/**
* @typedef {object} Option
@ -104,8 +103,8 @@ const Text = React.forwardRef(
)
const InputPropTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
name: PropTypes.string,
value: PropTypes.string,
options: PropTypes.arrayOf(
PropTypes.shape({
text: PropTypes.string.isRequired,
@ -119,4 +118,4 @@ Select.propTypes = InputPropTypes
Text.displayName = 'Text'
Text.propTypes = InputPropTypes
export { Select, Text }
export { Select, Text, InputPropTypes }

View File

@ -13,138 +13,14 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import * as React from 'react'
import PropTypes from 'prop-types'
import Attribute, { AttributePropTypes } from 'client/components/Tabs/Common/Attribute/Attribute'
import { makeStyles, Typography } from '@material-ui/core'
import { useDialog } from 'client/hooks'
import { DialogConfirmation } from 'client/components/Dialogs'
import { Tr } from 'client/components/HOC'
import * as Actions from 'client/components/Tabs/Common/Attribute/Actions'
import * as Inputs from 'client/components/Tabs/Common/Attribute/Inputs'
const useStyles = makeStyles({
wrapper: {
display: 'flex',
alignItems: 'center',
'& > *:first-child': {
flexGrow: 1
}
},
select: {
textOverflow: 'ellipsis'
}
})
const Attribute = React.memo(({
canDelete,
canEdit,
handleEdit,
handleDelete,
handleGetOptionList,
name,
value,
valueInOptionList
}) => {
const classes = useStyles()
const [isEditing, setIsEditing] = React.useState(() => false)
const [options, setOptions] = React.useState(() => [])
const { display, show, hide } = useDialog()
const inputRef = React.createRef()
const handleEditAttribute = async () => {
await handleEdit?.(inputRef.current.value)
setIsEditing(false)
}
const handleCancel = () => {
setIsEditing(false)
}
const handleActiveEditForm = async () => {
const response = await handleGetOptionList?.() ?? []
const isFormatValid = response?.every?.(({ text, value } = {}) => !!text && !!value)
if (!handleGetOptionList || isFormatValid) {
setOptions(response)
setIsEditing(true)
}
}
const handleDeleteAttribute = async () => {
await handleDelete?.()
hide()
}
return (
<>
<Typography noWrap variant='body2' title={Tr(name)}>
{Tr(name)}
</Typography>
<div className={classes.wrapper}>
{isEditing ? (
<>
{handleGetOptionList ? (
<Inputs.Select
name={name}
value={valueInOptionList}
ref={inputRef}
options={options} />
) : (
<Inputs.Text name={name} value={value} ref={inputRef} />
)}
<Actions.Accept name={name} handleClick={handleEditAttribute} />
<Actions.Cancel name={name} handleClick={handleCancel} />
</>
) : (
<>
<Typography
noWrap
variant='body2'
title={typeof value === 'string' ? value : undefined}
>
{value}
</Typography>
{canEdit && (
<Actions.Edit name={name} handleClick={handleActiveEditForm} />
)}
{canDelete && (
<Actions.Delete name={name} handleClick={show} />
)}
</>
)}
{display && (
<DialogConfirmation
title={`Delete attribute: ${name}`}
handleAccept={handleDeleteAttribute}
handleCancel={hide}
>
<p>Are you sure?</p>
</DialogConfirmation>
)}
</div>
</>
)
})
export const AttributePropType = {
canDelete: PropTypes.bool,
canEdit: PropTypes.bool,
handleEdit: PropTypes.func,
handleDelete: PropTypes.func,
handleGetOptionList: PropTypes.func,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object
]).isRequired,
valueInOptionList: PropTypes.string
export {
Actions,
Attribute,
AttributePropTypes,
Inputs
}
Attribute.propTypes = AttributePropType
Attribute.displayName = 'Attribute'
export default Attribute

View File

@ -0,0 +1,79 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, 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 React from 'react'
import PropTypes from 'prop-types'
import { makeStyles } from '@material-ui/core'
import { Actions, Inputs } from 'client/components/Tabs/Common/Attribute'
import { generateKey, fakeDelay } from 'client/utils'
const useStyles = makeStyles({
wrapper: {
display: 'flex',
alignItems: 'center',
'& > *:first-child': {
flexGrow: 1
}
}
})
const AttributeCreateForm = React.memo(({ handleAdd }) => {
const classes = useStyles()
const inputNameRef = React.createRef()
const inputValueRef = React.createRef()
const key = React.useMemo(() => generateKey(), [])
const handleCreateAttribute = async () => {
inputNameRef.current.disabled = true
inputValueRef.current.disabled = true
await fakeDelay(2000)
await handleAdd?.(
inputNameRef.current.value,
inputValueRef.current.value
)
inputNameRef.current.disabled = false
inputValueRef.current.disabled = false
inputNameRef.current.value = ''
inputValueRef.current.value = ''
}
return (
<>
{/* NAME ATTRIBUTE */}
<Inputs.Text name={`name-${key}`} ref={inputNameRef} />
{/* VALUE ATTRIBUTE */}
<div className={classes.wrapper}>
<Inputs.Text name={`value-${key}`} ref={inputValueRef} />
<Actions.Add
name={'action-add'}
handleClick={handleCreateAttribute}
/>
</div>
</>
)
})
AttributeCreateForm.propTypes = {
handleAdd: PropTypes.func
}
AttributeCreateForm.displayName = 'AttributeCreateForm'
export default AttributeCreateForm

View File

@ -0,0 +1,73 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, 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. *
* ------------------------------------------------------------------------- */
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, 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 React from 'react'
import PropTypes from 'prop-types'
import { List } from 'client/components/Tabs/Common'
import { ACTIONS } from 'client/constants'
const AttributePanel = React.memo((
{ title, attributes, handleEdit, handleDelete, handleAdd, actions }
) => {
const formatAttributes = Object.entries(attributes)
.filter(([_, value]) => typeof value === 'string')
.map(([name, value]) => ({
name,
value,
canEdit: actions?.includes?.(ACTIONS.EDIT_ATTRIBUTE),
canDelete: actions?.includes?.(ACTIONS.DELETE_ATTRIBUTE),
handleEdit,
handleDelete
}))
return (
<List
title={title}
list={formatAttributes}
{...(actions?.includes?.(ACTIONS.ADD_ATTRIBUTE) && {
handleAdd
})}
/>
)
})
AttributePanel.propTypes = {
actions: PropTypes.arrayOf(PropTypes.string),
attributes: PropTypes.object,
handleAdd: PropTypes.func,
handleEdit: PropTypes.func,
handleDelete: PropTypes.func,
title: PropTypes.string
}
AttributePanel.displayName = 'AttributePanel'
export default AttributePanel

View File

@ -19,7 +19,9 @@ import PropTypes from 'prop-types'
import { makeStyles, List as MList, ListItem, Typography, Paper } from '@material-ui/core'
import Attribute, { AttributePropType } from 'client/components/Tabs/Common/Attribute'
import { Attribute, AttributePropTypes } from 'client/components/Tabs/Common/Attribute'
import AttributeCreateForm from 'client/components/Tabs/Common/AttributeCreateForm'
import { Tr } from 'client/components/HOC'
const useStyles = makeStyles(theme => ({
@ -28,25 +30,26 @@ const useStyles = makeStyles(theme => ({
borderBottom: `1px solid ${theme.palette.divider}`
},
item: {
gap: '1em',
'& > *': {
width: '50%'
}
},
typo: {
...theme.typography.body2
}
typo: theme.typography.body2
}))
const List = ({ title, list = [], ...props }) => {
const List = ({ title, list = [], handleAdd, containerProps }) => {
const classes = useStyles()
return (
<Paper variant='outlined' {...props}>
<Paper variant='outlined' {...containerProps}>
<MList className={classes.list}>
{/* TITLE */}
{title && (
<ListItem className={classes.title}>
<Typography noWrap>{Tr(title)}</Typography>
<Typography noWrap>
{Tr(title)}
</Typography>
</ListItem>
)}
{/* LIST */}
@ -58,15 +61,23 @@ const List = ({ title, list = [], ...props }) => {
<Attribute {...attribute}/>
</ListItem>
))}
{/* ADD ACTION */}
{handleAdd && (
<ListItem className={classes.item}>
<AttributeCreateForm handleAdd={handleAdd} />
</ListItem>
)}
</MList>
</Paper>
)
}
List.propTypes = {
title: PropTypes.string,
containerProps: PropTypes.object,
handleAdd: PropTypes.func,
title: PropTypes.any,
list: PropTypes.arrayOf(
PropTypes.shape(AttributePropType)
PropTypes.shape(AttributePropTypes)
)
}

View File

@ -13,12 +13,16 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import AttributePanel from 'client/components/Tabs/Common/AttributePanel'
import List from 'client/components/Tabs/Common/List'
import Permissions from 'client/components/Tabs/Common/Permissions'
import Ownership from 'client/components/Tabs/Common/Ownership'
import Permissions from 'client/components/Tabs/Common/Permissions'
export * from 'client/components/Tabs/Common/Attribute'
export {
AttributePanel,
List,
Permissions,
Ownership
Ownership,
Permissions
}

View File

@ -15,12 +15,18 @@
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import * as React from 'react'
import { makeStyles, Paper, Typography } from '@material-ui/core'
import PropTypes from 'prop-types'
import { makeStyles, Paper, Typography, Button } from '@material-ui/core'
import { useDialog } from 'client/hooks'
import { TabContext } from 'client/components/Tabs/TabProvider'
import { Action } from 'client/components/Cards/SelectCard'
import * as VirtualMachine from 'client/models/VirtualMachine'
import { DialogConfirmation } from 'client/components/Dialogs'
import { Tr } from 'client/components/HOC'
import { prettyBytes } from 'client/utils'
import * as VirtualMachine from 'client/models/VirtualMachine'
import * as Helper from 'client/models/Helper'
import { T, VM_ACTIONS } from 'client/constants'
const useStyles = makeStyles(theme => ({
root: {
@ -57,54 +63,92 @@ const useStyles = makeStyles(theme => ({
}
}))
const VmCapacityTab = () => {
const VmCapacityTab = ({ tabProps: { actions = [] } = {} }) => {
const classes = useStyles()
const { display, show, hide } = useDialog()
const { data: vm = {} } = React.useContext(TabContext)
const { TEMPLATE } = vm
const isVCenter = VirtualMachine.isVCenter(vm)
const hypervisor = VirtualMachine.getHypervisor(vm)
const actionsAvailable = Helper.getActionsAvailable(actions, hypervisor)
const capacity = [
{ key: 'Physical CPU', value: TEMPLATE?.CPU },
{ key: 'Virtual CPU', value: TEMPLATE?.VCPU ?? '-' },
{
name: T.PhysicalCpu,
value: TEMPLATE?.CPU
},
{
name: T.VirtualCpu,
value: TEMPLATE?.VCPU ?? '-'
},
(isVCenter && {
key: 'Virtual Cores',
value: `
Cores x ${TEMPLATE?.TOPOLOGY?.CORES || '-'} |
Sockets ${TEMPLATE?.TOPOLOGY?.SOCKETS || '-'}`
name: T.VirtualCores,
value: (
<>
{`${Tr(T.Cores)} x ${TEMPLATE?.TOPOLOGY?.CORES || '-'} |
${Tr(T.Sockets)} ${TEMPLATE?.TOPOLOGY?.SOCKETS || '-'}`}
</>
)
}),
{ key: 'Memory', value: prettyBytes(+TEMPLATE?.MEMORY, 'MB') },
{ key: 'Cost / CPU', value: TEMPLATE?.CPU_COST || 0 },
{ key: 'Cost / MByte', value: TEMPLATE?.MEMORY_COST || 0 }
{
name: T.Memory,
value: prettyBytes(+TEMPLATE?.MEMORY, 'MB')
},
{
name: T.CostCpu,
value: TEMPLATE?.CPU_COST || 0
},
{
name: T.CostMByte,
value: TEMPLATE?.MEMORY_COST || 0
}
].filter(Boolean)
return (
<Paper variant='outlined' className={classes.root}>
<div className={classes.actions}>
<Action
cy='resize'
icon={false}
label={'Resize'}
size='small'
color='secondary'
handleClick={() => undefined}
/>
{actionsAvailable?.includes?.(VM_ACTIONS.RESIZE_CAPACITY) && (
<Button
data-cy='resize'
size='small'
color='secondary'
onClick={show}
variant='contained'
>
{Tr(T.Resize)}
</Button>
)}
</div>
{capacity.map(({ key, value }) => (
<div key={key} className={classes.item}>
<Typography className={classes.title} noWrap title={key}>
{key}
{capacity.map(({ name, value }) => (
<div key={name} className={classes.item}>
<Typography className={classes.title} noWrap title={name}>
{name}
</Typography>
<Typography variant='body2' noWrap title={value}>
{value}
</Typography>
</div>
))}
{display && (
<DialogConfirmation
title={T.ResizeCapacity}
handleAccept={hide}
handleCancel={hide}
>
<p>TODO: should define in view yaml ??</p>
</DialogConfirmation>
)}
</Paper>
)
}
VmCapacityTab.propTypes = {
tabProps: PropTypes.object
}
VmCapacityTab.displayName = 'VmCapacityTab'
export default VmCapacityTab

View File

@ -19,22 +19,32 @@ import PropTypes from 'prop-types'
import { useVmApi } from 'client/features/One'
import { TabContext } from 'client/components/Tabs/TabProvider'
import { Permissions, Ownership } from 'client/components/Tabs/Common'
import { Permissions, Ownership, AttributePanel } from 'client/components/Tabs/Common'
import Information from 'client/components/Tabs/Vm/Info/information'
import { Tr } from 'client/components/HOC'
import { T } from 'client/constants'
import * as VirtualMachine from 'client/models/VirtualMachine'
import * as Helper from 'client/models/Helper'
const LXC_ATTRIBUTES_REG = /^LXC_/
const VCENTER_ATTRIBUTES_REG = /^VCENTER_/
const HIDDEN_ATTRIBUTES_REG = /^(CPU|MEMORY|NETTX|NETRX|STATE|DISK_SIZE|SNAPSHOT_SIZE)$/
const VmInfoTab = ({ tabProps = {} }) => {
const {
information_panel: informationPanel,
permissions_panel: permissionsPanel,
ownership_panel: ownershipPanel
ownership_panel: ownershipPanel,
vcenter_panel: vcenterPanel,
lxc_panel: lxcPanel,
monitoring_panel: monitoringPanel,
attributes_panel: attributesPanel
} = tabProps
const { changeOwnership, changePermissions, rename } = useVmApi()
const { changeOwnership, changePermissions, rename, updateUserTemplate } = useVmApi()
const { handleRefetch, data: vm } = React.useContext(TabContext)
const { ID, UNAME, UID, GNAME, GID, PERMISSIONS } = vm
const { ID, UNAME, UID, GNAME, GID, PERMISSIONS, USER_TEMPLATE, MONITORING } = vm
const handleChangeOwnership = async newOwnership => {
const response = await changeOwnership(ID, newOwnership)
@ -51,9 +61,35 @@ const VmInfoTab = ({ tabProps = {} }) => {
String(response) === String(ID) && await handleRefetch?.()
}
const handleCreateAttribute = async (newName, newValue) => {
const newAttribute = `${String(newName).toUpperCase()} = "${newValue}"`
// 1: Merge the new attribute to existing user template
const response = await updateUserTemplate(ID, newAttribute, 1)
console.log({ response })
String(response) === String(ID) && await handleRefetch?.()
}
const hypervisor = VirtualMachine.getHypervisor(vm)
const getActions = actions => Helper.getActionsAvailable(actions, hypervisor)
const filteredAttributes = Helper.filterAttributes(USER_TEMPLATE, {
extra: {
vcenter: VCENTER_ATTRIBUTES_REG,
lxc: LXC_ATTRIBUTES_REG
},
hidden: HIDDEN_ATTRIBUTES_REG
})
const { vcenter: vcenterAttributes, lxc: lxcAttributes, attributes } = filteredAttributes
const ATTRIBUTE_FUNCTION = {
handleAdd: handleCreateAttribute,
handleEdit: console.log,
handleDelete: console.log
}
return (
<div style={{
display: 'grid',
@ -93,6 +129,35 @@ const VmInfoTab = ({ tabProps = {} }) => {
handleEdit={handleChangeOwnership}
/>
}
{attributesPanel?.enabled && attributes && (
<AttributePanel
{...ATTRIBUTE_FUNCTION}
actions={getActions(attributesPanel?.actions)}
title={Tr(T.Attributes)}
attributes={attributes} />
)}
{vcenterPanel?.enabled && vcenterAttributes && (
<AttributePanel
{...ATTRIBUTE_FUNCTION}
actions={getActions(vcenterPanel?.actions)}
title={`vCenter ${Tr(T.Information)}`}
attributes={vcenterAttributes} />
)}
{lxcPanel?.enabled && lxcAttributes && (
<AttributePanel
{...ATTRIBUTE_FUNCTION}
actions={getActions(lxcPanel?.actions)}
title={`LXC ${Tr(T.Information)}`}
attributes={lxcAttributes} />
)}
{monitoringPanel?.enabled && (
<AttributePanel
{...ATTRIBUTE_FUNCTION}
actions={getActions(monitoringPanel?.actions)}
title={Tr(T.Monitoring)}
attributes={MONITORING}
/>
)}
</div>
)
}

View File

@ -82,7 +82,11 @@ const InformationPanel = ({ vm = {}, handleRename, actions }) => {
]
return (
<List title={T.Information} list={info} style={{ gridRow: 'span 3' }} />
<List
title={T.Information}
list={info}
containerProps={{ style: { gridRow: 'span 3' } }}
/>
)
}

View File

@ -30,12 +30,14 @@ import {
} from '@material-ui/core'
import { useVmApi } from 'client/features/One'
import { Action } from 'client/components/Cards/SelectCard'
import Multiple from 'client/components/Tables/Vms/multiple'
import { useDialog } from 'client/hooks'
import { TabContext } from 'client/components/Tabs/TabProvider'
import { Action } from 'client/components/Cards/SelectCard'
import { DialogConfirmation } from 'client/components/Dialogs'
import Multiple from 'client/components/Tables/Vms/multiple'
import { T, VM_ACTIONS } from 'client/constants'
import { Tr } from 'client/components/HOC'
import { T, VM_ACTIONS } from 'client/constants'
const AccordionSummary = withStyles({
root: {
@ -89,84 +91,108 @@ const NetworkItem = ({ nic = {}, actions }) => {
const classes = useStyles()
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
const { handleRefetch, data: vm } = React.useContext(TabContext)
const { display, show, hide, values } = useDialog()
const { detachNic } = useVmApi()
const { handleRefetch, data: vm } = React.useContext(TabContext)
const { ID: vmId } = vm
const { NIC_ID, NETWORK = '-', BRIDGE, IP, MAC, PCI_ID, ALIAS, SECURITY_GROUPS } = nic
const hasDetails = React.useMemo(() =>
!!ALIAS.length || !!SECURITY_GROUPS?.length,
[ALIAS.length, SECURITY_GROUPS?.length]
const hasDetails = React.useMemo(
() => !!ALIAS.length || !!SECURITY_GROUPS?.length,
[ALIAS.length, SECURITY_GROUPS?.length]
)
const detachAction = () => actions?.includes?.(VM_ACTIONS.DETACH_NIC) && (
<Action
cy={`${VM_ACTIONS.DETACH_NIC}-${NIC_ID}`}
icon={<Trash size={18} />}
stopPropagation
handleClick={async () => {
const response = await detachNic(vm.ID, NIC_ID)
const handleDetach = async () => {
const response = values?.id && await detachNic(vmId, values.id)
String(response) === String(vm.ID) && handleRefetch?.(vm.ID)
}}
/>
)
String(response) === String(vmId) && await handleRefetch?.(vmId)
hide()
}
const detachAction = (id, isAlias) =>
actions?.includes?.(VM_ACTIONS.DETACH_NIC) && (
<Action
cy={`${VM_ACTIONS.DETACH_NIC}-${id}`}
icon={<Trash size={18} />}
stopPropagation
handleClick={() => show({ id, isAlias })}
/>
)
return (
<Accordion variant='outlined'>
<AccordionSummary>
<div className={classes.row}>
<Typography noWrap>
{`${NIC_ID} | ${NETWORK}`}
</Typography>
<span className={classes.labels}>
<Multiple
limitTags={isMobile ? 1 : 4}
tags={[IP, MAC, BRIDGE && `BRIDGE - ${BRIDGE}`, PCI_ID].filter(Boolean)}
/>
</span>
{!isMobile && detachAction()}
</div>
</AccordionSummary>
{hasDetails && (
<AccordionDetails className={classes.details}>
{ALIAS?.map(({ NIC_ID, NETWORK = '-', BRIDGE, IP, MAC }) => (
<div key={NIC_ID} className={classes.row}>
<Typography noWrap variant='body2'>
{`Alias ${NIC_ID} | ${NETWORK}`}
</Typography>
<span className={classes.labels}>
<Multiple
limitTags={isMobile ? 1 : 4}
tags={[IP, MAC, BRIDGE && `BRIDGE - ${BRIDGE}`].filter(Boolean)}
/>
</span>
{!isMobile && detachAction()}
</div>
))}
{!!SECURITY_GROUPS?.length && (
<Paper variant='outlined' className={classes.securityGroups}>
<Typography variant='body1'>{Tr(T.SecurityGroups)}</Typography>
<>
<Accordion variant='outlined'>
<AccordionSummary>
<div className={classes.row}>
<Typography noWrap>
{`${NIC_ID} | ${NETWORK}`}
</Typography>
<span className={classes.labels}>
<Multiple
limitTags={isMobile ? 1 : 4}
tags={[IP, MAC, BRIDGE && `BRIDGE - ${BRIDGE}`, PCI_ID].filter(Boolean)}
/>
</span>
{!isMobile && detachAction(NIC_ID)}
</div>
</AccordionSummary>
{hasDetails && (
<AccordionDetails className={classes.details}>
{ALIAS?.map(({ NIC_ID, NETWORK = '-', BRIDGE, IP, MAC }) => (
<div key={NIC_ID} className={classes.row}>
<Typography noWrap variant='body2'>
{`${Tr(T.Alias)} ${NIC_ID} | ${NETWORK}`}
</Typography>
<span className={classes.labels}>
<Multiple
limitTags={isMobile ? 1 : 4}
tags={[IP, MAC, BRIDGE && `BRIDGE - ${BRIDGE}`].filter(Boolean)}
/>
</span>
{!isMobile && detachAction(NIC_ID, true)}
</div>
))}
{!!SECURITY_GROUPS?.length && (
<Paper variant='outlined' className={classes.securityGroups}>
<Typography variant='body1'>{Tr(T.SecurityGroups)}</Typography>
{SECURITY_GROUPS
?.map(({ ID, NAME, PROTOCOL, RULE_TYPE, ICMP_TYPE, RANGE, NETWORK_ID }, idx) => (
<div key={`${idx}-${NAME}`} className={classes.row}>
<Typography noWrap variant='body2'>
{`${ID} | ${NAME}`}
</Typography>
<span className={classes.labels}>
<Multiple
limitTags={isMobile ? 2 : 5}
tags={[PROTOCOL, RULE_TYPE, RANGE, NETWORK_ID, ICMP_TYPE].filter(Boolean)}
/>
</span>
</div>
))}
</Paper>
)}
</AccordionDetails>
{SECURITY_GROUPS
?.map(({ ID, NAME, PROTOCOL, RULE_TYPE, ICMP_TYPE, RANGE, NETWORK_ID }, idx) => (
<div key={`${idx}-${NAME}`} className={classes.row}>
<Typography noWrap variant='body2'>
{`${ID} | ${NAME}`}
</Typography>
<span className={classes.labels}>
<Multiple
limitTags={isMobile ? 2 : 5}
tags={[PROTOCOL, RULE_TYPE, RANGE, NETWORK_ID, ICMP_TYPE].filter(Boolean)}
/>
</span>
</div>
))}
</Paper>
)}
</AccordionDetails>
)}
</Accordion>
{display && (
<DialogConfirmation
title={T.Detach}
handleAccept={handleDetach}
handleCancel={hide}
>
<p>{`
${Tr(T.Detach)}
${Tr(values?.isAlias ? T.Alias : T.NIC)}
#${values?.id}
`}</p>
<p>{Tr(T.DoYouWantProceed)}</p>
</DialogConfirmation>
)}
</Accordion>
</>
)
}

View File

@ -16,13 +16,20 @@
/* eslint-disable jsdoc/require-jsdoc */
import * as React from 'react'
import PropTypes from 'prop-types'
import { Button } from '@material-ui/core'
import NetworkList from 'client/components/Tabs/Vm/Network/List'
import { useDialog } from 'client/hooks'
import { TabContext } from 'client/components/Tabs/TabProvider'
import { DialogConfirmation } from 'client/components/Dialogs'
import NetworkList from 'client/components/Tabs/Vm/Network/List'
import { Tr } from 'client/components/HOC'
import * as VirtualMachine from 'client/models/VirtualMachine'
import * as Helper from 'client/models/Helper'
import { T, VM_ACTIONS } from 'client/constants'
const VmNetworkTab = ({ tabProps }) => {
const VmNetworkTab = ({ tabProps = {} }) => {
const { display, show, hide } = useDialog()
const { data: vm } = React.useContext(TabContext)
const { actions = [] } = tabProps
@ -35,7 +42,31 @@ const VmNetworkTab = ({ tabProps }) => {
const actionsAvailable = Helper.getActionsAvailable(actions, hypervisor)
return (
<NetworkList actions={actionsAvailable} nics={nics} />
<>
{actionsAvailable?.includes?.(VM_ACTIONS.ATTACH_NIC) && (
<Button
data-cy='resize'
size='small'
color='secondary'
onClick={show}
variant='contained'
>
{Tr(T.AttachNic)}
</Button>
)}
<NetworkList actions={actionsAvailable} nics={nics} />
{display && (
<DialogConfirmation
title={T.AttachNic}
handleAccept={hide}
handleCancel={hide}
>
<p>TODO: should define in view yaml ??</p>
</DialogConfirmation>
)}
</>
)
}

View File

@ -40,6 +40,7 @@ const StorageItem = ({ disk, actions = [] }) => {
DATASTORE,
TARGET,
IMAGE,
IMAGE_ID,
TYPE,
FORMAT,
SIZE,
@ -54,6 +55,7 @@ const StorageItem = ({ disk, actions = [] }) => {
const size = +SIZE ? prettyBytes(+SIZE, 'MB') : '-'
const monitorSize = +MONITOR_SIZE ? prettyBytes(+MONITOR_SIZE, 'MB') : '-'
const isImage = IMAGE_ID !== undefined
const type = String(TYPE).toLowerCase()
const image = IMAGE ?? ({
@ -106,28 +108,28 @@ const StorageItem = ({ disk, actions = [] }) => {
</div>
{!IS_CONTEXT && !!actions.length && (
<div className={classes.actions}>
{actions.includes(VM_ACTIONS.DISK_SAVEAS) && (
{actions?.includes?.(VM_ACTIONS.DISK_SAVEAS) && isImage && (
<Action
cy={`${VM_ACTIONS.DISK_SAVEAS}-${DISK_ID}`}
icon={<SaveActionFloppy size={18} />}
handleClick={() => undefined}
/>
)}
{actions.includes(VM_ACTIONS.SNAPSHOT_DISK_CREATE) && (
{actions?.includes?.(VM_ACTIONS.SNAPSHOT_DISK_CREATE) && isImage && (
<Action
cy={`${VM_ACTIONS.SNAPSHOT_DISK_CREATE}-${DISK_ID}`}
icon={<Camera size={18} />}
handleClick={() => undefined}
/>
)}
{actions.includes(VM_ACTIONS.RESIZE_DISK) && (
{actions?.includes?.(VM_ACTIONS.RESIZE_DISK) && (
<Action
cy={`${VM_ACTIONS.RESIZE_DISK}-${DISK_ID}`}
icon={<Expand size={18} />}
handleClick={() => undefined}
/>
)}
{actions.includes(VM_ACTIONS.DETACH_DISK) && (
{actions?.includes?.(VM_ACTIONS.DETACH_DISK) && (
<Action
cy={`${VM_ACTIONS.DETACH_DISK}-${DISK_ID}`}
icon={<Trash size={18} />}

View File

@ -16,13 +16,20 @@
/* eslint-disable jsdoc/require-jsdoc */
import * as React from 'react'
import PropTypes from 'prop-types'
import { Button } from '@material-ui/core'
import StorageList from 'client/components/Tabs/Vm/Storage/List'
import { useDialog } from 'client/hooks'
import { TabContext } from 'client/components/Tabs/TabProvider'
import { DialogConfirmation } from 'client/components/Dialogs'
import StorageList from 'client/components/Tabs/Vm/Storage/List'
import { Tr } from 'client/components/HOC'
import * as VirtualMachine from 'client/models/VirtualMachine'
import * as Helper from 'client/models/Helper'
import { T, VM_ACTIONS } from 'client/constants'
const VmStorageTab = ({ tabProps = {} }) => {
const { display, show, hide } = useDialog()
const { data: vm } = React.useContext(TabContext)
const { actions = [] } = tabProps
@ -32,7 +39,31 @@ const VmStorageTab = ({ tabProps = {} }) => {
const actionsAvailable = Helper.getActionsAvailable(actions, hypervisor)
return (
<StorageList actions={actionsAvailable} disks={disks} />
<>
{actionsAvailable?.includes?.(VM_ACTIONS.ATTACH_DISK) && (
<Button
data-cy='resize'
size='small'
color='secondary'
onClick={show}
variant='contained'
>
{Tr(T.AttachDisk)}
</Button>
)}
<StorageList actions={actionsAvailable} disks={disks} />
{display && (
<DialogConfirmation
title={T.AttachDisk}
handleAccept={hide}
handleCancel={hide}
>
<p>TODO: should define in view yaml ??</p>
</DialogConfirmation>
)}
</>
)
}

View File

@ -17,6 +17,11 @@
// INFORMATION
export const RENAME = 'rename'
// ATTRIBUTES
export const ADD_ATTRIBUTE = 'add'
export const EDIT_ATTRIBUTE = 'edit'
export const DELETE_ATTRIBUTE = 'delete'
// PERMISSION
export const CHANGE_MODE = 'chmod'

View File

@ -44,6 +44,14 @@ module.exports = {
SignIn: 'Sign In',
SignOut: 'Sign Out',
Submit: 'Submit',
Resize: 'Resize',
ResizeCapacity: 'Resize capacity',
AttachDisk: 'Attach disk',
AttachNic: 'Attach nic',
Detach: 'Detach',
/* questions */
DoYouWantProceed: 'Do you want proceed?',
/* dashboard */
InTotal: 'In Total',
@ -171,16 +179,7 @@ module.exports = {
StartTime: 'Start time',
EndTime: 'End time',
Locked: 'Locked',
/* instances schema */
IP: 'IP',
Reschedule: 'Reschedule',
DeployID: 'Deploy ID',
/* flow schema */
Strategy: 'Strategy',
ShutdownAction: 'Shutdown action',
ReadyStatusGate: 'Ready status gate',
Attributes: 'Attributes',
/* permissions */
Permissions: 'Permissions',
@ -193,6 +192,31 @@ module.exports = {
Owner: 'Owner',
Other: 'Other',
/* instances schema */
IP: 'IP',
Reschedule: 'Reschedule',
DeployID: 'Deploy ID',
Monitoring: 'Monitoring',
/* flow schema */
Strategy: 'Strategy',
ShutdownAction: 'Shutdown action',
ReadyStatusGate: 'Ready status gate',
/* VM schema */
/* VM schema - capacity */
PhysicalCpu: 'Physical CPU',
VirtualCpu: 'Virtual CPU',
VirtualCores: 'Virtual Cores',
Cores: 'Cores',
Sockets: 'Sockets',
Memory: 'Memory',
CostCpu: 'Cost / CPU',
CostMByte: 'Cost / MByte',
/* VM schema - network */
NIC: 'NIC',
Alias: 'Alias',
/* security group schema */
TCP: 'TCP',
UDP: 'UDP',

View File

@ -40,6 +40,7 @@ export const terminateVm = createAction(
})
)
export const updateUserTemplate = createAction('vm/update', vmService.updateUserTemplate)
export const rename = createAction('vm/rename', vmService.rename)
export const changePermissions = createAction('vm/chmod', vmService.changePermissions)
export const changeOwnership = createAction('vm/chown', vmService.changeOwnership)

View File

@ -36,6 +36,8 @@ export const useVmApi = () => {
getVm: id => unwrapDispatch(actions.getVm({ id })),
getVms: options => unwrapDispatch(actions.getVms(options)),
terminateVm: id => unwrapDispatch(actions.terminateVm({ id })),
updateUserTemplate: (id, template, replace) =>
unwrapDispatch(actions.updateUserTemplate({ id, template, replace })),
rename: (id, name) => unwrapDispatch(actions.rename({ id, name })),
changePermissions: (id, permissions) =>
unwrapDispatch(actions.changePermissions({ id, permissions })),

View File

@ -119,6 +119,31 @@ export const vmService = ({
return res?.data
},
/**
* Replaces the user template contents.
*
* @param {object} params - Request parameters
* @param {string|number} params.id - Virtual machine id
* @param {string} params.template - The new user template contents.
* @param {0|1} params.replace -
* Update type:
* ``0``: Replace the whole template.
* ``1``: Merge new template with the existing one.
* @returns {number} Virtual machine id
* @throws Fails when response isn't code 200
*/
updateUserTemplate: async ({ id, template, replace }) => {
const name = Actions.VM_UPDATE
const command = { name, ...Commands[name] }
const config = requestConfig({ id, template, replace }, command)
const res = await RestClient.request(config)
if (!res?.id || res?.id !== httpCodes.ok.id) throw res?.data
return res?.data
},
/**
* Changes the permission bits of a virtual machine.
*

View File

@ -31,10 +31,20 @@ const callAll = (...fns) => (...args) => fns.forEach(fn => fn && fn?.(...args))
*/
const useDialog = () => {
const [display, setDisplay] = useState(false)
const show = () => setDisplay(true)
const hide = () => setDisplay(false)
const [values, setValues] = useState(null)
const toggle = () => setDisplay(prev => !prev)
const show = newValues => {
setDisplay(true)
newValues && setValues(newValues)
}
const hide = () => {
setDisplay(false)
setValues(null)
}
const getToggleProps = (props = {}) => ({
'aria-controls': 'target',
'aria-expanded': Boolean(display),
@ -53,6 +63,7 @@ const useDialog = () => {
return {
display,
values,
show,
hide,
toggle,

View File

@ -92,10 +92,10 @@ export const permissionsToOctal = permissions => {
/**
* Returns the permission numeric code.
*
* @param {string[]} props - Array with Use, Manage and Access permissions.
* @param {('YES'|'NO')} props.0 - `true` if use permission is allowed
* @param {('YES'|'NO')} props.1 - `true` if manage permission is allowed
* @param {('YES'|'NO')} props.2 - `true` if access permission is allowed
* @param {string[]} category - Array with Use, Manage and Access permissions.
* @param {('YES'|'NO')} category.0 - `true` if use permission is allowed
* @param {('YES'|'NO')} category.1 - `true` if manage permission is allowed
* @param {('YES'|'NO')} category.2 - `true` if access permission is allowed
* @returns {number} Permission code number.
*/
const getCategoryValue = ([u, m, a]) => (
@ -128,3 +128,37 @@ export const getActionsAvailable = (actions = {}, hypervisor = '') =>
return !!enabled && !notOn?.includes?.(hypervisor)
})
.map(([actionName, _]) => actionName)
/**
*
* @param {object} list - List of attributes
* @param {object} options - Filter options
* @param {object} [options.extra] - List of extra RegExp to filter
* @param {RegExp} [options.hidden] - RegExp of hidden attributes
* @returns {{attributes: object}} List of filtered attributes
*/
export const filterAttributes = (list, options = {}) => {
const { extra = {}, hidden = /^$/ } = options
const response = {}
const addAttributeToList = (listName, [attributeName, attributeValue]) => {
response[listName] = {
...response[listName],
[attributeName]: attributeValue
}
}
Object.entries(list)
.filter(attribute => {
const [filterName] = Object.entries(extra)
.find(([_, regexp]) => attribute[0].match(regexp)) ?? []
return filterName ? addAttributeToList(filterName, attribute) : true
})
.forEach(attribute => {
!attribute[0].match(hidden) &&
addAttributeToList('attributes', attribute)
})
return response
}