1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-01-27 14:03:40 +03:00

F #5422: Merge branch f-5422 (#1558)

bring developments set aside due to 6.2.0 release
This commit is contained in:
Sergio Betanzos 2021-11-04 10:11:43 +01:00 committed by GitHub
parent 7e750c72a6
commit 03e93e8ea5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
102 changed files with 17004 additions and 1974 deletions

View File

@ -89,24 +89,28 @@ info-tabs:
vcenter_panel:
enabled: true
actions:
copy: true
add: true
edit: true
delete: true
lxc_panel:
enabled: true
actions:
copy: true
add: true
edit: true
delete: true
monitoring_panel:
enabled: true
actions:
copy: true
add: false
edit: false
delete: false
attributes_panel:
enabled: true
actions:
copy: true
add: true
edit: true
delete: true
@ -147,7 +151,7 @@ info-tabs:
snapshot_revert: true
snapshot_delete: true
placement:
history:
enabled: true
sched_actions:

View File

@ -5,7 +5,7 @@ vcenter_prepend_command: ''
sunstone_prepend: ''
# Support
support_url: ''
support_url: 'https://opennebula.zendesk.com/api/v2'
support_token: ''
# this display button and clock icon in table of vm

View File

@ -89,24 +89,28 @@ info-tabs:
vcenter_panel:
enabled: true
actions:
copy: true
add: false
edit: false
delete: false
lxc_panel:
enabled: true
actions:
copy: true
add: false
edit: false
delete: false
monitoring_panel:
enabled: true
actions:
copy: true
add: false
edit: false
delete: false
attributes_panel:
enabled: true
actions:
copy: true
add: true
edit: false
delete: false
@ -146,7 +150,7 @@ info-tabs:
snapshot_revert: true
snapshot_delete: true
placement:
history:
enabled: true
sched_actions:

File diff suppressed because it is too large Load Diff

View File

@ -107,7 +107,8 @@
"luxon": "1.28.0",
"marked": "2.0.0",
"morgan": "1.10.0",
"node-zendesk": "^2.1.0",
"multer": "1.4.3",
"node-zendesk": "2.1.0",
"notistack": "2.0.2",
"opennebula-guacamole": "1.0.0",
"path": "0.12.7",
@ -119,7 +120,7 @@
"react-dom": "17.0.2",
"react-flatpickr": "3.10.7",
"react-flow-renderer": "9.6.0",
"react-hook-form": "7.17.4",
"react-hook-form": "7.18.1",
"react-json-pretty": "2.2.0",
"react-minimal-pie-chart": "8.2.0",
"react-opennebula-ace": "1.0.1",

View File

@ -29,6 +29,7 @@ const AutocompleteController = memo(
cy = `autocomplete-${generateKey()}`,
name = '',
label = '',
tooltip = '',
multiple = false,
values = [],
fieldProps = {}
@ -83,6 +84,10 @@ const AutocompleteController = memo(
{...inputParams}
/>
)}
{...(tooltip && {
loading: true,
loadingText: labelCanBeTranslated(tooltip) ? Tr(tooltip) : tooltip
})}
{...fieldProps}
/>
)
@ -97,8 +102,9 @@ AutocompleteController.propTypes = {
cy: PropTypes.string,
name: PropTypes.string.isRequired,
label: PropTypes.any,
tooltip: PropTypes.any,
multiple: PropTypes.bool,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
values: PropTypes.arrayOf(PropTypes.object),
fieldProps: PropTypes.object
}

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo, useMemo } from 'react'
import { memo, useMemo, useEffect } from 'react'
import PropTypes from 'prop-types'
import { TextField } from '@mui/material'
@ -43,27 +43,31 @@ const SelectController = memo(
} = useController({ name, control })
const needShrink = useMemo(
() => values.find(v => v.value === optionSelected)?.text !== '',
() => multiple || values.find(v => v.value === optionSelected)?.text !== '',
[optionSelected]
)
useEffect(() => {
if (multiple && !Array.isArray(optionSelected)) {
onChange([optionSelected])
}
}, [multiple])
return (
<TextField
{...inputProps}
inputRef={ref}
value={optionSelected}
onChange={
multiple
? event => {
const { options = [] } = event.target
const newValue = options
.filter(option => option.selected)
.map(option => option.value)
onChange={!multiple ? onChange : evt => {
const { target: { options } } = evt
const newValue = []
for (const option of options) {
option.selected && newValue.push(option.value)
}
onChange(newValue)
}
: onChange
}
}}
select
fullWidth
SelectProps={{ native: true, multiple }}
@ -88,11 +92,12 @@ const SelectController = memo(
</TextField>
)
},
(prevProps, nextProps) =>
prevProps.error === nextProps.error &&
prevProps.values.length === nextProps.values.length &&
prevProps.label === nextProps.label &&
prevProps.tooltip === nextProps.tooltip
(prev, next) =>
prev.error === next.error &&
prev.values.length === next.values.length &&
prev.label === next.label &&
prev.tooltip === next.tooltip &&
prev.multiple === next.multiple
)
SelectController.propTypes = {

View File

@ -62,6 +62,7 @@ const TextController = memo(
value={value}
onChange={onChange}
multiline={multiline}
rows={3}
type={type}
label={labelCanBeTranslated(label) ? Tr(label) : label}
InputProps={{
@ -93,7 +94,7 @@ TextController.propTypes = {
tooltip: PropTypes.any,
watcher: PropTypes.func,
dependencies: PropTypes.oneOfType([
PropTypes.strin,
PropTypes.string,
PropTypes.arrayOf(PropTypes.string)
]),
fieldProps: PropTypes.object,

View File

@ -57,11 +57,12 @@ const CustomMobileStepper = ({
<Box className={classes.root}>
<Box minHeight={60}>
<Typography className={classes.title}>
{labelCanBeTranslated(label) ? Tr(label) : label}
{labelCanBeTranslated(label) ? <Translate word={label} /> : label}
</Typography>
{Boolean(errors[id]) && (
<Typography className={classes.error} variant='caption' color='error'>
{errors[id]?.message}
{labelCanBeTranslated(errors[id]?.message)
? Tr(errors[id]?.message) : errors[id]?.message}
</Typography>
)}
</Box>

View File

@ -93,7 +93,8 @@ const CustomStepper = ({
disabled={activeStep + 1 < stepIdx}
optional={errors[id] && (
<Typography variant='caption' color='error'>
{errors[id]?.message}
{labelCanBeTranslated(errors[id]?.message)
? Tr(errors[id]?.message) : errors[id]?.message}
</Typography>
)}
>
@ -101,7 +102,7 @@ const CustomStepper = ({
StepIconComponent={StepIconStyled}
error={Boolean(errors[id]?.message)}
>
{labelCanBeTranslated(label) ? Tr(label) : label}
{labelCanBeTranslated(label) ? <Translate word={label} /> : label}
</StepLabel>
</StepButton>
</Step>

View File

@ -16,6 +16,7 @@
import { useState, useMemo, useCallback, useEffect, JSXElementConstructor } from 'react'
import PropTypes from 'prop-types'
import { sprintf } from 'sprintf-js'
import { BaseSchema } from 'yup'
import { useFormContext } from 'react-hook-form'
import { DevTool } from '@hookform/devtools'
@ -26,6 +27,7 @@ import CustomMobileStepper from 'client/components/FormStepper/MobileStepper'
import CustomStepper from 'client/components/FormStepper/Stepper'
import SkeletonStepsForm from 'client/components/FormStepper/Skeleton'
import { groupBy, Step, isDevelopment } from 'client/utils'
import { T } from 'client/constants'
const FIRST_STEP = 0
@ -67,20 +69,23 @@ const FormStepper = ({ steps = [], schema, onSubmit }) => {
return { id, data: stepData, ...step }
}
const setErrors = ({ inner = [], ...rest }) => {
const setErrors = ({ inner = [], ...rest } = {}) => {
const errorsByPath = groupBy(inner, 'path') ?? {}
const totalErrors = Object.keys(errorsByPath).length
totalErrors > 0
? setError(id, {
type: 'manual',
message: `${totalErrors} error(s) occurred`
})
? setError(id, { type: 'manual', message: [T.ErrorsOcurred, totalErrors] })
: setError(id, rest)
inner?.forEach(({ path, type, message }) =>
inner?.forEach(({ path, type, errors: message }) => {
if (isDevelopment()) {
// the package @hookform/devtools requires message as string
const [key, ...values] = [message].flat()
setError(`${id}.${path}`, { type, message: sprintf(key, ...values) })
} else {
setError(`${id}.${path}`, { type, message })
)
}
})
}
const handleStep = stepToAdvance => {

View File

@ -69,7 +69,7 @@ const ButtonToTriggerForm = ({
aria-expanded={open ? 'true' : undefined}
aria-haspopup={isGroupButton ? 'true' : false}
disabled={!options.length}
disableElevation
endicon={isGroupButton ? <NavArrowDown /> : undefined}
onClick={evt => !isGroupButton
? openDialogForm(options[0])

View File

@ -17,14 +17,20 @@
import { createElement, useMemo } from 'react'
import PropTypes from 'prop-types'
import { Grid, Typography } from '@mui/material'
import { FormControl, Grid } from '@mui/material'
import { useFormContext } from 'react-hook-form'
import * as FC from 'client/components/FormControl'
import { Tr } from 'client/components/HOC'
import Legend from 'client/components/Forms/Legend'
import { INPUT_TYPES } from 'client/constants'
const NOT_DEPEND_ATTRIBUTES = ['watcher', 'transform', 'Table', 'renderValue']
const NOT_DEPEND_ATTRIBUTES = [
'watcher',
'transform',
'Table',
'getRowId',
'renderValue'
]
const INPUT_CONTROLLER = {
[INPUT_TYPES.TEXT]: FC.TextController,
@ -40,10 +46,12 @@ const INPUT_CONTROLLER = {
[INPUT_TYPES.TOGGLE]: FC.ToggleController
}
const FormWithSchema = ({ id, cy, fields, className, legend }) => {
const FormWithSchema = ({ id, cy, fields, rootProps, className, legend, legendTooltip }) => {
const formContext = useFormContext()
const { control, watch } = formContext
const { sx: sxRoot, restOfRootProps } = rootProps ?? {}
const getFields = useMemo(() => typeof fields === 'function' ? fields() : fields, [])
if (getFields.length === 0) return null
@ -53,11 +61,14 @@ const FormWithSchema = ({ id, cy, fields, className, legend }) => {
: id ? `${id}.${name}` : name // concat form ID if exists
return (
<fieldset className={className}>
<FormControl
component='fieldset'
className={className}
sx={{ width: '100%', ...sxRoot }}
{...restOfRootProps}
>
{legend && (
<Typography variant='subtitle1' component='legend'>
{Tr(legend)}
</Typography>
<Legend title={legend} tooltip={legendTooltip} />
)}
<Grid container spacing={1} alignContent='flex-start'>
{getFields?.map?.(
@ -111,7 +122,7 @@ const FormWithSchema = ({ id, cy, fields, className, legend }) => {
}
)}
</Grid>
</fieldset>
</FormControl>
)
}
@ -123,6 +134,8 @@ FormWithSchema.propTypes = {
PropTypes.arrayOf(PropTypes.object)
]),
legend: PropTypes.string,
legendTooltip: PropTypes.string,
rootProps: PropTypes.object,
className: PropTypes.string
}

View File

@ -0,0 +1,54 @@
/* ------------------------------------------------------------------------- *
* 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 { memo } from 'react'
import PropTypes from 'prop-types'
import { styled, Typography } from '@mui/material'
import { Translate } from 'client/components/HOC'
import AdornmentWithTooltip from 'client/components/FormControl/Tooltip'
const StyledLegend = styled(props => (
<Typography variant='subtitle1' component='legend' {...props} />
))(({ theme, tooltip }) => ({
marginBottom: '1em',
padding: '0em 1em 0.2em 0.5em',
borderBottom: `2px solid ${theme.palette.secondary.main}`,
...(!!tooltip && {
display: 'inline-flex',
alignItems: 'center'
})
}))
const Legend = memo(({ title, tooltip }) => {
return (
<StyledLegend tooltip={tooltip}>
<Translate word={title} />
{!!tooltip && <AdornmentWithTooltip title={tooltip} />}
</StyledLegend>
)
}, (prev, next) =>
prev.title === next.title &&
prev.tooltip === next.tooltip
)
Legend.propTypes = {
title: PropTypes.string,
tooltip: PropTypes.string
}
Legend.displayName = 'FieldsetLegend'
export default Legend

View File

@ -37,7 +37,7 @@ export const FORM_FIELDS = inputs =>
name,
type,
options: optionsValue,
defaultValue
default: defaultValue
})
}
})

View File

@ -73,7 +73,7 @@ const PERIODIC_FIELD = {
type: INPUT_TYPES.CHECKBOX,
validation: yup
.boolean()
.default(false),
.default(() => false),
grid: { md: 12 }
}
@ -106,27 +106,16 @@ const REPEAT_FIELD = {
const DAYS_FIELD = {
name: 'DAYS',
dependOf: [PERIODIC_FIELD.name, REPEAT_FIELD.name],
multiple: (dependValues = {}) => {
const { [REPEAT_FIELD.name]: repeat } = dependValues
return REPEAT_VALUES.WEEKLY === repeat
},
type: (dependValues = {}) => {
const { [REPEAT_FIELD.name]: repeat } = dependValues
return REPEAT_VALUES.WEEKLY === repeat ? INPUT_TYPES.SELECT : INPUT_TYPES.TEXT
},
label: (dependValues = {}) => {
const { [REPEAT_FIELD.name]: repeat } = dependValues
return {
dependOf: [REPEAT_FIELD.name, PERIODIC_FIELD.name],
multiple: ([repeat] = []) => REPEAT_VALUES.WEEKLY === repeat,
type: ([repeat] = []) =>
REPEAT_VALUES.WEEKLY === repeat ? INPUT_TYPES.SELECT : INPUT_TYPES.TEXT,
label: ([repeat] = []) => ({
[REPEAT_VALUES.WEEKLY]: 'Days of week',
[REPEAT_VALUES.MONTHLY]: 'Days of month',
[REPEAT_VALUES.YEARLY]: 'Days of year',
[REPEAT_VALUES.HOURLY]: "Each 'x' hours"
}[repeat]
},
}[repeat]),
values: [
{ text: T.Sunday, value: '0' },
{ text: T.Monday, value: '1' },
@ -136,12 +125,9 @@ const DAYS_FIELD = {
{ text: T.Friday, value: '5' },
{ text: T.Saturday, value: '6' }
],
htmlType: (dependValues = {}) => {
const { [PERIODIC_FIELD.name]: periodic, [REPEAT_FIELD.name]: repeat } = dependValues
if (!periodic) return INPUT_TYPES.HIDDEN
return REPEAT_VALUES.HOURLY === repeat ? 'number' : undefined
htmlType: ([repeat, isPeriodic] = []) => {
if (!isPeriodic) return INPUT_TYPES.HIDDEN
if (repeat === REPEAT_VALUES.HOURLY) return 'number'
},
validation: yup
.string()

View File

@ -49,7 +49,7 @@ const Content = ({ data, setFormData }) => {
)
}
const NetworkStep = () => ({
const HostsTableStep = () => ({
id: STEP_ID,
label: T.SelectHost,
resolver: SCHEMA,
@ -61,4 +61,4 @@ Content.propTypes = {
setFormData: PropTypes.func
}
export default NetworkStep
export default HostsTableStep

View File

@ -13,10 +13,9 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { SetStateAction } from 'react'
import { useMemo, useState, JSXElementConstructor } from 'react'
import PropTypes from 'prop-types'
import { useWatch } from 'react-hook-form'
import { useFormContext } from 'react-hook-form'
import { NetworkAlt as NetworkIcon, BoxIso as ImageIcon } from 'iconoir-react'
import { Stack, Checkbox, styled } from '@mui/material'
@ -24,31 +23,30 @@ import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautif
import { Translate } from 'client/components/HOC'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { TAB_ID as BOOTING_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting'
import { TAB_ID as OS_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting'
import { TAB_ID as STORAGE_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/storage'
import { TAB_ID as NIC_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/networking'
import { T } from 'client/constants'
export const BOOT_ORDER_ID = 'BOOT'
const BootItem = styled('div')(({ theme }) => ({
/** @returns {string} Boot order path in form */
export const BOOT_ORDER_NAME = () => `${EXTRA_ID}.${OS_ID}.${BOOT_ORDER_ID}`
const BootItemDraggable = styled('div')(({ theme, disabled }) => ({
display: 'flex',
alignItems: 'center',
gap: '0.5em',
border: `1px solid ${theme.palette.divider}`,
borderRadius: '0.5em',
padding: '1em',
marginBottom: '1em',
backgroundColor: theme.palette.background.default
}))
const BootItemDraggable = styled(BootItem)(({ theme }) => ({
backgroundColor: theme.palette.background.default,
'&:before': {
content: "''",
display: 'block',
width: 16,
height: 10,
backgroundImage: `linear-gradient(
background: !disabled && `linear-gradient(
to bottom,
${theme.palette.action.active} 4px,
transparent 4px,
@ -61,20 +59,20 @@ const BootItemDraggable = styled(BootItem)(({ theme }) => ({
/**
* @param {string} id - Resource id: 'NIC<index>' or 'DISK<index>'
* @param {Array} list - List of resources
* @param {object} formData - Form data
* @param {SetStateAction} setFormData - React set state action
* @param {object} currentBootOrder - Current boot order
* @returns {string} Updated boot order after remove
*/
export const reorderBootAfterRemove = (id, list, formData, setFormData) => {
export const reorderBootAfterRemove = (id, list, currentBootOrder) => {
const type = String(id).toLowerCase().replace(/\d+/g, '') // nic | disk
const getIndexFromId = id => String(id).toLowerCase().replace(type, '')
const idxToRemove = getIndexFromId(id)
const ids = list
.filter(resource => resource.NAME !== id)
const otherIds = list
.filter(resource => resource.NAME !== String(id))
.map(resource => String(resource.NAME).toLowerCase())
const newBootOrder = [...formData?.[BOOTING_ID]?.[BOOT_ORDER_ID]?.split(',').filter(Boolean)]
.filter(bootId => !bootId.startsWith(type) || ids.includes(bootId))
const newBootOrder = [...currentBootOrder?.split(',').filter(Boolean)]
.filter(bootId => !bootId.startsWith(type) || otherIds.includes(bootId))
.map(bootId => {
if (!bootId.startsWith(type)) return bootId
@ -85,31 +83,22 @@ export const reorderBootAfterRemove = (id, list, formData, setFormData) => {
: `${type}${resourceId - 1}`
})
reorder(newBootOrder, setFormData)
return newBootOrder.join(',')
}
/**
* @param {string[]} newBootOrder - New boot order
* @param {SetStateAction} setFormData - React set state action
*/
const reorder = (newBootOrder, setFormData) => {
setFormData(prev => ({
...prev,
[EXTRA_ID]: {
...prev[EXTRA_ID],
[BOOTING_ID]: {
...prev[EXTRA_ID]?.[BOOTING_ID],
[BOOT_ORDER_ID]: newBootOrder.join(',')
}
}
}))
}
/** @returns {JSXElementConstructor} Boot order component */
const BootOrder = () => {
const { setValue, getValues } = useFormContext()
const [bootOrder, setBootOrder] = useState(
getValues(BOOT_ORDER_NAME())?.split(',')?.filter(Boolean) ?? []
)
const BootOrder = ({ data, setFormData, control }) => {
const booting = useWatch({ name: `${EXTRA_ID}.${BOOTING_ID}.${BOOT_ORDER_ID}`, control })
const bootOrder = booting?.split(',').filter(Boolean) ?? []
const updateValues = updatedBootOrder => {
setValue(BOOT_ORDER_NAME(), updatedBootOrder.join(','))
setBootOrder(updatedBootOrder)
}
const disks = data?.[STORAGE_ID]
const disks = useMemo(() => getValues(`${EXTRA_ID}.${STORAGE_ID}`)
?.map((disk, idx) => {
const isVolatile = !disk?.IMAGE && !disk?.IMAGE_ID
@ -124,9 +113,9 @@ const BootOrder = ({ data, setFormData, control }) => {
</>
)
}
}) ?? []
}) ?? [], [])
const nics = data?.[NIC_ID]
const nics = useMemo(() => getValues(`${EXTRA_ID}.${NIC_ID}`)
?.map((nic, idx) => ({
ID: `nic${idx}`,
NAME: (
@ -135,7 +124,7 @@ const BootOrder = ({ data, setFormData, control }) => {
{[nic?.NAME, nic.NETWORK].filter(Boolean).join(': ')}
</>
)
})) ?? []
})) ?? [], [])
const enabledItems = [...disks, ...nics]
.filter(item => bootOrder.includes(item.ID))
@ -145,7 +134,7 @@ const BootOrder = ({ data, setFormData, control }) => {
.filter(item => !bootOrder.includes(item.ID))
/** @param {DropResult} result - Drop result */
const onDragEnd = result => {
const onDragEnd = async result => {
const { destination, source, draggableId } = result
const newBootOrder = [...bootOrder]
@ -154,10 +143,8 @@ const BootOrder = ({ data, setFormData, control }) => {
destination.index !== source.index &&
newBootOrder.includes(draggableId)
) {
newBootOrder.splice(source.index, 1) // remove current position
newBootOrder.splice(destination.index, 0, draggableId) // set in new position
reorder(newBootOrder, setFormData)
newBootOrder.splice(destination.index, 0, newBootOrder.splice(source.index, 1)[0])
updateValues(newBootOrder)
}
}
@ -169,25 +156,29 @@ const BootOrder = ({ data, setFormData, control }) => {
? newBootOrder.splice(itemIndex, 1)
: newBootOrder.push(itemId)
reorder(newBootOrder, setFormData)
updateValues(newBootOrder)
}
return (
<DragDropContext onDragEnd={onDragEnd}>
<Stack>
<Stack gap='1em'>
<Droppable droppableId='booting'>
{({ droppableProps, innerRef, placeholder }) => (
<Stack {...droppableProps} ref={innerRef} m={2}>
{enabledItems.map(({ ID, NAME }, idx) => (
<Draggable key={ID} draggableId={ID} index={idx}>
<Stack {...droppableProps} ref={innerRef} gap={1}>
{[...enabledItems, ...restOfItems].map(({ ID, NAME }, idx) => {
const disabled = !bootOrder.includes(ID)
return (
<Draggable key={ID} isDragDisabled={disabled} draggableId={ID} index={idx}>
{({ draggableProps, dragHandleProps, innerRef }) => (
<BootItemDraggable
{...draggableProps}
{...dragHandleProps}
disabled={disabled}
ref={innerRef}
>
<Checkbox
checked
checked={!disabled}
color='secondary'
data-cy={ID}
onChange={() => handleEnable(ID)}
@ -196,21 +187,12 @@ const BootOrder = ({ data, setFormData, control }) => {
</BootItemDraggable>
)}
</Draggable>
))}
)
})}
{placeholder}
</Stack>
)}
</Droppable>
{restOfItems.map(({ ID, NAME }) => (
<BootItem key={ID}>
<Checkbox
color='secondary'
data-cy={ID}
onChange={() => handleEnable(ID)}
/>
{NAME}
</BootItem>
))}
</Stack>
</DragDropContext>
)

View File

@ -17,7 +17,7 @@ import { string, boolean } from 'yup'
import { useHost } from 'client/features/One'
import { getKvmMachines, getKvmCpuModels } from 'client/models/Host'
import { Field, arrayToOptions, filterFieldsByHypervisor } from 'client/utils'
import { Field, arrayToOptions } from 'client/utils'
import {
T,
INPUT_TYPES,
@ -196,13 +196,8 @@ export const FIRMWARE_SECURE = {
grid: { md: 12 }
}
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} List of Boot fields
*/
export const BOOT_FIELDS = hypervisor =>
filterFieldsByHypervisor(
[
/** @type {Field[]} List of Boot fields */
export const BOOT_FIELDS = [
ARCH,
SD_DISK_BUS,
MACHINE_TYPES,
@ -214,6 +209,4 @@ export const BOOT_FIELDS = hypervisor =>
FEATURE_CUSTOM_ENABLED,
FIRMWARE,
FIRMWARE_SECURE
],
hypervisor
)
]

View File

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { string, number } from 'yup'
import { Field, arrayToOptions, filterFieldsByHypervisor } from 'client/utils'
import { Field, arrayToOptions } from 'client/utils'
import { T, INPUT_TYPES, HYPERVISORS } from 'client/constants'
const { vcenter, lxc, firecracker } = HYPERVISORS
@ -130,13 +130,8 @@ export const IO_THREADS = {
.default(() => undefined)
}
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} List of Features fields
*/
export const FEATURES_FIELDS = hypervisor =>
filterFieldsByHypervisor(
[
/** @type {Field[]} List of Features fields */
export const FEATURES_FIELDS = [
ACPI,
PAE,
APIC,
@ -145,6 +140,4 @@ export const FEATURES_FIELDS = hypervisor =>
GUEST_AGENT,
VIRTIO_SCSI_QUEUES,
IO_THREADS
],
hypervisor
)
]

View File

@ -13,78 +13,51 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useMemo } from 'react'
import PropTypes from 'prop-types'
import { Stack, Typography } from '@mui/material'
import { Stack, Box } from '@mui/material'
import { SystemShut as OsIcon } from 'iconoir-react'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import Legend from 'client/components/Forms/Legend'
import { STEP_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import BootOrder, { reorderBootAfterRemove } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting/bootOrder'
import { STEP_ID as EXTRA_ID, TabType } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import BootOrder, { BOOT_ORDER_NAME, reorderBootAfterRemove } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting/bootOrder'
import { TAB_ID as STORAGE_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/storage'
import { TAB_ID as NIC_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/networking'
import { BOOT_FIELDS, KERNEL_FIELDS, RAMDISK_FIELDS, FEATURES_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting/schema'
import { Translate } from 'client/components/HOC'
import { SECTIONS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting/schema'
import { T } from 'client/constants'
import AdornmentWithTooltip from 'client/components/FormControl/Tooltip'
export const TAB_ID = 'OS'
const Booting = props => {
const { hypervisor } = props
const Booting = ({ hypervisor, ...props }) => {
const sections = useMemo(() => SECTIONS(hypervisor), [hypervisor])
return (
<>
<Stack
display='grid'
gap='1em'
sx={{ gridTemplateColumns: { sm: '1fr', md: '1fr 1fr' } }}
>
{(
!!props.data?.[STORAGE_ID]?.length ||
!!props.data?.[NIC_ID]?.length
) && (
<fieldset>
<Typography
variant='subtitle1'
component='legend'
sx={{
display: 'inline-flex',
alignItems: 'center'
}}
>
<Translate word={T.BootOrder} />
<AdornmentWithTooltip title={T.BootOrderConcept} />
</Typography>
<Box component='fieldset' gridColumn='1/-1'>
<Legend title={T.BootOrder} tooltip={T.BootOrderConcept} />
<BootOrder {...props} />
</fieldset>
</Box>
)}
<Stack
display='grid'
gap='2em'
sx={{ gridTemplateColumns: { sm: '1fr', md: '1fr 1fr' } }}
>
{sections.map(({ id, ...section }) => (
<FormWithSchema
cy='create-vm-template-extra.os-boot'
fields={BOOT_FIELDS(hypervisor)}
legend={T.Boot}
id={STEP_ID}
/>
<FormWithSchema
cy='create-vm-template-extra.os-features'
fields={FEATURES_FIELDS(hypervisor)}
legend={T.Features}
id={STEP_ID}
/>
<FormWithSchema
cy='create-vm-template-extra.os-kernel'
fields={KERNEL_FIELDS(hypervisor)}
legend={T.Kernel}
id={STEP_ID}
/>
<FormWithSchema
cy='create-vm-template-extra.os-ramdisk'
fields={RAMDISK_FIELDS(hypervisor)}
legend={T.Ramdisk}
id={STEP_ID}
key={id}
id={EXTRA_ID}
cy={`create-vm-template-${EXTRA_ID}.${id}`}
{...section}
/>
))}
</Stack>
</>
)
}
@ -95,8 +68,15 @@ Booting.propTypes = {
control: PropTypes.object
}
Booting.displayName = 'Booting'
/** @type {TabType} */
const TAB = {
id: 'booting',
name: T.OSAndCpu,
icon: OsIcon,
Content: Booting,
getError: error => !!error?.[TAB_ID]
}
export default Booting
export default TAB
export { reorderBootAfterRemove }
export { reorderBootAfterRemove, BOOT_ORDER_NAME }

View File

@ -17,7 +17,7 @@ import { boolean, string } from 'yup'
import { useImage } from 'client/features/One'
import { getType } from 'client/models/Image'
import { Field, clearNames, filterFieldsByHypervisor } from 'client/utils'
import { Field, clearNames } from 'client/utils'
import { T, INPUT_TYPES, HYPERVISORS, IMAGE_TYPES_STR } from 'client/constants'
const { vcenter, lxc } = HYPERVISORS
@ -73,12 +73,5 @@ export const KERNEL = {
)
}
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} List of Kernel fields
*/
export const KERNEL_FIELDS = hypervisor =>
filterFieldsByHypervisor(
[KERNEL_PATH_ENABLED, KERNEL, KERNEL_DS],
hypervisor
)
/** @type {Field[]} List of Kernel fields */
export const KERNEL_FIELDS = [KERNEL_PATH_ENABLED, KERNEL, KERNEL_DS]

View File

@ -17,7 +17,7 @@ import { string, boolean } from 'yup'
import { useImage } from 'client/features/One'
import { getType } from 'client/models/Image'
import { Field, clearNames, filterFieldsByHypervisor } from 'client/utils'
import { Field, clearNames } from 'client/utils'
import { T, INPUT_TYPES, HYPERVISORS, IMAGE_TYPES_STR } from 'client/constants'
const { vcenter, lxc, firecracker } = HYPERVISORS
@ -73,12 +73,5 @@ export const RAMDISK = {
)
}
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} List of Ramdisk fields
*/
export const RAMDISK_FIELDS = hypervisor =>
filterFieldsByHypervisor(
[RAMDISK_PATH_ENABLED, RAMDISK, RAMDISK_DS],
hypervisor
)
/** @type {Field[]} List of Ramdisk fields */
export const RAMDISK_FIELDS = [RAMDISK_PATH_ENABLED, RAMDISK, RAMDISK_DS]

View File

@ -0,0 +1,68 @@
/* ------------------------------------------------------------------------- *
* 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. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { string, boolean } from 'yup'
import { Field } from 'client/utils'
import { T, INPUT_TYPES, HYPERVISORS } from 'client/constants'
const { kvm, lxc, vcenter, firecracker } = HYPERVISORS
/** @type {Field} Raw type field */
const TYPE = {
name: 'RAW.TYPE',
label: T.Type,
type: INPUT_TYPES.TEXT,
notOnHypervisors: [lxc, vcenter, firecracker],
validation: string()
.trim()
.notRequired()
.equals([kvm])
.default(() => kvm),
fieldProps: { disabled: true },
grid: { md: 12 }
}
/** @type {Field} Raw data field */
const DATA = {
name: 'RAW.DATA',
label: T.Data,
type: INPUT_TYPES.TEXT,
multiline: true,
notOnHypervisors: [lxc, vcenter, firecracker],
validation: string().trim().notRequired(),
grid: { md: 12 }
}
/** @type {Field} Raw validate field */
const VALIDATE = {
name: 'RAW.VALIDATE',
label: T.Validate,
tooltip: T.RawValidateConcept,
type: INPUT_TYPES.CHECKBOX,
notOnHypervisors: [lxc, vcenter, firecracker],
validation: boolean()
.transform(value => {
if (typeof value === 'boolean') return value
return String(value).toUpperCase() === 'YES'
})
.default(() => false),
grid: { md: 12 }
}
/** @type {Field[]} List of Boot fields */
export const RAW_FIELDS = [TYPE, DATA, VALIDATE]

View File

@ -14,23 +14,52 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { string } from 'yup'
import { BaseSchema, string } from 'yup'
import { Field } from 'client/utils'
import { BOOT_FIELDS } from './bootSchema'
import { KERNEL_FIELDS } from './kernelSchema'
import { RAMDISK_FIELDS } from './ramdiskSchema'
import { FEATURES_FIELDS } from './featuresSchema'
import { RAW_FIELDS } from './rawSchema'
export {
BOOT_FIELDS,
KERNEL_FIELDS,
RAMDISK_FIELDS,
FEATURES_FIELDS
}
import { Field, Section, getObjectSchemaFromFields, filterFieldsByHypervisor } from 'client/utils'
import { T, HYPERVISORS } from 'client/constants'
/**
* @param {HYPERVISORS} [hypervisor] - Template hypervisor
* @returns {Section[]} Sections
*/
const SECTIONS = hypervisor => [
{
id: 'os-boot',
legend: T.Boot,
fields: filterFieldsByHypervisor(BOOT_FIELDS, hypervisor)
},
{
id: 'os-features',
legend: T.Features,
fields: filterFieldsByHypervisor(FEATURES_FIELDS, hypervisor)
},
{
id: 'os-kernel',
legend: T.Kernel,
fields: filterFieldsByHypervisor(KERNEL_FIELDS, hypervisor)
},
{
id: 'os-ramdisk',
legend: T.Ramdisk,
fields: filterFieldsByHypervisor(RAMDISK_FIELDS, hypervisor)
},
{
id: 'os-raw',
legend: T.RawData,
legendTooltip: T.RawDataConcept,
fields: filterFieldsByHypervisor(RAW_FIELDS, hypervisor)
}
]
/** @type {Field} Boot order field */
export const BOOT_ORDER = {
const BOOT_ORDER = {
name: 'OS.BOOT',
validation: string()
.trim()
@ -42,9 +71,15 @@ export const BOOT_ORDER = {
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} All 'OS & CPU' fields
*/
export const OS_FIELDS = hypervisor => [
...BOOT_FIELDS(hypervisor),
...KERNEL_FIELDS(hypervisor),
...RAMDISK_FIELDS(hypervisor),
...FEATURES_FIELDS(hypervisor)
const FIELDS = hypervisor => [
BOOT_ORDER,
...SECTIONS(hypervisor).map(({ fields }) => fields).flat()
]
/**
* @param {HYPERVISORS} [hypervisor] - VM hypervisor
* @returns {BaseSchema} Step schema
*/
const SCHEMA = hypervisor => getObjectSchemaFromFields(FIELDS(hypervisor))
export { SECTIONS, FIELDS, SCHEMA }

View File

@ -13,28 +13,194 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { forwardRef } from 'react'
import PropTypes from 'prop-types'
import {
Folder as ContextIcon,
WarningCircledOutline as WarningIcon,
DeleteCircledOutline,
AddCircledOutline
} from 'iconoir-react'
import {
styled,
FormControl,
Stack,
IconButton,
Button,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText
} from '@mui/material'
// import FormWithSchema from 'client/components/Forms/FormWithSchema'
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'
import { useFieldArray, useForm, FormProvider, useFormContext, get } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
// import { STEP_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
// import {} from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/schema'
// import { T } from 'client/constants'
import { Tooltip } from 'client/components/FormControl'
import { FormWithSchema, Legend } from 'client/components/Forms'
import { Translate } from 'client/components/HOC'
import { STEP_ID as EXTRA_ID, TabType } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { FIELDS, USER_INPUT_SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/schema'
import { getUserInputString } from 'client/models/Helper'
import { T } from 'client/constants'
export const TAB_ID = 'USER_INPUTS'
const UserItemDraggable = styled(ListItem)(({ theme }) => ({
'&:before': {
content: "''",
display: 'block',
width: 16,
height: 10,
background: `linear-gradient(
to bottom,
${theme.palette.action.active} 4px,
transparent 4px,
transparent 6px,
${theme.palette.action.active} 6px
)`
}
}))
const UserInputItem = forwardRef(({
removeAction,
error,
userInput: { name, ...ui } = {},
...props
}, ref) => (
<UserItemDraggable
ref={ref}
secondaryAction={
<IconButton onClick={removeAction}>
<DeleteCircledOutline />
</IconButton>
}
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
{...props}
>
{!!error && (
<ListItemIcon sx={{ '& svg': { color: 'error.dark' } }}>
<Tooltip title={error?.default.message}>
<WarningIcon />
</Tooltip>
</ListItemIcon>
)}
<ListItemText
inset={!error}
primary={name}
primaryTypographyProps={{ variant: 'body1' }}
secondary={getUserInputString(ui)}
/>
</UserItemDraggable>
))
UserInputItem.propTypes = {
removeAction: PropTypes.func,
error: PropTypes.object,
userInput: PropTypes.object
}
UserInputItem.displayName = 'UserInputItem'
const Context = () => {
const { formState: { errors } } = useFormContext()
const { fields: userInputs, append, remove, move } = useFieldArray({
name: `${EXTRA_ID}.${TAB_ID}`
})
const methods = useForm({
defaultValues: USER_INPUT_SCHEMA.default(),
resolver: yupResolver(USER_INPUT_SCHEMA)
})
const onSubmit = newInput => {
append(newInput)
methods.reset()
}
/** @param {DropResult} result - Drop result */
const onDragEnd = result => {
const { destination, source } = result ?? {}
if (destination && destination.index !== source.index) {
move(source.index, destination.index)
}
}
return (
<>
{'Context'}
</>
<FormControl component='fieldset' sx={{ width: '100%' }}>
<Legend title={T.UserInputs} tooltip={T.UserInputsConcept} />
<FormProvider {...methods}>
<Stack
direction='row' alignItems='flex-start' gap='0.5rem'
component='form'
onSubmit={methods.handleSubmit(onSubmit)}
>
<FormWithSchema
cy={`create-vm-template-${EXTRA_ID}.context-user-input`}
fields={FIELDS}
rootProps={{ sx: { m: 0 } }}
/>
<Button
variant='outlined'
type='submit'
startIcon={<AddCircledOutline />}
sx={{ mt: '1em' }}
>
<Translate word={T.Add} />
</Button>
</Stack>
</FormProvider>
<Divider />
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId='context'>
{({ droppableProps, innerRef: outerRef, placeholder }) => (
<List ref={outerRef} {...droppableProps}>
{userInputs?.map(({ id, ...userInput }, index) => (
<Draggable
key={`ui[${index}]`}
draggableId={`ui-${index}`}
index={index}
>
{({ draggableProps, dragHandleProps, innerRef }) => (
<UserInputItem
key={id}
ref={innerRef}
userInput={userInput}
error={get(errors, `${EXTRA_ID}.${TAB_ID}.${index}`)}
removeAction={() => remove(index)}
{...draggableProps}
{...dragHandleProps}
/>
)}
</Draggable>
))}
{placeholder}
</List>
)}
</Droppable>
</DragDropContext>
</FormControl>
)
}
Context.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func
setFormData: PropTypes.func,
hypervisor: PropTypes.string,
control: PropTypes.object
}
Context.displayName = 'Context'
/** @type {TabType} */
const TAB = {
id: 'context',
name: T.Context,
icon: ContextIcon,
Content: Context,
getError: error => !!error?.[TAB_ID]
}
export default Context
export default TAB

View File

@ -13,17 +13,161 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { string } from 'yup'
import { array, string, boolean, number, ref } from 'yup'
import { INPUT_TYPES } from 'client/constants'
import { UserInputType, T, INPUT_TYPES, USER_INPUT_TYPES } from 'client/constants'
import { Field, arrayToOptions, sentenceCase, getObjectSchemaFromFields } from 'client/utils'
const FIELD = {
name: '',
label: '',
tooltip: '',
type: INPUT_TYPES.TEXT,
validation: string().trim().notRequired()
const {
password: uiPassword,
list: uiList,
listMultiple: uiListMultiple,
number: uiNumber,
numberFloat: uiNumberFloat,
range: uiRange,
rangeFloat: uiRangeFloat,
boolean: uiBoolean
} = USER_INPUT_TYPES
const { array: _, ...userInputTypes } = USER_INPUT_TYPES
/** @type {UserInputType[]} User inputs types */
const valuesOfUITypes = Object.values(userInputTypes)
/** @type {Field} Type field */
export const TYPE = {
name: 'type',
label: T.Type,
type: INPUT_TYPES.SELECT,
values: arrayToOptions(valuesOfUITypes, {
addEmpty: false,
getText: type => sentenceCase(type)
}),
validation: string()
.trim()
.required()
.oneOf(valuesOfUITypes)
.default(() => valuesOfUITypes[0]),
grid: { sm: 6, md: 4 }
}
export const CONTEXT_FIELDS = [FIELD]
/** @type {Field} Name field */
export const NAME = {
name: 'name',
label: T.Name,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required()
.default(() => undefined),
grid: { sm: 6, md: 4 }
}
/** @type {Field} Description field */
export const DESCRIPTION = {
name: 'description',
label: T.Description,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { sm: 6, md: 4 }
}
/** @type {Field} Options field */
const OPTIONS = {
name: 'options',
label: T.Options,
tooltip: 'Press ENTER key to add a value',
dependOf: TYPE.name,
type: INPUT_TYPES.AUTOCOMPLETE,
multiple: true,
htmlType: type =>
![uiList, uiListMultiple].includes(type) && INPUT_TYPES.HIDDEN,
validation: array(string().trim())
.default(() => [])
.when(
TYPE.name,
(type, schema) => [uiList, uiListMultiple].includes(type)
? schema.required()
: schema.strip().notRequired()
),
fieldProps: {
freeSolo: true,
placeholder: 'optA,optB,optC'
},
grid: { md: 8 }
}
/** @type {{ MIN: Field, MAX: Field }} Range fields */
const { MIN, MAX } = (() => {
const validation = number()
.positive()
.default(() => undefined)
.when(TYPE.name, (type, schema) => [uiRange, uiRangeFloat].includes(type)
? schema.required()
: schema.strip().notRequired()
)
const common = {
dependOf: TYPE.name,
type: INPUT_TYPES.TEXT,
htmlType: type =>
[uiRange, uiRangeFloat].includes(type) ? 'number' : INPUT_TYPES.HIDDEN,
grid: { sm: 6, md: 4 },
fieldProps: type => ({ step: type === uiRangeFloat ? 0.01 : 1 })
}
return {
MIN: { ...common, name: 'min', label: T.Min, validation: validation.lessThan(ref('max')) },
MAX: { ...common, name: 'max', label: T.Max, validation: validation.moreThan(ref('min')) }
}
})()
/** @type {Field} Default value field */
const DEFAULT_VALUE = {
name: 'default',
label: T.DefaultValue,
dependOf: [TYPE.name, OPTIONS.name],
type: ([type] = []) => [uiBoolean, uiList, uiListMultiple].includes(type)
? INPUT_TYPES.SELECT
: INPUT_TYPES.TEXT,
htmlType: ([type] = []) => ({
[uiNumber]: 'number',
[uiNumberFloat]: 'number',
[uiPassword]: INPUT_TYPES.HIDDEN
}[type]),
multiple: ([type] = []) => type === uiListMultiple,
values: ([type, options = []] = []) => type === uiBoolean
? arrayToOptions(['NO', 'YES'])
: arrayToOptions(options),
validation: string()
.trim()
.default(() => undefined)
.when([TYPE.name, OPTIONS.name], (type, options = [], schema) => {
return {
[uiList]: schema.oneOf(options).notRequired(),
[uiListMultiple]: schema.includesInOptions(options),
[uiRange]: number().min(ref(MIN.name)).max(ref(MAX.name)).integer(),
[uiRangeFloat]: number().min(ref(MIN.name)).max(ref(MAX.name)),
[uiPassword]: schema.strip().notRequired()
}[type] ?? schema
}),
grid: { sm: 6, md: 4 }
}
/** @type {Field} Mandatory field */
const MANDATORY = {
name: 'mandatory',
label: T.Mandatory,
type: INPUT_TYPES.SWITCH,
validation: boolean().default(() => false),
grid: { md: 12 }
}
export const FIELDS = [TYPE, NAME, DESCRIPTION, DEFAULT_VALUE, OPTIONS, MIN, MAX, MANDATORY]
export const USER_INPUT_SCHEMA = getObjectSchemaFromFields(FIELDS)
export const SCHEMA = array(USER_INPUT_SCHEMA).ensure()

View File

@ -14,22 +14,11 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useMemo } from 'react'
// eslint-disable-next-line no-unused-vars
import { useMemo, JSXElementConstructor } from 'react'
import PropTypes from 'prop-types'
import { useFormContext } from 'react-hook-form'
import { useTheme } from '@mui/material'
import {
WarningCircledOutline as WarningIcon,
Db as DatastoreIcon,
ServerConnection as NetworkIcon,
SystemShut as OsIcon,
DataTransferBoth as IOIcon,
Calendar as ActionIcon,
NetworkAlt as PlacementIcon,
Folder as ContextIcon,
ElectronicsChip as NumaIcon
} from 'iconoir-react'
// eslint-disable-next-line no-unused-vars
import { useFormContext, FieldErrors } from 'react-hook-form'
import { useAuth } from 'client/features/Auth'
import { Tr, Translate } from 'client/components/HOC'
@ -49,41 +38,46 @@ import { SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/Extr
import { getActionsAvailable as getSectionsAvailable } from 'client/models/Helper'
import { T } from 'client/constants'
/**
* @typedef {object} TabType
* @property {string} id - Id will be to use in view yaml to hide/display the tab
* @property {string} name - Label of tab
* @property {JSXElementConstructor} Content - Content tab
* @property {object} [icon] - Icon of tab
* @property {function(FieldErrors):boolean} [getError] - Returns `true` if the tab contains an error in form
*/
export const STEP_ID = 'extra'
export const STEP_SECTION = [
{ id: 'storage', name: T.Storage, icon: DatastoreIcon, Content: Storage },
{ id: 'network', name: T.Network, icon: NetworkIcon, Content: Networking },
{ id: 'booting', name: T.OSBooting, icon: OsIcon, Content: Booting },
{ id: 'input_output', name: T.InputOrOutput, icon: IOIcon, Content: InputOutput },
{ id: 'context', name: T.Context, icon: ContextIcon, Content: Context },
{ id: 'sched_action', name: T.ScheduledAction, icon: ActionIcon, Content: ScheduleAction },
{ id: 'placement', name: T.Placement, icon: PlacementIcon, Content: Placement },
{ id: 'numa', name: T.Numa, icon: NumaIcon, Content: Numa }
]
/** @type {TabType[]} */
export const TABS = [Storage, Networking, Booting, InputOutput, Context, ScheduleAction, Placement, Numa]
const Content = ({ data, setFormData }) => {
const theme = useTheme()
const { watch, formState: { errors }, control } = useFormContext()
const { view, getResourceView } = useAuth()
const tabs = useMemo(() => {
const hypervisor = watch(`${GENERAL_ID}.HYPERVISOR`)
const dialog = getResourceView('VM-TEMPLATE')?.dialogs?.create_dialog
const sectionsAvailable = getSectionsAvailable(dialog, hypervisor)
const hypervisor = useMemo(() => watch(`${GENERAL_ID}.HYPERVISOR`), [])
return STEP_SECTION
const sectionsAvailable = useMemo(() => {
const dialog = getResourceView('VM-TEMPLATE')?.dialogs?.create_dialog
return getSectionsAvailable(dialog, hypervisor)
}, [view])
const totalErrors = Object.keys(errors[STEP_ID] ?? {}).length
const tabs = useMemo(
() => TABS
.filter(({ id }) => sectionsAvailable.includes(id))
.map(({ Content, name, icon, ...section }, idx) => ({
.map(({ Content: TabContent, name, getError, ...section }) => ({
...section,
name,
label: <Translate word={name} />,
renderContent: <Content {...{ data, setFormData, hypervisor, control }} />,
icon: errors[STEP_ID]?.[idx] ? (
<WarningIcon color={theme.palette.error.main} />
) : icon
}))
}, [errors[STEP_ID], view, control])
// eslint-disable-next-line react/display-name
renderContent: () => <TabContent {...{ data, setFormData, hypervisor, control }} />,
error: getError?.(errors[STEP_ID])
})),
[totalErrors, view, control]
)
return tabs.length > 0 ? (
<Tabs tabs={tabs} />

View File

@ -0,0 +1,141 @@
/* ------------------------------------------------------------------------- *
* 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 { string, boolean } from 'yup'
import { Field, arrayToOptions, filterFieldsByHypervisor } from 'client/utils'
import { T, INPUT_TYPES, HYPERVISORS } from 'client/constants'
const { vcenter, lxc, kvm } = HYPERVISORS
/** @type {Field} Type field */
const TYPE = {
name: 'GRAPHICS.TYPE',
type: INPUT_TYPES.TOGGLE,
dependOf: '$general.HYPERVISOR',
values: (hypervisor = kvm) => {
const types = {
[vcenter]: [T.VMRC],
[lxc]: [T.VNC]
}[hypervisor] ?? [T.VNC, T.SDL, T.SPICE]
return arrayToOptions(types)
},
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { md: 12 }
}
/** @type {Field} Listen field */
const LISTEN = {
name: 'GRAPHICS.LISTEN',
label: T.ListenOnIp,
type: INPUT_TYPES.TEXT,
dependOf: TYPE.name,
htmlType: noneType => !noneType && INPUT_TYPES.HIDDEN,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
fieldProps: { placeholder: '0.0.0.0' },
grid: { md: 12 }
}
/** @type {Field} Port field */
const PORT = {
name: 'GRAPHICS.PORT',
label: T.ServerPort,
tooltip: T.ServerPortConcept,
type: INPUT_TYPES.TEXT,
dependOf: TYPE.name,
htmlType: noneType => !noneType && INPUT_TYPES.HIDDEN,
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} Keymap field */
const KEYMAP = {
name: 'GRAPHICS.KEYMAP',
label: T.Keymap,
type: INPUT_TYPES.TEXT,
dependOf: TYPE.name,
htmlType: noneType => !noneType && INPUT_TYPES.HIDDEN,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
fieldProps: { placeholder: 'en-us' }
}
/** @type {Field} Password random field */
const RANDOM_PASSWD = {
name: 'GRAPHICS.RANDOM_PASSWD',
label: T.GenerateRandomPassword,
type: INPUT_TYPES.CHECKBOX,
dependOf: TYPE.name,
htmlType: noneType => !noneType && INPUT_TYPES.HIDDEN,
validation: boolean()
.default(() => false)
.transform(value => {
if (typeof value === 'boolean') return value
return String(value).toUpperCase() === 'YES'
}),
grid: { md: 12 }
}
/** @type {Field} Password field */
const PASSWD = {
name: 'GRAPHICS.PASSWD',
label: T.Password,
type: INPUT_TYPES.PASSWORD,
dependOf: [TYPE.name, RANDOM_PASSWD.name],
htmlType: ([noneType, random] = []) =>
(!noneType || random) && INPUT_TYPES.HIDDEN,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { md: 12 }
}
/** @type {Field} Command field */
const COMMAND = {
name: 'GRAPHICS.COMMAND',
label: T.Command,
notOnHypervisors: [lxc],
type: INPUT_TYPES.TEXT,
dependOf: TYPE.name,
htmlType: noneType => !noneType && INPUT_TYPES.HIDDEN,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { md: 12 }
}
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} List of Graphics fields
*/
export const GRAPHICS_FIELDS = hypervisor =>
filterFieldsByHypervisor(
[TYPE, LISTEN, PORT, KEYMAP, PASSWD, RANDOM_PASSWD, COMMAND],
hypervisor
)

View File

@ -13,29 +13,38 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useMemo } from 'react'
import PropTypes from 'prop-types'
import { Stack } from '@mui/material'
import { DataTransferBoth as IOIcon } from 'iconoir-react'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import { FormWithSchema } from 'client/components/Forms'
import { STEP_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { INPUT_OUTPUT_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/inputOutput/schema'
import { STEP_ID as EXTRA_ID, TabType } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import InputsSection, { SECTION_ID as INPUT_ID } from './inputsSection'
import { INPUT_OUTPUT_FIELDS, INPUTS_FIELDS } from './schema'
import { T } from 'client/constants'
export const TAB_ID = ['GRAPHICS', INPUT_ID]
const InputOutput = ({ hypervisor }) => {
const inputsFields = useMemo(() => INPUTS_FIELDS(hypervisor), [hypervisor])
return (
<Stack
display='grid'
gap='2em'
gap='1em'
sx={{ gridTemplateColumns: { sm: '1fr', md: '1fr 1fr' } }}
>
<FormWithSchema
cy='create-vm-template-extra.io-graphics'
cy={`create-vm-template-${EXTRA_ID}.io-graphics`}
fields={INPUT_OUTPUT_FIELDS(hypervisor)}
legend={T.Graphics}
id={STEP_ID}
id={EXTRA_ID}
/>
{inputsFields.length > 0 && (
<InputsSection fields={inputsFields} />
)}
</Stack>
)
}
@ -49,4 +58,13 @@ InputOutput.propTypes = {
InputOutput.displayName = 'InputOutput'
export default InputOutput
/** @type {TabType} */
const TAB = {
id: 'input_output',
name: T.InputOrOutput,
icon: IOIcon,
Content: InputOutput,
getError: error => TAB_ID.some(id => error?.[id])
}
export default TAB

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. *
* ------------------------------------------------------------------------- */
import { string, array, object, ObjectSchema, ArraySchema } from 'yup'
import { PcMouse, PenTablet, Usb, PlugTypeG } from 'iconoir-react'
import { Field, arrayToOptions, filterFieldsByHypervisor, getValidationFromFields } from 'client/utils'
import { T, INPUT_TYPES, DEVICE_TYPES, DEVICE_BUS_TYPES, HYPERVISORS } from 'client/constants'
const { vcenter, lxc } = HYPERVISORS
export const deviceTypeIcons = {
[DEVICE_TYPES.mouse]: <PcMouse />,
[DEVICE_TYPES.tablet]: <PenTablet />
}
export const busTypeIcons = {
[DEVICE_BUS_TYPES.usb]: <Usb />,
[DEVICE_BUS_TYPES.ps2]: <PlugTypeG />
}
/** @type {Field} Type field */
const TYPE = {
name: 'TYPE',
label: T.Type,
notOnHypervisors: [lxc, vcenter],
type: INPUT_TYPES.SELECT,
values: arrayToOptions(Object.values(DEVICE_TYPES)),
validation: string()
.trim()
.required()
.default(() => undefined),
grid: { sm: 6, md: 6 }
}
/** @type {Field} Bus field */
const BUS = {
name: 'BUS',
label: T.Bus,
notOnHypervisors: [lxc, vcenter],
type: INPUT_TYPES.SELECT,
values: arrayToOptions(Object.values(DEVICE_BUS_TYPES)),
validation: string()
.trim()
.required()
.default(() => undefined),
grid: { sm: 6, md: 6 }
}
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} List of Graphic inputs fields
*/
export const INPUTS_FIELDS = (hypervisor) =>
filterFieldsByHypervisor([TYPE, BUS], hypervisor)
/** @type {ObjectSchema} Graphic input object schema */
export const INPUT_SCHEMA = object(getValidationFromFields([TYPE, BUS]))
/** @type {ArraySchema} Graphic inputs schema */
export const INPUTS_SCHEMA = array(INPUT_SCHEMA).ensure()

View File

@ -0,0 +1,128 @@
/* ------------------------------------------------------------------------- *
* 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 { JSXElementConstructor } from 'react'
import PropTypes from 'prop-types'
import { Stack, FormControl, Divider, Button, IconButton } from '@mui/material'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import ListItemText from '@mui/material/ListItemText'
import { DeleteCircledOutline, AddCircledOutline } from 'iconoir-react'
import { useFieldArray, useForm, FormProvider } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import { FormWithSchema, Legend } from 'client/components/Forms'
import { Translate } from 'client/components/HOC'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { INPUT_SCHEMA, deviceTypeIcons, busTypeIcons } from './schema'
import { T } from 'client/constants'
export const SECTION_ID = 'INPUT'
/**
* @param {object} props - Props
* @param {Array} props.fields - Fields
* @returns {JSXElementConstructor} - Inputs section
*/
const InputsSection = ({ fields }) => {
const { fields: inputs, append, remove } = useFieldArray({
name: `${EXTRA_ID}.${SECTION_ID}`
})
const methods = useForm({
defaultValues: INPUT_SCHEMA.default(),
resolver: yupResolver(INPUT_SCHEMA)
})
const onSubmit = newInput => {
append(newInput)
methods.reset()
}
return (
<FormControl component='fieldset' sx={{ width: '100%' }}>
<Legend title={T.Inputs} />
<FormProvider {...methods}>
<Stack
direction='row' alignItems='flex-start' gap='0.5rem'
component='form'
onSubmit={methods.handleSubmit(onSubmit)}
>
<FormWithSchema
cy={`create-vm-template-${EXTRA_ID}.io-inputs`}
fields={fields}
rootProps={{ sx: { m: 0 } }}
/>
<Button
variant='outlined'
type='submit'
startIcon={<AddCircledOutline />}
sx={{ mt: '1em' }}
>
<Translate word={T.Add} />
</Button>
</Stack>
</FormProvider>
<Divider />
<List>
{inputs?.map(({ id, TYPE, BUS }, index) => {
const deviceIcon = deviceTypeIcons[TYPE]
const deviceInfo = `${TYPE}`
const busIcon = busTypeIcons[BUS]
const busInfo = `${BUS}`
return (
<ListItem
key={id}
secondaryAction={
<IconButton onClick={() => remove(index)}>
<DeleteCircledOutline />
</IconButton>
}
sx={{ '&:hover': { bgcolor: 'action.hover' } }}
>
<ListItemText
primary={
<Stack
component='span'
direction='row'
spacing={2}
sx={{ '& > *': { width: 36 } }}
>
{deviceIcon}
<span>{deviceInfo}</span>
<Divider orientation='vertical' flexItem />
{busIcon}
<span>{busInfo}</span>
</Stack>
}
primaryTypographyProps={{ variant: 'body1' }}
/>
</ListItem>
)
})}
</List>
</FormControl>
)
}
InputsSection.propTypes = {
fields: PropTypes.array
}
InputsSection.displayName = 'InputsSection'
export default InputsSection

View File

@ -13,131 +13,28 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { string, boolean } from 'yup'
import { object, ObjectSchema } from 'yup'
import { Field, arrayToOptions, filterFieldsByHypervisor } from 'client/utils'
import { T, INPUT_TYPES, HYPERVISORS } from 'client/constants'
const { vcenter, lxc, kvm } = HYPERVISORS
/** @type {Field} Type field */
export const TYPE = {
name: 'GRAPHICS.TYPE',
label: T.Type,
type: INPUT_TYPES.TOGGLE,
dependOf: '$general.HYPERVISOR',
values: (hypervisor = kvm) => {
const types = {
[vcenter]: [T.VMRC],
[lxc]: [T.VNC]
}[hypervisor] ?? [T.VNC, T.SDL, T.SPICE]
return arrayToOptions(types)
},
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { md: 12 }
}
/** @type {Field} Listen field */
export const LISTEN = {
name: 'GRAPHICS.LISTEN',
label: T.ListenOnIp,
type: INPUT_TYPES.TEXT,
dependOf: TYPE.name,
htmlType: noneType => !noneType && INPUT_TYPES.HIDDEN,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
fieldProps: { placeholder: '0.0.0.0' },
grid: { md: 12 }
}
/** @type {Field} Port field */
export const PORT = {
name: 'GRAPHICS.PORT',
label: T.ServerPort,
tooltip: T.ServerPortConcept,
type: INPUT_TYPES.TEXT,
dependOf: TYPE.name,
htmlType: noneType => !noneType && INPUT_TYPES.HIDDEN,
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} Keymap field */
export const KEYMAP = {
name: 'GRAPHICS.KEYMAP',
label: T.Keymap,
type: INPUT_TYPES.TEXT,
dependOf: TYPE.name,
htmlType: noneType => !noneType && INPUT_TYPES.HIDDEN,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
fieldProps: { placeholder: 'en-us' }
}
/** @type {Field} Password random field */
export const RANDOM_PASSWD = {
name: 'OS.RANDOM_PASSWD',
label: T.GenerateRandomPassword,
type: INPUT_TYPES.CHECKBOX,
dependOf: TYPE.name,
htmlType: noneType => !noneType && INPUT_TYPES.HIDDEN,
validation: boolean()
.default(() => false)
.transform(value => {
if (typeof value === 'boolean') return value
return String(value).toUpperCase() === 'YES'
}),
grid: { md: 12 }
}
/** @type {Field} Password field */
export const PASSWD = {
name: 'GRAPHICS.PASSWD',
label: T.Password,
type: INPUT_TYPES.PASSWORD,
dependOf: [TYPE.name, RANDOM_PASSWD.name],
htmlType: ([noneType, random] = []) =>
(!noneType || random) && INPUT_TYPES.HIDDEN,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { md: 12 }
}
/** @type {Field} Command field */
export const COMMAND = {
name: 'GRAPHICS.COMMAND',
label: T.Command,
notOnHypervisors: [lxc],
type: INPUT_TYPES.TEXT,
dependOf: TYPE.name,
htmlType: noneType => !noneType && INPUT_TYPES.HIDDEN,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { md: 12 }
}
import { GRAPHICS_FIELDS } from './graphicsSchema'
import { INPUTS_SCHEMA } from './inputsSchema'
import { Field, getObjectSchemaFromFields } from 'client/utils'
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} List of I/O fields
*/
export const INPUT_OUTPUT_FIELDS = hypervisor =>
filterFieldsByHypervisor(
[TYPE, LISTEN, PORT, KEYMAP, PASSWD, RANDOM_PASSWD, COMMAND],
hypervisor
)
[...GRAPHICS_FIELDS(hypervisor)]
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {ObjectSchema} I/O schema
*/
export const SCHEMA = hypervisor => object({
INPUT: INPUTS_SCHEMA
}).concat(getObjectSchemaFromFields([
...GRAPHICS_FIELDS(hypervisor)
]))
export * from './graphicsSchema'
export * from './inputsSchema'

View File

@ -13,53 +13,39 @@
* 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 makeStyles from '@mui/styles/makeStyles'
import { Edit, Trash } from 'iconoir-react'
import { useWatch } from 'react-hook-form'
import { Stack } from '@mui/material'
import { ServerConnection as NetworkIcon, Edit, Trash } from 'iconoir-react'
import { useFormContext, useFieldArray } from 'react-hook-form'
import { useListForm } from 'client/hooks'
import ButtonToTriggerForm from 'client/components/Forms/ButtonToTriggerForm'
import SelectCard, { Action } from 'client/components/Cards/SelectCard'
import { AttachNicForm } from 'client/components/Forms/Vm'
import { Translate } from 'client/components/HOC'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { SCHEMA as EXTRA_SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema'
import { reorderBootAfterRemove } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting'
import { STEP_ID as EXTRA_ID, TabType } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { mapNameByIndex } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema'
import { BOOT_ORDER_NAME, reorderBootAfterRemove } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting'
import { stringToBoolean } from 'client/models/Helper'
import { T } from 'client/constants'
const useStyles = makeStyles({
root: {
paddingBlock: '1em',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, auto))',
gap: '1em'
}
})
export const TAB_ID = 'NIC'
const Networking = ({ data, setFormData, control }) => {
const classes = useStyles()
const nics = useWatch({ name: `${EXTRA_ID}.${TAB_ID}`, control })
const mapNameFunction = mapNameByIndex('NIC')
const { handleSetList, handleRemove, handleSave } = useListForm({
parent: EXTRA_ID,
key: TAB_ID,
list: nics,
setList: setFormData,
getItemId: item => item.NAME,
addItemId: (item, _, itemIndex) => ({ ...item, NAME: `${TAB_ID}${itemIndex}` })
const Networking = () => {
const { setValue, getValues } = useFormContext()
const { fields: nics, replace, update, append } = useFieldArray({
name: `${EXTRA_ID}.${TAB_ID}`
})
const reorderNics = () => {
const diskSchema = EXTRA_SCHEMA.pick([TAB_ID])
const { [TAB_ID]: newList } = diskSchema.cast({ [TAB_ID]: data?.[TAB_ID] })
const removeAndReorder = nicName => {
const updatedNics = nics.filter(({ NAME }) => NAME !== nicName).map(mapNameFunction)
const currentBootOrder = getValues(BOOT_ORDER_NAME())
const updatedBootOrder = reorderBootAfterRemove(nicName, nics, currentBootOrder)
handleSetList(newList)
replace(updatedNics)
setValue(BOOT_ORDER_NAME(), updatedBootOrder)
}
return (
@ -74,17 +60,23 @@ const Networking = ({ data, setFormData, control }) => {
options={[{
dialogProps: { title: T.AttachNic },
form: () => AttachNicForm({ nics }),
onSubmit: handleSave
onSubmit: nic => append(mapNameFunction(nic, nics.length))
}]}
/>
<div className={classes.root}>
{nics?.map(item => {
const { NAME, RDP, SSH, NETWORK, PARENT, EXTERNAL } = item
<Stack
pb='1em'
display='grid'
gridTemplateColumns='repeat(auto-fit, minmax(300px, 0.5fr))'
gap='1em'
mt='1em'
>
{nics?.map((item, index) => {
const { id, NAME, RDP, SSH, NETWORK, PARENT, EXTERNAL } = item
const hasAlias = nics?.some(nic => nic.PARENT === NAME)
return (
<SelectCard
key={NAME}
key={id ?? NAME}
title={[NAME, NETWORK].filter(Boolean).join(' - ')}
subheader={<>
{Object
@ -92,7 +84,7 @@ const Networking = ({ data, setFormData, control }) => {
RDP: stringToBoolean(RDP),
SSH: stringToBoolean(SSH),
EXTERNAL: stringToBoolean(EXTERNAL),
ALIAS: PARENT
[`PARENT: ${PARENT}`]: PARENT
})
.map(([k, v]) => v ? `${k}` : '')
.filter(Boolean)
@ -104,11 +96,7 @@ const Networking = ({ data, setFormData, control }) => {
{!hasAlias &&
<Action
data-cy={`remove-${NAME}`}
handleClick={() => {
handleRemove(NAME)
reorderNics()
reorderBootAfterRemove(NAME, nics, data, setFormData)
}}
handleClick={() => removeAndReorder(NAME)}
icon={<Trash />}
/>
}
@ -120,10 +108,16 @@ const Networking = ({ data, setFormData, control }) => {
}}
options={[{
dialogProps: {
title: <Translate word={T.EditSomething} values={[`${NAME} - ${NETWORK}`]} />
title: (
<Translate
word={T.EditSomething}
values={[`${NAME} - ${NETWORK}`]}
/>
)
},
form: () => AttachNicForm({ nics }, item),
onSubmit: newValues => handleSave(newValues, NAME)
onSubmit: updatedNic =>
update(index, mapNameFunction(updatedNic, index))
}]}
/>
</>
@ -131,7 +125,7 @@ const Networking = ({ data, setFormData, control }) => {
/>
)
})}
</div>
</Stack>
</>
)
}
@ -143,6 +137,13 @@ Networking.propTypes = {
control: PropTypes.object
}
Networking.displayName = 'Networking'
/** @type {TabType} */
const TAB = {
id: 'network',
name: T.Network,
icon: NetworkIcon,
Content: Networking,
getError: error => !!error?.[TAB_ID]
}
export default Networking
export default TAB

View File

@ -13,25 +13,28 @@
* 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 { ElectronicsChip as NumaIcon } from 'iconoir-react'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import { STEP_ID as GENERAL_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General'
import { VIRTUAL_CPU } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General/capacitySchema'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { NUMA_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/numa/schema'
import { STEP_ID as EXTRA_ID, TabType } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { VIRTUAL_CPU as VCPU_FIELD } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General/capacitySchema'
import { FIELDS as NUMA_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/numa/schema'
import { T } from 'client/constants'
const Placement = ({ hypervisor }) => {
export const TAB_ID = 'NUMA'
const Numa = ({ hypervisor }) => {
return (
<>
<FormWithSchema
cy='create-vm-template-extra.vcpu'
fields={[VIRTUAL_CPU]}
cy={`create-vm-template-${EXTRA_ID}.vcpu`}
fields={[VCPU_FIELD]}
id={GENERAL_ID}
/>
<FormWithSchema
cy='create-vm-template-extra.numa'
cy={`create-vm-template-${EXTRA_ID}.numa`}
fields={NUMA_FIELDS(hypervisor)}
id={EXTRA_ID}
/>
@ -39,13 +42,22 @@ const Placement = ({ hypervisor }) => {
)
}
Placement.propTypes = {
Numa.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
hypervisor: PropTypes.string,
control: PropTypes.object
}
Placement.displayName = 'Placement'
/** @type {TabType} */
const TAB = {
id: 'numa',
name: T.Numa,
icon: NumaIcon,
Content: Numa,
getError: error =>
!!error?.[TAB_ID] ||
!!error?.[VCPU_FIELD.name]
}
export default Placement
export default TAB

View File

@ -142,9 +142,10 @@ const HUGEPAGES = {
const hosts = useHost()
const sizes = hosts
.reduce((res, host) => res.concat(getHugepageSizes(host)), [])
.flat()
return arrayToOptions([...new Set(sizes)], {
getText: size => prettyBytes(+size, 'MB')
getText: size => prettyBytes(+size)
})
},
validation: string()
@ -171,8 +172,10 @@ const MEMORY_ACCESS = {
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} List of NUMA fields
*/
export const NUMA_FIELDS = hypervisor =>
const FIELDS = hypervisor =>
filterFieldsByHypervisor(
[PIN_POLICY, CORES, SOCKETS, THREADS, HUGEPAGES, MEMORY_ACCESS],
hypervisor
)
export { FIELDS }

View File

@ -13,16 +13,13 @@
* 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 { NetworkAlt as PlacementIcon } from 'iconoir-react'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import { STEP_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import {
PLACEMENT_HOST_FIELDS,
PLACEMENT_DS_FIELDS
} from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/placement/schema'
import { STEP_ID as EXTRA_ID, TabType } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { SECTIONS, FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/placement/schema'
import { T } from 'client/constants'
const Placement = () => {
@ -34,18 +31,14 @@ const Placement = () => {
return (
<>
{SECTIONS.map(({ id, ...section }) => (
<FormWithSchema
cy='create-vm-template-extra.host-placement'
fields={PLACEMENT_HOST_FIELDS}
legend={T.Host}
id={STEP_ID}
/>
<FormWithSchema
cy='create-vm-template-extra.ds-placement'
fields={PLACEMENT_DS_FIELDS}
legend={T.Datastore}
id={STEP_ID}
key={id}
id={EXTRA_ID}
cy={`create-vm-template-${EXTRA_ID}.${id}`}
{...section}
/>
))}
</>
)
}
@ -57,4 +50,13 @@ Placement.propTypes = {
Placement.displayName = 'Placement'
export default Placement
/** @type {TabType} */
const TAB = {
id: 'placement',
name: T.Placement,
icon: PlacementIcon,
Content: Placement,
getError: error => FIELDS.some(({ name }) => error?.[name])
}
export default TAB

View File

@ -13,11 +13,12 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { string } from 'yup'
import { Field, Section } from 'client/utils'
import { T, INPUT_TYPES } from 'client/constants'
/** @type {Field} Host requirement field */
const HOST_REQ_FIELD = {
name: 'SCHED_REQUIREMENTS',
label: T.HostReqExpression,
@ -26,6 +27,7 @@ const HOST_REQ_FIELD = {
validation: string().trim().notRequired()
}
/** @type {Field} Host rank requirement field */
const HOST_RANK_FIELD = {
name: 'SCHED_RANK',
label: T.HostPolicyExpression,
@ -34,6 +36,7 @@ const HOST_RANK_FIELD = {
validation: string().trim().notRequired()
}
/** @type {Field} Datastore requirement field */
const DS_REQ_FIELD = {
name: 'DS_SCHED_REQUIREMENTS',
label: T.DatastoreReqExpression,
@ -42,6 +45,7 @@ const DS_REQ_FIELD = {
validation: string().trim().notRequired()
}
/** @type {Field} Datastore rank requirement field */
const DS_RANK_FIELD = {
name: 'DS_SCHED_RANK',
label: T.DatastorePolicyExpression,
@ -50,8 +54,26 @@ const DS_RANK_FIELD = {
validation: string().trim().notRequired()
}
export const PLACEMENT_HOST_FIELDS = [HOST_REQ_FIELD, HOST_RANK_FIELD]
/** @type {Section[]} Sections */
const SECTIONS = [
{
id: 'placement-host',
legend: T.Host,
fields: [HOST_REQ_FIELD, HOST_RANK_FIELD]
},
{
id: 'placement-ds',
legend: T.Datastore,
fields: [DS_REQ_FIELD, DS_RANK_FIELD]
}
]
export const PLACEMENT_DS_FIELDS = [DS_REQ_FIELD, DS_RANK_FIELD]
/** @type {Field[]} List of Placement fields */
const FIELDS = [
HOST_REQ_FIELD,
HOST_RANK_FIELD,
DS_REQ_FIELD,
DS_RANK_FIELD
]
export const PLACEMENT_FIELDS = [PLACEMENT_HOST_FIELDS, PLACEMENT_DS_FIELDS]
export { SECTIONS, FIELDS }

View File

@ -15,41 +15,26 @@
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import makeStyles from '@mui/styles/makeStyles'
import { Edit, Trash } from 'iconoir-react'
import { useWatch } from 'react-hook-form'
import { Stack } from '@mui/material'
import { Calendar as ActionIcon, Edit, Trash } from 'iconoir-react'
import { useFieldArray } from 'react-hook-form'
import { useListForm } from 'client/hooks'
import ButtonToTriggerForm from 'client/components/Forms/ButtonToTriggerForm'
import SelectCard, { Action } from 'client/components/Cards/SelectCard'
import { PunctualForm, RelativeForm } from 'client/components/Forms/Vm'
import { Translate } from 'client/components/HOC'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { STEP_ID as EXTRA_ID, TabType } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { mapNameByIndex } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema'
import { T } from 'client/constants'
const useStyles = makeStyles({
root: {
paddingBlock: '1em',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, auto))',
gap: '1em'
}
})
export const TAB_ID = 'SCHED_ACTION'
const ScheduleAction = ({ setFormData, control }) => {
const classes = useStyles()
const scheduleActions = useWatch({ name: `${EXTRA_ID}.${TAB_ID}`, control })
const mapNameFunction = mapNameByIndex('SCHED_ACTION')
const { handleRemove, handleSave } = useListForm({
parent: EXTRA_ID,
key: TAB_ID,
list: scheduleActions,
setList: setFormData,
getItemId: item => item.NAME,
addItemId: (item, _, itemIndex) => ({ ...item, NAME: `${TAB_ID}${itemIndex}` })
const ScheduleAction = () => {
const { fields: scheduleActions, remove, update, append } = useFieldArray({
name: `${EXTRA_ID}.${TAB_ID}`
})
return (
@ -66,30 +51,36 @@ const ScheduleAction = ({ setFormData, control }) => {
name: 'Punctual action',
dialogProps: { title: T.ScheduledAction },
form: () => PunctualForm(),
onSubmit: handleSave
onSubmit: action => append(mapNameFunction(action, scheduleActions.length))
},
{
cy: 'add-sched-action-relative',
name: 'Relative action',
dialogProps: { title: T.ScheduledAction },
form: () => RelativeForm(),
onSubmit: handleSave
onSubmit: action => append(mapNameFunction(action, scheduleActions.length))
}]}
/>
<div className={classes.root}>
{scheduleActions?.map(item => {
const { NAME, ACTION, TIME } = item
<Stack
pb='1em'
display='grid'
gridTemplateColumns='repeat(auto-fit, minmax(300px, 0.5fr))'
gap='1em'
mt='1em'
>
{scheduleActions?.map((item, index) => {
const { id, NAME, ACTION, TIME } = item
const isRelative = String(TIME).includes('+')
return (
<SelectCard
key={NAME}
key={id ?? NAME}
title={`${NAME} - ${ACTION}`}
action={
<>
<Action
data-cy={`remove-${NAME}`}
handleClick={() => handleRemove(NAME)}
handleClick={() => remove(index)}
icon={<Trash />}
/>
<ButtonToTriggerForm
@ -105,7 +96,8 @@ const ScheduleAction = ({ setFormData, control }) => {
form: () => isRelative
? RelativeForm(undefined, item)
: PunctualForm(undefined, item),
onSubmit: newValues => handleSave(newValues, NAME)
onSubmit: updatedAction =>
update(index, mapNameFunction(updatedAction, index))
}]}
/>
</>
@ -113,7 +105,7 @@ const ScheduleAction = ({ setFormData, control }) => {
/>
)
})}
</div>
</Stack>
</>
)
}
@ -125,6 +117,13 @@ ScheduleAction.propTypes = {
control: PropTypes.object
}
ScheduleAction.displayName = 'ScheduleAction'
/** @type {TabType} */
const TAB = {
id: 'sched_action',
name: T.ScheduledAction,
icon: ActionIcon,
Content: ScheduleAction,
getError: error => !!error?.[TAB_ID]
}
export default ScheduleAction
export default TAB

View File

@ -16,37 +16,39 @@
/* eslint-disable jsdoc/require-jsdoc */
import { array, object } from 'yup'
import { PLACEMENT_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/placement/schema'
import { OS_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting/schema'
import { NUMA_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/numa/schema'
import { FIELDS as PLACEMENT_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/placement/schema'
import { FIELDS as OS_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting/schema'
import { FIELDS as NUMA_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/numa/schema'
import { SCHEMA as IO_SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/inputOutput/schema'
import { SCHEMA as CONTEXT_SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/schema'
import { getObjectSchemaFromFields } from 'client/utils'
export const SCHEMA = hypervisor => object({
DISK: array()
.ensure()
.transform(disks => disks?.map((disk, idx) => ({
...disk,
NAME: disk?.NAME?.startsWith('DISK') || !disk?.NAME
? `DISK${idx}`
: disk?.NAME
}))),
NIC: array()
.ensure()
.transform(nics => nics?.map((nic, idx) => ({
...nic,
NAME: nic?.NAME?.startsWith('NIC') || !nic?.NAME
? `NIC${idx}`
: nic?.NAME
}))),
SCHED_ACTION: array()
.ensure()
.transform(actions => actions?.map((action, idx) => ({
...action,
NAME: action?.NAME?.startsWith('SCHED_ACTION') || !action?.NAME
? `SCHED_ACTION${idx}`
: action?.NAME
})))
export const mapNameByIndex = (prefixName) => (resource, idx) => ({
...resource,
NAME: resource?.NAME?.startsWith(prefixName) || !resource?.NAME
? `${prefixName}${idx}`
: resource?.NAME
})
export const DISK_SCHEMA = array()
.ensure()
.transform(disks => disks.map(mapNameByIndex('DISK')))
export const NIC_SCHEMA = array()
.ensure()
.transform(nics => nics.map(mapNameByIndex('NIC')))
export const SCHED_ACTION_SCHEMA = array()
.ensure()
.transform(actions => actions.map(mapNameByIndex('SCHED_ACTION')))
export const SCHEMA = hypervisor => object({
DISK: DISK_SCHEMA,
NIC: NIC_SCHEMA,
SCHED_ACTION: SCHED_ACTION_SCHEMA,
USER_INPUTS: CONTEXT_SCHEMA
})
.concat(IO_SCHEMA(hypervisor))
.concat(getObjectSchemaFromFields([
...PLACEMENT_FIELDS,
...OS_FIELDS(hypervisor),

View File

@ -15,54 +15,41 @@
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import makeStyles from '@mui/styles/makeStyles'
import { Edit, Trash } from 'iconoir-react'
import { useWatch } from 'react-hook-form'
import { Stack } from '@mui/material'
import { Db as DatastoreIcon, Edit, Trash } from 'iconoir-react'
import { useFormContext, useFieldArray } from 'react-hook-form'
import { useListForm } from 'client/hooks'
import ButtonToTriggerForm from 'client/components/Forms/ButtonToTriggerForm'
import SelectCard, { Action } from 'client/components/Cards/SelectCard'
import { ImageSteps, VolatileSteps } from 'client/components/Forms/Vm'
import { StatusCircle, StatusChip } from 'client/components/Status'
import { Translate } from 'client/components/HOC'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { SCHEMA as EXTRA_SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema'
import { reorderBootAfterRemove } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting'
import { STEP_ID as EXTRA_ID, TabType } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { mapNameByIndex } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema'
import { BOOT_ORDER_NAME, reorderBootAfterRemove } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting'
import { getState, getDiskType } from 'client/models/Image'
import { stringToBoolean } from 'client/models/Helper'
import { prettyBytes } from 'client/utils'
import { T } from 'client/constants'
const useStyles = makeStyles({
root: {
paddingBlock: '1em',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, auto))',
gap: '1em'
}
})
export const TAB_ID = 'DISK'
const Storage = ({ data, setFormData, hypervisor, control }) => {
const classes = useStyles()
const disks = useWatch({ name: `${EXTRA_ID}.${TAB_ID}`, control })
const mapNameFunction = mapNameByIndex('DISK')
const { handleSetList, handleRemove, handleSave } = useListForm({
parent: EXTRA_ID,
key: TAB_ID,
list: disks,
setList: setFormData,
getItemId: item => item.NAME,
addItemId: (item, _, itemIndex) => ({ ...item, NAME: `${TAB_ID}${itemIndex}` })
const Storage = ({ hypervisor }) => {
const { getValues, setValue } = useFormContext()
const { fields: disks, replace, update, append } = useFieldArray({
name: `${EXTRA_ID}.${TAB_ID}`
})
const reorderDisks = () => {
const diskSchema = EXTRA_SCHEMA.pick([TAB_ID])
const { [TAB_ID]: newList } = diskSchema.cast({ [TAB_ID]: data?.[TAB_ID] })
const removeAndReorder = diskName => {
const updatedDisks = disks.filter(({ NAME }) => NAME !== diskName).map(mapNameFunction)
const currentBootOrder = getValues(BOOT_ORDER_NAME())
const updatedBootOrder = reorderBootAfterRemove(diskName, disks, currentBootOrder)
handleSetList(newList)
replace(updatedDisks)
setValue(BOOT_ORDER_NAME(), updatedBootOrder)
}
return (
@ -80,20 +67,27 @@ const Storage = ({ data, setFormData, hypervisor, control }) => {
name: T.Image,
dialogProps: { title: T.AttachImage },
form: () => ImageSteps({ hypervisor }),
onSubmit: handleSave
onSubmit: image => append(mapNameFunction(image, disks.length))
},
{
cy: 'attach-volatile-disk',
name: T.Volatile,
dialogProps: { title: T.AttachVolatile },
form: () => VolatileSteps({ hypervisor }),
onSubmit: handleSave
onSubmit: image => append(mapNameFunction(image, disks.length))
}
]}
/>
<div className={classes.root}>
{disks?.map(item => {
<Stack
pb='1em'
display='grid'
gridTemplateColumns='repeat(auto-fit, minmax(300px, 0.5fr))'
gap='1em'
mt='1em'
>
{disks?.map((item, index) => {
const {
id,
NAME,
TYPE,
IMAGE,
@ -115,18 +109,18 @@ const Storage = ({ data, setFormData, hypervisor, control }) => {
return (
<SelectCard
key={NAME}
key={id ?? NAME}
title={isVolatile ? (
<>
{`${NAME} - `}
<Translate word={T.VolatileDisk} />
</>
) : (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5em' }}>
<Stack component='span' alignItems='center' gap='0.5em'>
<StatusCircle color={state?.color} tooltip={state?.name} />
{`${NAME}: ${IMAGE}`}
{isPersistent && <StatusChip text='PERSISTENT' />}
</span>
</Stack>
)}
subheader={<>
{Object
@ -134,7 +128,8 @@ const Storage = ({ data, setFormData, hypervisor, control }) => {
[DATASTORE]: DATASTORE,
READONLY: stringToBoolean(READONLY),
PERSISTENT: stringToBoolean(PERSISTENT),
[isVolatile || ORIGINAL_SIZE === SIZE ? size : `${originalSize}/${size}`]: true,
[isVolatile || ORIGINAL_SIZE === SIZE
? size : `${originalSize}/${size}`]: true,
[type]: type
})
.map(([k, v]) => v ? `${k}` : '')
@ -147,11 +142,7 @@ const Storage = ({ data, setFormData, hypervisor, control }) => {
<Action
data-cy={`remove-${NAME}`}
tooltip={<Translate word={T.Remove} />}
handleClick={() => {
handleRemove(NAME)
reorderDisks()
reorderBootAfterRemove(NAME, disks, data, setFormData)
}}
handleClick={() => removeAndReorder(NAME, index)}
icon={<Trash />}
/>
<ButtonToTriggerForm
@ -167,7 +158,8 @@ const Storage = ({ data, setFormData, hypervisor, control }) => {
form: () => isVolatile
? VolatileSteps({ hypervisor }, item)
: ImageSteps({ hypervisor }, item),
onSubmit: newValues => handleSave(newValues, NAME)
onSubmit: updatedDisk =>
update(index, mapNameFunction(updatedDisk, index))
}]}
/>
</>
@ -175,7 +167,7 @@ const Storage = ({ data, setFormData, hypervisor, control }) => {
/>
)
})}
</div>
</Stack>
</>
)
}
@ -187,6 +179,13 @@ Storage.propTypes = {
control: PropTypes.object
}
Storage.displayName = 'Storage'
/** @type {TabType} */
const TAB = {
id: 'storage',
name: T.Storage,
icon: DatastoreIcon,
Content: Storage,
getError: error => !!error?.[TAB_ID]
}
export default Storage
export default TAB

View File

@ -18,63 +18,25 @@ import { number, boolean } from 'yup'
import { T, INPUT_TYPES, HYPERVISORS } from 'client/constants'
import { Field } from 'client/utils'
/**
* @param {Field} field - Field params
* @param {boolean} field.required - If `true`, add to validation if it's required
* @param {boolean} field.divBy4 - If `true`, add to validation if it's divisible by 4
* @returns {Field} Capacity field params
*/
const CAPACITY_FIELD = ({ dependOf, required, divBy4, ...field }) => {
let validation = number()
.integer('Should be integer number')
.positive('Should be positive number')
.typeError('Must be a number')
const commonValidation = number()
.positive()
.default(() => undefined)
if (required) {
validation = validation.required()
}
if (dependOf) {
validation = validation.when(
dependOf,
(enabledHr, schema) => enabledHr
? schema.required()
: schema.strip().notRequired()
)
}
if (divBy4) {
validation = validation.when(
'HYPERVISOR',
(hypervisor, schema) => hypervisor === HYPERVISORS.vcenter
? schema.isDivisibleBy(4)
: schema
)
}
return {
...field,
dependOf,
type: INPUT_TYPES.TEXT,
htmlType: 'number',
...(dependOf && {
htmlType: dependValue =>
dependValue ? 'number' : INPUT_TYPES.HIDDEN
}),
validation
}
}
/** @type {Field} Memory field */
export const MEMORY = CAPACITY_FIELD({
export const MEMORY = {
name: 'MEMORY',
label: T.Memory,
tooltip: T.MemoryConcept,
divBy4: true,
required: true,
type: INPUT_TYPES.TEXT,
htmlType: 'number',
validation: commonValidation
.required()
.when('HYPERVISOR', (hypervisor, schema) => hypervisor === HYPERVISORS.vcenter
? schema.isDivisibleBy(4)
: schema
),
grid: { md: 12 }
})
}
/** @type {Field} Hot reloading on memory field */
export const ENABLE_HR_MEMORY = {
@ -86,29 +48,40 @@ export const ENABLE_HR_MEMORY = {
}
/** @type {Field} Maximum memory field */
export const MEMORY_MAX = CAPACITY_FIELD({
export const MEMORY_MAX = {
name: 'MEMORY_MAX',
label: T.MaxMemory,
dependOf: ENABLE_HR_MEMORY.name,
type: INPUT_TYPES.TEXT,
htmlType: enabledHr => enabledHr ? 'number' : INPUT_TYPES.HIDDEN,
validation: commonValidation
.when(ENABLE_HR_MEMORY.name, (enabledHr, schema) =>
enabledHr ? schema.required() : schema.strip().notRequired()
),
grid: { xs: 8, md: 6 }
})
}
/** @type {Field} Physical CPU field */
export const PHYSICAL_CPU = CAPACITY_FIELD({
export const PHYSICAL_CPU = {
name: 'CPU',
label: T.PhysicalCpu,
tooltip: T.CpuConcept,
required: true,
type: INPUT_TYPES.TEXT,
htmlType: 'number',
validation: commonValidation.required(),
grid: { md: 12 }
})
}
/** @type {Field} Virtual CPU field */
export const VIRTUAL_CPU = CAPACITY_FIELD({
export const VIRTUAL_CPU = {
name: 'VCPU',
label: T.VirtualCpu,
tooltip: T.VirtualCpuConcept,
type: INPUT_TYPES.TEXT,
htmlType: 'number',
validation: commonValidation,
grid: { md: 12 }
})
}
/** @type {Field} Hot reloading on virtual CPU field */
export const ENABLE_HR_VCPU = {
@ -120,12 +93,18 @@ export const ENABLE_HR_VCPU = {
}
/** @type {Field} Maximum virtual CPU field */
export const VCPU_MAX = CAPACITY_FIELD({
export const VCPU_MAX = {
name: 'VCPU_MAX',
label: T.MaxVirtualCpu,
dependOf: ENABLE_HR_VCPU.name,
type: INPUT_TYPES.TEXT,
htmlType: enabledHr => enabledHr ? 'number' : INPUT_TYPES.HIDDEN,
validation: commonValidation
.when(ENABLE_HR_VCPU.name, (enabledHr, schema) =>
enabledHr ? schema.required() : schema.strip().notRequired()
),
grid: { xs: 8, md: 6 }
})
}
/** @type {Field[]} List of capacity fields */
export const FIELDS = [

View File

@ -22,7 +22,7 @@ import FormWithSchema from 'client/components/Forms/FormWithSchema'
import useStyles from 'client/components/Forms/VmTemplate/CreateForm/Steps/General/styles'
import { HYPERVISOR_FIELD } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General/informationSchema'
import { SCHEMA, FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General/schema'
import { SCHEMA, SECTIONS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General/schema'
import { getActionsAvailable as getSectionsAvailable } from 'client/models/Helper'
import { T } from 'client/constants'
@ -33,11 +33,11 @@ const Content = () => {
const { view, getResourceView } = useAuth()
const hypervisor = useWatch({ name: `${STEP_ID}.HYPERVISOR` })
const groups = useMemo(() => {
const sections = useMemo(() => {
const dialog = getResourceView('VM-TEMPLATE')?.dialogs?.create_dialog
const sectionsAvailable = getSectionsAvailable(dialog, hypervisor)
return FIELDS(hypervisor)
return SECTIONS(hypervisor)
.filter(({ id, required }) => required || sectionsAvailable.includes(id))
}, [view, hypervisor])
@ -49,14 +49,13 @@ const Content = () => {
legend={T.Hypervisor}
id={STEP_ID}
/>
{groups.map(({ id, legend, fields }) => (
{sections.map(({ id, ...section }) => (
<FormWithSchema
key={id}
className={classes[id]}
cy={`create-vm-template-general.${id}`}
fields={fields}
legend={legend}
id={STEP_ID}
className={classes[id]}
cy={`create-vm-template-${STEP_ID}.${id}`}
{...section}
/>
))}
</div>

View File

@ -21,14 +21,14 @@ import { FIELDS as VM_GROUP_FIELDS } from './vmGroupSchema'
import { FIELDS as OWNERSHIP_FIELDS } from './ownershipSchema'
import { FIELDS as VCENTER_FIELDS } from './vcenterSchema'
import { filterFieldsByHypervisor, getObjectSchemaFromFields, Field } from 'client/utils'
import { Section, filterFieldsByHypervisor, getObjectSchemaFromFields } from 'client/utils'
import { T, HYPERVISORS } from 'client/constants'
/**
* @param {HYPERVISORS} [hypervisor] - Template hypervisor
* @returns {{ id: string, legend: string, fields: Field[] }[]} Fields
* @returns {Section[]} Fields
*/
const FIELDS = hypervisor => [
const SECTIONS = hypervisor => [
{
id: 'information',
legend: T.Information,
@ -63,7 +63,7 @@ const FIELDS = hypervisor => [
*/
const SCHEMA = hypervisor => getObjectSchemaFromFields([
HYPERVISOR_FIELD,
...FIELDS(hypervisor).map(({ fields }) => fields).flat()
...SECTIONS(hypervisor).map(({ fields }) => fields).flat()
])
export { FIELDS, SCHEMA }
export { SECTIONS, SCHEMA }

View File

@ -19,7 +19,7 @@ export default makeStyles(theme => ({
root: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '2em',
gap: theme.spacing(1),
[theme.breakpoints.down('lg')]: {
gridTemplateColumns: '1fr'
}

View File

@ -15,21 +15,42 @@
* ------------------------------------------------------------------------- */
import General, { STEP_ID as GENERAL_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General'
import ExtraConfiguration, { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { jsonToXml } from 'client/models/Helper'
// import { jsonToXml } from 'client/models/Helper'
import { userInputsToArray, userInputsToObject } from 'client/models/Helper'
import { createSteps } from 'client/utils'
const Steps = createSteps(
[General, ExtraConfiguration],
{
// transformInitialValue: (vmTemplate, schema) =>,
transformInitialValue: (vmTemplate, schema) => ({
...schema.pick([GENERAL_ID]).cast({
[GENERAL_ID]: { ...vmTemplate, ...vmTemplate?.TEMPLATE }
}, { stripUnknown: true }),
...schema.pick([EXTRA_ID]).cast({
[EXTRA_ID]: {
...vmTemplate?.TEMPLATE,
USER_INPUTS: userInputsToArray(vmTemplate?.TEMPLATE?.USER_INPUTS)
}
}, { context: { [EXTRA_ID]: vmTemplate.TEMPLATE } })
}),
transformBeforeSubmit: formData => {
const {
[GENERAL_ID]: general = {},
[EXTRA_ID]: extraTemplate = {}
[EXTRA_ID]: { USER_INPUTS, ...extraTemplate } = {}
} = formData ?? {}
const templateXML = jsonToXml({ ...general, ...extraTemplate })
return { template: templateXML }
// const templateXML = jsonToXml({ ...general, ...extraTemplate })
// return { template: templateXML }
const userInputs = userInputsToObject(USER_INPUTS)
const inputsOrder = USER_INPUTS.map(({ name }) => name).join(',')
return {
...general,
...extraTemplate,
USER_INPUTS: userInputs,
INPUTS_ORDER: inputsOrder
}
}
}
)

View File

@ -27,7 +27,7 @@ import FormStepper, { SkeletonStepsForm } from 'client/components/FormStepper'
import Steps from 'client/components/Forms/VmTemplate/CreateForm/Steps'
const CreateForm = ({ template, onSubmit }) => {
const stepsForm = useMemo(() => Steps(template, {}), [])
const stepsForm = useMemo(() => Steps(template, template), [])
const { steps, defaultValues, resolver, transformBeforeSubmit } = stepsForm
const methods = useForm({

View File

@ -26,7 +26,7 @@ const NAME = {
label: 'VM name',
tooltip: `
Defaults to 'template name-<vmid>' when empty.
When creating several VMs, the wildcard %idx will be
When creating several VMs, the wildcard %%idx will be
replaced with a number starting from 0`,
type: INPUT_TYPES.TEXT,
validation: string().trim().default(() => undefined)

View File

@ -14,10 +14,12 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import ButtonToTriggerForm, { ButtonToTriggerFormPropTypes } from 'client/components/Forms/ButtonToTriggerForm'
import Legend from 'client/components/Forms/Legend'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
export {
ButtonToTriggerForm,
ButtonToTriggerFormPropTypes,
Legend,
FormWithSchema
}

View File

@ -87,7 +87,11 @@ const translateString = (str = '', values) => {
}
if (key && Array.isArray(values)) {
try {
key = sprintf(key, ...values)
} catch (e) {
return str
}
}
return key
@ -104,12 +108,14 @@ const Tr = (str = '') => {
const valuesTr = !Array.isArray(values) ? [values] : values
return translateString(key, valuesTr)
return translateString(key, valuesTr.filter(Boolean))
}
const Translate = ({ word = '', values = [] }) => {
const valuesTr = !Array.isArray(values) ? [values] : values
return translateString(word, valuesTr)
const [w, v = values] = Array.isArray(word) ? word : [word, values]
const valuesTr = !Array.isArray(v) ? [v] : v
return translateString(w, valuesTr)
}
TranslateProvider.propTypes = {

View File

@ -43,7 +43,7 @@ const User = () => {
disablePadding
>
{() => (
<MenuList>
<MenuList disablePadding>
<MenuItem onClick={logout} data-cy='header-logout-button'>
<Translate word={T.SignOut} />
</MenuItem>

View File

@ -20,7 +20,7 @@ import { Tooltip, Typography } from '@mui/material'
import { StatusChip } from 'client/components/Status'
const MultipleTags = ({ tags, limitTags = 1 }) => {
const MultipleTags = ({ tags, limitTags = 1, clipboard }) => {
if (tags?.length === 0) {
return null
}
@ -29,7 +29,9 @@ const MultipleTags = ({ tags, limitTags = 1 }) => {
const Tags = tags
.splice(0, limitTags)
.map((tag, idx) => <StatusChip key={`${idx}-${tag}`} text={tag} />)
.map((tag, idx) => (
<StatusChip key={`${idx}-${tag}`} text={tag} clipboard={clipboard} />
))
return (
<>
@ -62,6 +64,10 @@ const MultipleTags = ({ tags, limitTags = 1 }) => {
MultipleTags.propTypes = {
tags: PropTypes.array,
clipboard: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.string
]),
limitTags: PropTypes.number
}

View File

@ -16,18 +16,21 @@
import { memo } from 'react'
import PropTypes from 'prop-types'
import { Typography, lighten, darken } from '@mui/material'
import { Typography, Tooltip, lighten, darken } from '@mui/material'
import makeStyles from '@mui/styles/makeStyles'
import { Copy as CopyIcon } from 'iconoir-react'
import { useClipboard } from 'client/hooks'
import { Tr, Translate } from 'client/components/HOC'
import { addOpacityToColor } from 'client/utils'
import { SCHEMES } from 'client/constants'
import { T, SCHEMES } from 'client/constants'
const useStyles = makeStyles(({ spacing, palette, typography }) => {
const getBackgroundColor = palette.mode === SCHEMES.DARK ? lighten : darken
const defaultStateColor = palette.grey[palette.mode === SCHEMES.DARK ? 300 : 700]
return {
root: ({ stateColor = defaultStateColor }) => {
text: ({ stateColor = defaultStateColor, clipboard }) => {
const paletteColor = palette[stateColor]
const color = paletteColor?.contrastText ?? getBackgroundColor(stateColor, 0.75)
@ -36,30 +39,72 @@ const useStyles = makeStyles(({ spacing, palette, typography }) => {
return {
color,
backgroundColor: addOpacityToColor(bgColor, 0.2),
cursor: 'default',
padding: spacing('0.25rem', '0.5rem'),
borderRadius: 2,
textTransform: 'uppercase',
fontSize: typography.overline.fontSize,
fontWeight: 500,
lineHeight: 'normal'
lineHeight: 'normal',
cursor: 'default',
...(clipboard && {
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: '0.5em',
'&:hover > .copy-icon': {
color: bgColor
}
})
}
}
}
})
const StatusChip = memo(({ stateColor, text = '', ...props }) => {
const classes = useStyles({ stateColor })
const callAll = (...fns) => (...args) => fns.forEach(fn => fn && fn?.(...args))
const StatusChip = memo(({
stateColor,
text = '',
clipboard = false,
onClick,
...props
}) => {
const { copy, isCopied } = useClipboard()
const textToCopy = typeof clipboard === 'string' ? clipboard : text
const classes = useStyles({ stateColor, clipboard })
const handleCopy = evt => {
!isCopied && copy(textToCopy)
evt.stopPropagation()
}
return (
<Typography component='span' className={classes.root} {...props}>
<Tooltip
arrow
open={isCopied}
title={<>{'✔️'}<Translate word={T.CopiedToClipboard} /></>}
>
<Typography
component='span'
className={classes.text}
onClick={callAll(onClick, handleCopy)}
{...props}
>
{text}
{clipboard && (
<CopyIcon
className='copy-icon'
title={Tr(T.ClickToCopy)}
/>
)}
</Typography>
</Tooltip>
)
},
(prev, next) =>
prev.stateColor === next.stateColor &&
prev.text === next.text
prev.text === next.text &&
prev.clipboard === next.clipboard
)
StatusChip.propTypes = {
@ -67,7 +112,12 @@ StatusChip.propTypes = {
text: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node
])
]),
clipboard: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.string
]),
onClick: PropTypes.func
}
StatusChip.displayName = 'StatusChip'

View File

@ -14,7 +14,8 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { Column } from 'react-table'
import { CategoryFilter } from 'client/components/Tables/Enhanced/Utils'
import CategoryFilter from 'client/components/Tables/Enhanced/Utils/CategoryFilter'
import { GlobalAction } from 'client/components/Tables/Enhanced/Utils/GlobalActions/Action'
/**
* Add filters defined in view yaml to columns.
@ -65,7 +66,7 @@ export const createCategoryFilter = title => ({
*
* @param {object} params - Config parameters
* @param {object[]} params.filters - Which buttons are visible to operate over the resources
* @param {object[]} params.actions - Actions
* @param {GlobalAction[]} params.actions - Actions
* @returns {object} Action with filters
*/
export const createActions = ({ filters = {}, actions = [] }) => {

View File

@ -114,12 +114,13 @@ const Actions = () => {
label: T.Update,
tooltip: T.Update,
selected: { max: 1 },
disabled: true,
color: 'secondary',
action: rows => {
// const { ID } = rows?.[0]?.original ?? {}
// const path = generatePath(PATH.TEMPLATE.VMS.CREATE, { id: ID })
const vmTemplate = rows?.[0]?.original ?? {}
// const path = generatePath(PATH.TEMPLATE.VMS.CREATE, vmTemplate)
const path = PATH.TEMPLATE.VMS.CREATE
// history.push(path)
history.push(path, vmTemplate)
}
},
{

View File

@ -13,10 +13,11 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useMemo, JSXElementConstructor } from 'react'
import { memo, useMemo, JSXElementConstructor } from 'react'
import PropTypes from 'prop-types'
import {
Copy as CopyIcon,
AddSquare as AddIcon,
Edit as EditIcon,
Trash as DeleteIcon,
@ -24,8 +25,11 @@ import {
Cancel as CancelIcon
} from 'iconoir-react'
import { useClipboard } from 'client/hooks'
import { Translate } from 'client/components/HOC'
import { Action } from 'client/components/Cards/SelectCard'
import { camelCase } from 'client/utils'
import { T } from 'client/constants'
/**
* @param {string} action - Action name
@ -66,6 +70,25 @@ ActionButton.propTypes = {
handleClick: PropTypes.func.isRequired
}
/**
* @param {ActionButtonProps} props - Action button props
* @returns {JSXElementConstructor} Action button with props
*/
const Copy = memo(({ value, ...props }) => {
const { copy, isCopied } = useClipboard(1000)
return (
<ActionButton
action='copy'
tooltip={<>{'✔️'}<Translate word={T.CopiedToClipboard} /></>}
tooltipProps={{ open: isCopied }}
handleClick={async () => await copy(value)}
icon={CopyIcon}
{...props}
/>
)
}, (prev, next) => prev.value === next.value)
/**
* @param {ActionButtonProps} props - Action button props
* @returns {JSXElementConstructor} Action button with props
@ -96,9 +119,17 @@ const Accept = props => <ActionButton action='accept' icon={AcceptIcon} {...prop
*/
const Cancel = props => <ActionButton action='cancel' icon={CancelIcon} {...props}/>
Copy.displayName = 'CopyActionButton'
Copy.propTypes = {
...ActionButton,
value: PropTypes.string
}
export {
getAttributeCy,
ActionButton,
Copy,
Add,
Accept,
Cancel,

View File

@ -17,46 +17,41 @@ import { memo, useMemo, useState, createRef } from 'react'
import PropTypes from 'prop-types'
import { Link as RouterLink } from 'react-router-dom'
import { Typography, Link } from '@mui/material'
import makeStyles from '@mui/styles/makeStyles'
import { Typography, Link, Stack } from '@mui/material'
import { useDialog } from 'client/hooks'
import { DialogConfirmation } from 'client/components/Dialogs'
import { Actions, Inputs } from 'client/components/Tabs/Common/Attribute'
import { Tr, Translate } from 'client/components/HOC'
import { Translate } from 'client/components/HOC'
import { T } from 'client/constants'
const useStyles = makeStyles({
wrapper: {
display: 'flex',
alignItems: 'center',
'& > *:first-child': {
flexGrow: 1
}
},
select: {
textOverflow: 'ellipsis'
},
nested: ({ numberOfParents }) => numberOfParents > 0 && ({
paddingLeft: `${numberOfParents}em`
})
})
const Column = props => (
<Stack direction='row' alignItems='center'
sx={{ '&:hover > .actions': { display: 'contents' } }}
{...props}
/>
)
const ActionWrapper = props => (
<Stack direction='row' component='span' className='actions' {...props} />
)
const Attribute = memo(({
canCopy = false,
canDelete,
canEdit,
handleEdit,
handleDelete,
handleEdit,
handleGetOptionList,
link,
name,
path = name,
showActionsOnHover = false,
value,
valueInOptionList
}) => {
const numberOfParents = useMemo(() => path.split('.').length - 1, [path])
const classes = useStyles({ numberOfParents })
const [isEditing, setIsEditing] = useState(() => false)
const [options, setOptions] = useState(() => [])
@ -90,10 +85,22 @@ const Attribute = memo(({
return (
<>
<Typography noWrap variant='body2' title={Tr(name)} className={classes.nested}>
{Tr(name)}
<Column>
<Typography
noWrap
component='span'
variant='body2'
title={typeof name === 'string' ? name : undefined}
flexGrow={1}
sx={numberOfParents > 0 ? { pl: `${numberOfParents}em` } : undefined}
>
{name}
</Typography>
<div className={classes.wrapper}>
<ActionWrapper {...(showActionsOnHover && { display: 'none' })}>
{canCopy && <Actions.Copy name={name} value={name} />}
</ActionWrapper>
</Column>
<Column>
{isEditing ? (
<>
{handleGetOptionList ? (
@ -117,6 +124,7 @@ const Attribute = memo(({
noWrap
component='span'
variant='body2'
flexGrow={1}
title={typeof value === 'string' ? value : undefined}
>
{link
@ -128,12 +136,17 @@ const Attribute = memo(({
: value
}
</Typography>
<ActionWrapper {...(showActionsOnHover && { display: 'none' })}>
{canCopy && (
<Actions.Copy name={name} value={value} />
)}
{canEdit && (
<Actions.Edit name={name} handleClick={handleActiveEditForm} />
)}
{canDelete && (
<Actions.Delete name={name} handleClick={show} />
)}
</ActionWrapper>
</>
)}
@ -148,24 +161,26 @@ const Attribute = memo(({
</p>
</DialogConfirmation>
)}
</div>
</Column>
</>
)
})
export const AttributePropTypes = {
canCopy: PropTypes.bool,
canDelete: PropTypes.bool,
canEdit: PropTypes.bool,
handleEdit: PropTypes.func,
handleDelete: PropTypes.func,
handleEdit: PropTypes.func,
handleGetOptionList: PropTypes.func,
link: PropTypes.string,
name: PropTypes.string.isRequired,
path: PropTypes.string,
showActionsOnHover: PropTypes.bool,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object
]),
path: PropTypes.string,
valueInOptionList: PropTypes.string
}

View File

@ -35,14 +35,39 @@ import makeStyles from '@mui/styles/makeStyles'
import { List } from 'client/components/Tabs/Common'
import { ACTIONS } from 'client/constants'
const {
COPY_ATTRIBUTE: COPY,
ADD_ATTRIBUTE: ADD,
EDIT_ATTRIBUTE: EDIT,
DELETE_ATTRIBUTE: DELETE
} = ACTIONS
// This attributes has special restrictions
const SPECIAL_ATTRIBUTES = {
VCENTER_CCR_REF: { edit: false, delete: false },
VCENTER_HOST: { edit: false, delete: false },
VCENTER_INSTANCE_ID: { edit: false, delete: false },
VCENTER_PASSWORD: { edit: true, delete: false },
VCENTER_USER: { edit: false, delete: false },
VCENTER_VERSION: { edit: false, delete: false }
VCENTER_CCR_REF: {
[EDIT]: false,
[DELETE]: false
},
VCENTER_HOST: {
[EDIT]: false,
[DELETE]: false
},
VCENTER_INSTANCE_ID: {
[EDIT]: false,
[DELETE]: false
},
VCENTER_PASSWORD: {
[EDIT]: true,
[DELETE]: false
},
VCENTER_USER: {
[EDIT]: false,
[DELETE]: false
},
VCENTER_VERSION: {
[EDIT]: false,
[DELETE]: false
}
}
const useStyles = makeStyles({
@ -70,12 +95,13 @@ const AttributePanel = memo(({
.map(([name, value]) => ({
name,
value,
showActionsOnHover: true,
canCopy:
actions?.includes?.(COPY) && !SPECIAL_ATTRIBUTES[name]?.[COPY],
canEdit:
actions?.includes?.(ACTIONS.EDIT_ATTRIBUTE) &&
SPECIAL_ATTRIBUTES[name]?.edit !== false,
actions?.includes?.(EDIT) && !SPECIAL_ATTRIBUTES[name]?.[EDIT],
canDelete:
actions?.includes?.(ACTIONS.DELETE_ATTRIBUTE) &&
SPECIAL_ATTRIBUTES[name]?.delete !== false,
actions?.includes?.(DELETE) && !SPECIAL_ATTRIBUTES[name]?.[DELETE],
handleEdit,
handleDelete
}))
@ -87,7 +113,7 @@ const AttributePanel = memo(({
subListProps={{ disablePadding: true }}
title={title}
list={formatAttributes}
handleAdd={actions?.includes?.(ACTIONS.ADD_ATTRIBUTE) && handleAdd}
handleAdd={actions?.includes?.(ADD) && handleAdd}
/>
)
})

View File

@ -32,6 +32,7 @@ const useStyles = makeStyles(theme => ({
borderBottom: `1px solid ${theme.palette.divider}`
},
item: {
height: '2.4em',
gap: '1em',
'& > *': {
flex: '1 1 50%',

View File

@ -46,6 +46,6 @@ export default makeStyles(theme => ({
}
},
title: {
fontWeight: theme.typography.fontWeightBold
fontWeight: theme.typography.fontWeightMedium
}
}))

View File

@ -16,7 +16,7 @@
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import HistoryItem from 'client/components/Tabs/Vm/Placement/Item'
import HistoryItem from 'client/components/Tabs/Vm/History/Item'
const HistoryList = ({ records, actions }) => (
<div style={{ display: 'grid', gap: '1em', paddingBlock: '0.8em' }}>

View File

@ -18,12 +18,12 @@ import { useContext, useMemo } from 'react'
import PropTypes from 'prop-types'
import { TabContext } from 'client/components/Tabs/TabProvider'
import HistoryList from 'client/components/Tabs/Vm/Placement/List'
import HistoryList from 'client/components/Tabs/Vm/History/List'
import { getHypervisor, getHistoryRecords, isAvailableAction } from 'client/models/VirtualMachine'
import { getActionsAvailable } from 'client/models/Helper'
const VmPlacementTab = ({ tabProps: { actions } = {} }) => {
const VmHistoryTab = ({ tabProps: { actions } = {} }) => {
const { data: vm } = useContext(TabContext)
const [records, actionsAvailable] = useMemo(() => {
@ -40,10 +40,10 @@ const VmPlacementTab = ({ tabProps: { actions } = {} }) => {
)
}
VmPlacementTab.propTypes = {
VmHistoryTab.propTypes = {
tabProps: PropTypes.object
}
VmPlacementTab.displayName = 'VmPlacementTab'
VmHistoryTab.displayName = 'VmHistoryTab'
export default VmPlacementTab
export default VmHistoryTab

View File

@ -24,7 +24,7 @@ import Information from 'client/components/Tabs/Vm/Info/information'
import { Tr } from 'client/components/HOC'
import { T } from 'client/constants'
import { getHypervisor, isAvailableAction } from 'client/models/VirtualMachine'
import { getHypervisor } from 'client/models/VirtualMachine'
import { getActionsAvailable, filterAttributes, jsonToXml } from 'client/models/Helper'
import { cloneObject, set } from 'client/utils'
@ -76,14 +76,10 @@ const VmInfoTab = ({ tabProps = {} }) => {
String(response) === String(ID) && (await handleRefetch?.())
}
const getActions = useCallback(actions => {
const hypervisor = getHypervisor(vm)
const actionsByHypervisor = getActionsAvailable(actions, hypervisor)
const actionsByState = actionsByHypervisor
.filter(action => !isAvailableAction(action)(vm))
return actionsByState
}, [vm])
const getActions = useCallback(
actions => getActionsAvailable(actions, getHypervisor(vm)),
[vm]
)
const {
attributes,
@ -172,6 +168,7 @@ const VmInfoTab = ({ tabProps = {} }) => {
)}
{monitoringPanel?.enabled && monitoringAttributes && (
<AttributePanel
actions={getActions(monitoringPanel?.actions)}
attributes={monitoringAttributes}
title={Tr(T.Monitoring)}
/>

View File

@ -17,16 +17,13 @@
import { useMemo, useContext } from 'react'
import PropTypes from 'prop-types'
import { Trash } from 'iconoir-react'
import {
Typography,
Accordion,
AccordionSummary as MAccordionSummary,
AccordionDetails,
useMediaQuery,
Paper
} from '@mui/material'
import { withStyles, makeStyles } from '@mui/styles'
import { styled, useMediaQuery } from '@mui/material'
import Typography from '@mui/material/Typography'
import Paper from '@mui/material/Paper'
import MAccordion from '@mui/material/Accordion'
import AccordionDetails from '@mui/material/AccordionDetails'
import AccordionSummary, { accordionSummaryClasses } from '@mui/material/AccordionSummary'
import { Trash as TrashIcon, NavArrowRight as ExpandIcon } from 'iconoir-react'
import { useVmApi } from 'client/features/One'
import { useDialog } from 'client/hooks'
@ -38,63 +35,62 @@ import MultipleTags from 'client/components/MultipleTags'
import { Translate } from 'client/components/HOC'
import { T, VM_ACTIONS } from 'client/constants'
const AccordionSummary = withStyles({
root: {
backgroundColor: 'rgba(0, 0, 0, .03)',
borderBottom: '1px solid rgba(0, 0, 0, .125)',
marginBottom: -1,
minHeight: 56,
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, .07)'
},
'&$expanded': {
backgroundColor: 'rgba(0, 0, 0, .07)',
minHeight: 56
}
},
content: {
overflow: 'hidden',
'&$expanded': {
margin: '12px 0'
}
},
expanded: {}
})(MAccordionSummary)
const Accordion = styled(props => (
<MAccordion disableGutters elevation={0} square {...props} />
))(({ theme }) => ({
border: `1px solid ${theme.palette.divider}`,
'&:before': { display: 'none' }
}))
const useStyles = makeStyles(({
row: {
width: '100%',
const Summary = styled(props => (
<AccordionSummary expandIcon={<ExpandIcon />} {...props} />
))(({
[`&.${accordionSummaryClasses.root}`]: {
backgroundColor: 'rgba(0, 0, 0, .03)',
flexDirection: 'row-reverse',
'&:hover': { backgroundColor: 'rgba(0, 0, 0, .07)' }
},
[`& .${accordionSummaryClasses.expandIconWrapper}.${accordionSummaryClasses.expanded}`]: {
transform: 'rotate(90deg)'
}
}))
const Row = styled('div')({
display: 'flex',
width: '100%',
gap: '0.5em',
alignItems: 'center',
flexWrap: 'nowrap'
},
labels: {
})
const Labels = styled('span')({
display: 'inline-flex',
gap: '0.5em',
alignItems: 'center'
},
details: {
marginLeft: '1em',
})
const Details = styled(AccordionDetails)({
display: 'flex',
flexDirection: 'column',
gap: '0.5em'
},
securityGroups: {
gap: '0.5em',
marginLeft: '1em'
})
const SecGroups = styled(Paper)({
display: 'flex',
flexDirection: 'column',
gap: '0.5em',
padding: '0.8em'
}
}))
})
const NetworkItem = ({ nic = {}, actions }) => {
const classes = useStyles()
const isMobile = useMediaQuery(theme => theme.breakpoints.down('md'))
const { display, show, hide, values } = useDialog()
const { detachNic } = useVmApi()
const { handleRefetch, data: vm } = useContext(TabContext)
const { NIC_ID, NETWORK = '-', BRIDGE, IP, MAC, PCI_ID, ALIAS, SECURITY_GROUPS } = nic
const { NIC_ID, NETWORK = '-', IP, MAC, PCI_ID, ALIAS, SECURITY_GROUPS } = nic
const hasDetails = useMemo(
() => !!ALIAS.length || !!SECURITY_GROUPS?.length,
@ -112,7 +108,7 @@ const NetworkItem = ({ nic = {}, actions }) => {
actions?.includes?.(VM_ACTIONS.DETACH_NIC) && (
<Action
cy={`${VM_ACTIONS.DETACH_NIC}-${id}`}
icon={<Trash />}
icon={<TrashIcon />}
stopPropagation
handleClick={() => show({ id, isAlias })}
/>
@ -120,50 +116,52 @@ const NetworkItem = ({ nic = {}, actions }) => {
return (
<>
<Accordion variant='outlined'>
<AccordionSummary>
<div className={classes.row}>
<Accordion>
<Summary>
<Row>
<Typography noWrap>
{`${NIC_ID} | ${NETWORK}`}
</Typography>
<span className={classes.labels}>
<Labels>
<MultipleTags
limitTags={isMobile ? 1 : 4}
tags={[IP, MAC, BRIDGE && `BRIDGE - ${BRIDGE}`, PCI_ID].filter(Boolean)}
clipboard
limitTags={isMobile ? 1 : 3}
tags={[IP, MAC, PCI_ID].filter(Boolean)}
/>
</span>
</Labels>
{!isMobile && detachAction(NIC_ID)}
</div>
</AccordionSummary>
</Row>
</Summary>
{hasDetails && (
<AccordionDetails className={classes.details}>
<Details>
{ALIAS?.map(({ NIC_ID, NETWORK = '-', BRIDGE, IP, MAC }) => (
<div key={NIC_ID} className={classes.row}>
<Row key={NIC_ID}>
<Typography noWrap variant='body2'>
<Translate word={T.Alias} />{`${NIC_ID} | ${NETWORK}`}
</Typography>
<span className={classes.labels}>
<Labels>
<MultipleTags
limitTags={isMobile ? 1 : 4}
clipboard
limitTags={isMobile ? 1 : 3}
tags={[IP, MAC, BRIDGE && `BRIDGE - ${BRIDGE}`].filter(Boolean)}
/>
</span>
</Labels>
{!isMobile && detachAction(NIC_ID, true)}
</div>
</Row>
))}
{!!SECURITY_GROUPS?.length && (
<Paper variant='outlined' className={classes.securityGroups}>
<SecGroups variant='outlined'>
<Typography variant='body1'>
<Translate word={T.SecurityGroups} />
</Typography>
{SECURITY_GROUPS
?.map(({ ID, NAME, PROTOCOL, RULE_TYPE, ICMP_TYPE, RANGE, NETWORK_ID }, idx) => (
<div key={`${idx}-${NAME}`} className={classes.row}>
<Row key={`${idx}-${NAME}`}>
<Typography noWrap variant='body2'>
{`${ID} | ${NAME}`}
</Typography>
<span className={classes.labels}>
<Labels>
<MultipleTags
limitTags={isMobile ? 2 : 5}
tags={[
@ -174,12 +172,12 @@ const NetworkItem = ({ nic = {}, actions }) => {
ICMP_TYPE
].filter(Boolean)}
/>
</span>
</div>
</Labels>
</Row>
))}
</Paper>
</SecGroups>
)}
</AccordionDetails>
</Details>
)}
</Accordion>
@ -193,9 +191,9 @@ const NetworkItem = ({ nic = {}, actions }) => {
handleAccept={handleDetach}
handleCancel={hide}
>
<p>
<Typography>
<Translate word={T.DoYouWantProceed} />
</p>
</Typography>
</DialogConfirmation>
)}
</>

View File

@ -31,7 +31,7 @@ import Configuration from 'client/components/Tabs/Vm/Configuration'
import Info from 'client/components/Tabs/Vm/Info'
import Log from 'client/components/Tabs/Vm/Log'
import Network from 'client/components/Tabs/Vm/Network'
import Placement from 'client/components/Tabs/Vm/Placement'
import History from 'client/components/Tabs/Vm/History'
import SchedActions from 'client/components/Tabs/Vm/SchedActions'
import Snapshot from 'client/components/Tabs/Vm/Snapshot'
import Storage from 'client/components/Tabs/Vm/Storage'
@ -42,7 +42,7 @@ const getTabComponent = tabName => ({
info: Info,
log: Log,
network: Network,
placement: Placement,
history: History,
schedActions: SchedActions,
snapshot: Snapshot,
storage: Storage

View File

@ -13,14 +13,17 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useState, useMemo } from 'react'
import { useState, useMemo, JSXElementConstructor } from 'react'
import PropTypes from 'prop-types'
import { Tabs as MTabs, Tab as MTab, Fade } from '@mui/material'
import { Box } from '@mui/system'
import { styled, Tabs as MTabs, TabsProps, Tab as MTab, Box, Fade } from '@mui/material'
import { WarningCircledOutline } from 'iconoir-react'
const Content = ({ name, renderContent: Content, hidden }) => (
const WarningIcon = styled(WarningCircledOutline)(({ theme }) => ({
color: theme.palette.error.main
}))
const Content = ({ name, renderContent: RenderContent, hidden }) => (
<Fade in timeout={400} key={`tab-${name}`}>
<Box
sx={{
@ -29,12 +32,25 @@ const Content = ({ name, renderContent: Content, hidden }) => (
display: hidden ? 'none' : 'block'
}}
>
{typeof Content === 'function' ? <Content /> : Content}
{typeof RenderContent === 'function'
? <RenderContent />
: RenderContent}
</Box>
</Fade>
)
const Tabs = ({ tabs = [], renderHiddenTabs = false }) => {
/**
* @param {object} props - Props
* @param {Array} props.tabs - Tabs
* @param {TabsProps} props.tabsProps - Props to tabs component
* @param {boolean} props.renderHiddenTabs - If `true`, will be render hidden tabs
* @returns {JSXElementConstructor} Tabs component with content
*/
const Tabs = ({
tabs = [],
tabsProps: { sx, ...tabsProps } = {},
renderHiddenTabs = false
}) => {
const [tabSelected, setTab] = useState(() => 0)
const renderTabs = useMemo(() => (
@ -43,18 +59,26 @@ const Tabs = ({ tabs = [], renderHiddenTabs = false }) => {
variant='scrollable'
scrollButtons='auto'
onChange={(_, tab) => setTab(tab)}
sx={{
position: 'sticky',
top: 0,
zIndex: theme => theme.zIndex.appBar,
...sx
}}
{...tabsProps}
>
{tabs.map(({ value, name, label, icon: Icon }, idx) =>
<MTab
{tabs.map(({ value, name, label, error, icon: Icon }, idx) => {
return <MTab
key={`tab-${name}`}
id={`tab-${name}`}
icon={Icon && <Icon />}
icon={error ? <WarningIcon /> : (Icon && <Icon />)}
value={value ?? idx}
label={label ?? name}
/>
}
)}
</MTabs>
), [tabs.length, tabSelected])
), [tabs, tabSelected])
const renderAllHiddenTabContents = useMemo(() =>
tabs.map((tabProps, idx) => {
@ -83,6 +107,7 @@ Content.displayName = 'Content'
Tabs.propTypes = {
tabs: PropTypes.array,
tabsProps: PropTypes.object,
renderHiddenTabs: PropTypes.bool
}

View File

@ -18,6 +18,7 @@
export const RENAME = 'rename'
// ATTRIBUTES
export const COPY_ATTRIBUTE = 'copy'
export const ADD_ATTRIBUTE = 'add'
export const EDIT_ATTRIBUTE = 'edit'
export const DELETE_ATTRIBUTE = 'delete'

View File

@ -90,6 +90,7 @@ export const SOCKETS = {
export * as T from 'client/constants/translates'
export * as ACTIONS from 'client/constants/actions'
export * as STATES from 'client/constants/states'
export * from 'client/constants/userInput'
export * from 'client/constants/flow'
export * from 'client/constants/provision'
export * from 'client/constants/cluster'

View File

@ -31,7 +31,9 @@ module.exports = {
/* actions */
Accept: 'Accept',
Active: 'Active',
Add: 'Add',
AddAction: 'Add action',
Append: 'Append',
Attach: 'Attach',
AttachDisk: 'Attach disk',
AttachImage: 'Attach image disk',
@ -41,18 +43,20 @@ module.exports = {
Cancel: 'Cancel',
Change: 'Change',
ChangeGroup: 'Change group',
CurrentGroup: 'Current group: %s',
ChangeOwner: 'Change owner',
CurrentOwner: 'Current owner: %s',
Clear: 'Clear',
ClickToCopy: 'Click to copy',
Clone: 'Clone',
CloneSeveralTemplates: 'Clone several Templates',
CloneTemplate: 'Clone Template',
Close: 'Close',
Collapse: 'Collapse',
Configuration: 'Configuration',
CopiedToClipboard: 'Copied to clipboard',
Create: 'Create',
CreateMarketApp: 'Create Marketplace App',
CurrentGroup: 'Current group: %s',
CurrentOwner: 'Current owner: %s',
Delete: 'Delete',
DeleteScheduledAction: 'Delete scheduled action: %s',
DeleteSomething: 'Delete: %s',
@ -76,8 +80,8 @@ module.exports = {
Reboot: 'Reboot',
RebootHard: 'Reboot hard',
Recover: 'Recover',
RecoverSomething: 'Recover: %s',
RecoverSeveralVMs: 'Recover several VMs',
RecoverSomething: 'Recover: %s',
Refresh: 'Refresh',
Release: 'Release',
Remove: 'Remove',
@ -96,8 +100,8 @@ module.exports = {
SaveAsTemplate: 'Save as Template',
Search: 'Search',
Select: 'Select',
SelectHost: 'Select a host',
SelectGroup: 'Select a group',
SelectHost: 'Select a host',
SelectRequest: 'Select request',
SelectVmTemplate: 'Select a VM Template',
Share: 'Share',
@ -175,6 +179,7 @@ module.exports = {
None: 'None',
Empty: 'Empty',
NoDataAvailable: 'There is no data available',
ErrorsOcurred: '%s error(s) occurred',
/* steps form */
AdvancedOptions: 'Advanced options',
@ -285,6 +290,9 @@ module.exports = {
EndTime: 'End time',
Locked: 'Locked',
Attributes: 'Attributes',
Type: 'Type',
Data: 'Data',
Validate: 'Validate',
/* permissions */
Permissions: 'Permissions',
@ -455,23 +463,29 @@ module.exports = {
`Number of iothreads for virtio disks.
By default threads will be assign to disk by round robin algorithm.
Disk thread id can be forced by disk IOTHREAD attribute`,
RawData: 'Raw data',
RawDataConcept: 'Raw data to be passed directly to the hypervisor',
RawValidateConcept: `
Disable validation of the RAW data.
By default, the data will be checked against the libvirt schema`,
/* VM Template schema - context */
Context: 'Context',
/* VM Template schema - Input/Output */
InputOrOutput: 'Input / Output',
Inputs: 'Inputs',
/* VM Template schema - Input/Output - graphics */
Graphics: 'Graphics',
VMRC: 'VMRC',
VNC: 'VNC',
SDL: 'SDL',
SPICE: 'SPICE',
Type: 'Type',
ListenOnIp: 'Listen on IP',
ServerPort: 'Server port',
ServerPortConcept: 'Port for the VNC/SPICE server',
Keymap: 'Keymap',
GenerateRandomPassword: 'Generate random password',
Command: 'Command',
Bus: 'Bus',
/* VM Template schema - NUMA */
PinPolicy: 'Pin Policy',
PinPolicyConcept: 'Virtual CPU pinning preference: %s',
@ -517,10 +531,21 @@ module.exports = {
ReservedCpu: 'Allocated CPU',
/* User inputs */
UserInputs: 'User Inputs',
UserInputsConcept: `
These attributes must be provided by the user when a new VM is instantiated.
They will be included in the VM context`,
Fixed: 'Fixed',
Range: 'Range',
List: 'List',
AnyValue: 'Any value',
Options: 'Options',
UIOptionsConcept:
'Comma-separated list of options for the drop-down select input',
Min: 'Min',
Max: 'Max',
DefaultValue: 'Default value',
Mandatory: 'Mandatory',
/* Validation */
/* Validation - mixed */

View File

@ -0,0 +1,59 @@
/* ------------------------------------------------------------------------- *
* 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. *
* ------------------------------------------------------------------------- */
/**
* @typedef {(
* 'text' |
* 'text64' |
* 'password' |
* 'number' |
* 'number-float' |
* 'range' |
* 'range-float' |
* 'boolean' |
* 'list' |
* 'array' |
* 'list-multiple'
* )} UserInputType
* - OpenNebula types for user inputs
*/
/**
* @typedef {object} UserInputObject
* @property {boolean} mandatory - If `true`, the input will be required
* @property {UserInputType} type - Input type
* @property {string} name - Name of input
* @property {string} [description] - Description of input
* @property {number|string} [min] - Minimum value of range type input
* @property {number|string} [max] - Maximum value of range type input
* @property {string[]} [options] - Options available for the input
* @property {number|string|string[]} [default] - Default value for the input
*/
/** @enum {UserInputType} User input types */
export const USER_INPUT_TYPES = {
text: 'text',
text64: 'text64',
password: 'password',
number: 'number',
numberFloat: 'number-float',
range: 'range',
rangeFloat: 'range-float',
boolean: 'boolean',
list: 'list',
array: 'array',
listMultiple: 'list-multiple'
}

View File

@ -43,6 +43,9 @@ export const DEFAULT_CPU_MODELS = ['host-passthrough']
export const SD_DISK_BUSES = ['scsi', 'sata']
export const DEVICE_TYPES = { mouse: 'mouse', tablet: 'tablet' }
export const DEVICE_BUS_TYPES = { usb: 'usb', ps2: 'ps2' }
export const FIRMWARE_TYPES = ['BIOS']
export const KVM_FIRMWARE_TYPES = FIRMWARE_TYPES.concat([

View File

@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import useClipboard, { CLIPBOARD_STATUS } from 'client/hooks/useClipboard'
import useDialog from 'client/hooks/useDialog'
import useFetch from 'client/hooks/useFetch'
import useFetchAll from 'client/hooks/useFetchAll'
@ -23,6 +24,7 @@ import useSearch from 'client/hooks/useSearch'
import useSocket from 'client/hooks/useSocket'
export {
useClipboard,
useDialog,
useFetch,
useFetchAll,
@ -30,5 +32,7 @@ export {
useListForm,
useNearScreen,
useSearch,
useSocket
useSocket,
CLIPBOARD_STATUS
}

View File

@ -0,0 +1,71 @@
/* ------------------------------------------------------------------------- *
* 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 { useMemo, useState } from 'react'
/** @enum {string} Clipboard state */
export const CLIPBOARD_STATUS = {
INIT: 'INIT',
ERROR: 'ERROR',
COPIED: 'COPIED'
}
const { INIT, ERROR, COPIED } = CLIPBOARD_STATUS
/**
* @callback CallbackCopy
* @param {string} text - Text to write on clipboard
*/
/**
* @typedef {object} UseClipboard
* @property {CallbackCopy} copy - Function to write text on clipboard
* @property {CLIPBOARD_STATUS} state - Result state of copy action
* @property {boolean} isCopied
* - If the text is copied successfully, will be `true` temporally (2s)
*/
/**
* Hook to manage a clipboard.
*
* @param {string|number} tooltipDelay - Time in milliseconds to hide the tooltip
* @returns {UseClipboard} Returns management attributes
*/
const useClipboard = ({ tooltipDelay = 2000 } = {}) => {
const [state, setState] = useState(() => INIT)
const isCopied = useMemo(() => state === COPIED, [state])
const copy = async text => {
try {
// Use the Async Clipboard API when available.
// Requires a secure browsing context (i.e. HTTPS)
!navigator?.clipboard && setState(ERROR)
await navigator.clipboard.writeText(String(text))
setState(COPIED)
if (+tooltipDelay > 0) {
setTimeout(() => setState(INIT), +tooltipDelay)
}
} catch (error) {
setState(ERROR)
}
}
return { copy, state, isCopied }
}
export default useClipboard

View File

@ -21,7 +21,8 @@ const callAll = (...fns) => (...args) => fns.forEach(fn => fn && fn?.(...args))
* Hook to manage a dialog.
*
* @returns {{
* on: boolean,
* display: boolean,
* values: any,
* show: Function,
* hide: Function,
* toggle: Function,

View File

@ -53,6 +53,7 @@ import { set } from 'client/utils'
/**
* @typedef {object} HookListForm
* @property {object[]} list - Form list
* @property {object} editingData - Current editing data
* @property {NoParamsCallback} handleClear - Clear the data form list
* @property {NewListCallback} handleSetList - Resets the list with a new value
@ -201,6 +202,7 @@ const useListForm = ({
)
return {
list,
editingData,
handleSelect,
handleUnselect,

View File

@ -13,10 +13,11 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { T } from 'client/constants'
import { DateTime } from 'luxon'
import { j2xParser as Parser } from 'fast-xml-parser'
import { T, UserInputObject, USER_INPUT_TYPES } from 'client/constants'
/**
* @param {object} json - JSON
* @param {boolean} [addRoot] - Add ROOT element as parent
@ -124,6 +125,21 @@ export const levelLockToString = level => ({
4: T.All
}[level] || '-')
/**
* Returns the permission numeric code.
*
* @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]) => (
(stringToBoolean(u) ? 4 : 0) +
(stringToBoolean(m) ? 2 : 0) +
(stringToBoolean(a) ? 1 : 0)
)
/**
* Transform the permission from OpenNebula template to octal format.
*
@ -146,21 +162,6 @@ export const permissionsToOctal = permissions => {
OTHER_U, OTHER_M, OTHER_A
} = permissions
/**
* Returns the permission numeric code.
*
* @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]) => (
(stringToBoolean(u) ? 4 : 0) +
(stringToBoolean(m) ? 2 : 0) +
(stringToBoolean(a) ? 1 : 0)
)
return [
[OWNER_U, OWNER_M, OWNER_A],
[GROUP_U, GROUP_M, GROUP_A],
@ -219,3 +220,95 @@ export const filterAttributes = (list = {}, options = {}) => {
return response
}
// ----------------------------------------------------------
// User inputs
// ----------------------------------------------------------
const PARAMS_SEPARATOR = '|'
const RANGE_SEPARATOR = '..'
const LIST_SEPARATOR = ','
const OPTIONS_DEFAULT = ' '
const MANDATORY = 'M'
const OPTIONAL = 'O'
/**
* Get object attributes from user input as string.
*
* @param {string} userInputString - User input as string with format:
* mandatory | type | description | options | defaultValue
* @returns {UserInputObject} User input object
*/
export const getUserInputParams = userInputString => {
const params = String(userInputString).split(PARAMS_SEPARATOR)
const options = [
USER_INPUT_TYPES.range,
USER_INPUT_TYPES.rangeFloat
].includes(params[1])
? params[3].split(RANGE_SEPARATOR)
: params[3].split(LIST_SEPARATOR)
return {
mandatory: params[0] === MANDATORY,
type: params[1],
description: params[2],
options: options,
default: params[4],
min: options[0],
max: options[1]
}
}
/**
* @param {UserInputObject} userInput - User input object
* @returns {string} User input in string format
*/
export const getUserInputString = userInput => {
const {
mandatory,
mandatoryString = mandatory ? MANDATORY : OPTIONAL,
type,
description,
min,
max,
range = [min, max].filter(Boolean).join(RANGE_SEPARATOR),
options,
default: defaultValue
} = userInput
// mandatory|type|description|range/options/' '|defaultValue
const uiString = [mandatoryString, type, description]
range?.length > 0
? uiString.push(range)
: options?.length > 0
? uiString.push(options.join(LIST_SEPARATOR))
: uiString.push(OPTIONS_DEFAULT)
return uiString.concat(defaultValue).join(PARAMS_SEPARATOR)
}
/**
* Get list of user inputs defined in OpenNebula template.
*
* @param {object} userInputs - List of user inputs in string format
* @returns {UserInputObject[]} User input object
*/
export const userInputsToArray = userInputs => {
return Object
.entries(userInputs)
.map(([name, ui]) => ({ name, ...getUserInputParams(ui) }))
}
/**
* Get list of user inputs in format valid to forms.
*
* @param {UserInputObject[]} userInputs - List of user inputs in object format
* @returns {object} User input object
*/
export const userInputsToObject = userInputs =>
userInputs.reduce((res, { name, ...userInput }) => ({
...res,
[String(name).toUpperCase()]: getUserInputString(userInput)
}), {})

View File

@ -73,9 +73,10 @@ export const getAllocatedInfo = host => {
export const getHugepageSizes = host => {
const numaNodes = [host?.HOST_SHARE?.NUMA_NODES?.NODE ?? []].flat()
return numaNodes.filter(node => node?.NODE_ID &&
[node?.HUGEPAGE?.SIZE ?? []].flat().map(size => +size)
)
return numaNodes
.filter(node => node?.NODE_ID && node?.HUGEPAGE)
.map(node => node.HUGEPAGE.map(({ SIZE }) => +SIZE))
.flat()
}
/**

View File

@ -80,7 +80,7 @@ export const getSecurityGroupsFromResource = (resource, securityGroups) => {
const groups = Array.isArray(securityGroups)
? securityGroups
: securityGroups.split(',')
: securityGroups?.split(',')
return rules.filter(({ SECURITY_GROUP_ID }) => groups.includes?.(SECURITY_GROUP_ID))
return rules.filter(({ SECURITY_GROUP_ID }) => groups?.includes?.(SECURITY_GROUP_ID))
}

View File

@ -248,16 +248,6 @@ export default (appTheme, mode = SCHEMES.DARK) => {
fieldset: { border: 'none' }
}
},
MuiTypography: {
variants: [{
props: { component: 'legend' },
style: {
marginBottom: '1em',
padding: '0em 1em 0.2em 0.5em',
borderBottom: `2px solid ${secondary.main}`
}
}]
},
MuiPaper: {
styleOverrides: {
root: { backgroundImage: 'unset' }
@ -424,8 +414,7 @@ export default (appTheme, mode = SCHEMES.DARK) => {
},
MuiList: {
defaultProps: {
dense: true,
disablePadding: true
dense: true
}
},
MuiChip: {

View File

@ -132,8 +132,8 @@ export const getObjectSchemaFromFields = fields =>
}
const paths = name.split('.')
const path = paths.pop()
const fieldSchema = object({ [path]: validation })
const pathname = paths.pop()
const fieldSchema = object({ [pathname]: validation })
/**
* @param {string} [path] - Path

View File

@ -20,8 +20,10 @@ import { JSXElementConstructor, SetStateAction } from 'react'
// eslint-disable-next-line no-unused-vars
import { GridProps, TextFieldProps, CheckboxProps, InputBaseComponentProps } from '@mui/material'
import { string, number, boolean, array, object, BaseSchema } from 'yup'
// eslint-disable-next-line no-unused-vars
import { Row } from 'react-table'
import { INPUT_TYPES } from 'client/constants'
import { UserInputObject, INPUT_TYPES, USER_INPUT_TYPES } from 'client/constants'
// ----------------------------------------------------------
// Types
@ -90,16 +92,37 @@ import { INPUT_TYPES } from 'client/constants'
* @property {BaseSchema|DependOfCallback} [validation]
* - Schema to validate the field value
* @property {TextFieldProps|CheckboxProps|InputBaseComponentProps} [fieldProps]
* - Extra properties to material field
* - Extra properties to material-ui field
* @property {function(string|number):any} [renderValue]
* - Render the current selected value inside selector input
* - **Only for select inputs.**
* @property {JSXElementConstructor} [Table]
* - Table component. One of table defined in: `client/components/Tables`
* - **Only for table inputs.**
* @property {boolean|DependOfCallback} [singleSelect]
* If `true`, the table component only will allows to select one row
* - **Only for table inputs.**
* @property {function(Row, number):string} [getRowId]
* This function changes how React Table detects unique rows
* and also how it constructs each row's underlying id property.
* - **Only for table inputs.**
* @property {{message: string, test: Function}[]|DependOfCallback} [validationBeforeTransform]
* - Tests to validate the field value.
* - **Only for file inputs.**
* @property {Function|DependOfCallback} [transform]
* @property {Function} [transform]
* - Transform the file value.
* - For example: to save file as string value in base64 format.
* - **Only for file inputs.**
*/
/**
* @typedef {object} Section
* @property {string} id - Section id
* @property {string} legend - Legend text
* @property {string} legendTooltip - Legend tooltip
* @property {Field[]} fields - The Fields will be includes on section
*/
/**
* @typedef {object} Form
* @property {BaseSchema|function(object):BaseSchema} resolver - Schema
@ -149,12 +172,6 @@ import { INPUT_TYPES } from 'client/constants'
* @returns {Form & ExtraParams}
*/
/**
* @typedef {('text'|'text64'|'password'|'number'|'number-float'|'range'|
* 'range-float'|'boolean'|'list'|'array'|'list-multiple')} UserInputType
* - OpenNebula types for user inputs
*/
// ----------------------------------------------------------
// Constants
// ----------------------------------------------------------
@ -192,19 +209,15 @@ const parseUserInputValue = value => {
/**
* Get input schema for the user input defined in OpenNebula resource.
*
* @param {object} userInput - User input from OpenNebula document
* @param {boolean} userInput.mandatory - If `true`, the input will be required
* @param {string} userInput.name - Name of input
* @param {UserInputType} userInput.type - Input type
* @param {string} [userInput.options] - Options available for the input
* @param {number|string|string[]} [userInput.defaultValue] - Default value for the input
* @param {UserInputObject} userInput - User input from OpenNebula document
* @param {number|string|string[]} [userInput.default] - Default value for the input
* @returns {Field} Field properties
*/
export const schemaUserInput = ({ mandatory, name, type, options, defaultValue }) => {
export const schemaUserInput = ({ mandatory, name, type, options, default: defaultValue }) => {
switch (type) {
case 'text':
case 'text64':
case 'password': return {
case [USER_INPUT_TYPES.text]:
case [USER_INPUT_TYPES.text64]:
case [USER_INPUT_TYPES.password]: return {
type: INPUT_TYPES.TEXT,
htmlType: type === 'password' ? 'password' : 'text',
validation: string()
@ -212,8 +225,8 @@ export const schemaUserInput = ({ mandatory, name, type, options, defaultValue }
.concat(requiredSchema(mandatory, name, string()))
.default(defaultValue || undefined)
}
case 'number':
case 'number-float': return {
case [USER_INPUT_TYPES.number]:
case [USER_INPUT_TYPES.numberFloat]: return {
type: INPUT_TYPES.TEXT,
htmlType: 'number',
validation: number()
@ -222,8 +235,8 @@ export const schemaUserInput = ({ mandatory, name, type, options, defaultValue }
.transform(value => !isNaN(value) ? value : null)
.default(() => parseFloat(defaultValue) ?? undefined)
}
case 'range':
case 'range-float': {
case [USER_INPUT_TYPES.range]:
case [USER_INPUT_TYPES.rangeFloat]: {
const [min, max] = getRange(options)
return {
@ -238,13 +251,13 @@ export const schemaUserInput = ({ mandatory, name, type, options, defaultValue }
fieldProps: { min, max, step: type === 'range-float' ? 0.01 : 1 }
}
}
case 'boolean': return {
case [USER_INPUT_TYPES.boolean]: return {
type: INPUT_TYPES.CHECKBOX,
validation: boolean()
.concat(requiredSchema(mandatory, name, boolean()))
.default(defaultValue === 'YES' ?? false)
}
case 'list': {
case [USER_INPUT_TYPES.list]: {
const values = getOptionsFromList(options)
const firstOption = values?.[0]?.value ?? undefined
@ -258,7 +271,7 @@ export const schemaUserInput = ({ mandatory, name, type, options, defaultValue }
.default(defaultValue ?? firstOption)
}
}
case 'array': {
case [USER_INPUT_TYPES.array]: {
const defaultValues = getValuesFromArray(defaultValue)
return {
@ -270,7 +283,7 @@ export const schemaUserInput = ({ mandatory, name, type, options, defaultValue }
fieldProps: { freeSolo: true }
}
}
case 'list-multiple': {
case [USER_INPUT_TYPES.listMultiple]: {
const values = getOptionsFromList(options)
const defaultValues = defaultValue?.split(',') ?? undefined

View File

@ -36,6 +36,18 @@ const buildMethods = () => {
value => isBase64(value)
)
})
addMethod(string, 'includesInOptions', function (options, separator = ',') {
return this.test({
name: 'includes-string-of-values',
message: [T['validation.string.invalidFormat'], options.join(separator)],
exclusive: true,
test: function (values) {
return values
?.split(separator)
?.every(value => this.resolve(options).includes(value))
}
})
})
}
/**

View File

@ -573,7 +573,8 @@ const functionRoutes = {
setRes,
updaterResponse,
setNodeConnect,
connectOpennebula
connectOpennebula,
getCreatedTokenOpennebula
}
module.exports = functionRoutes

View File

@ -14,19 +14,51 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
const questions = {
datastores: [
const DATASTORES = 'datastores'
const TEMPLATES = 'templates'
const NETWORKS = 'networks'
const IMAGES = 'images'
],
templates: [
],
networks: [
],
images: [
]
const resources = {
DATASTORES,
TEMPLATES,
NETWORKS,
IMAGES
}
module.exports = questions
const questions = {
[DATASTORES]: [],
[TEMPLATES]: [
{
question: 'Type',
flag: '--type'
}, {
question: 'Folder',
flag: '--folder'
}, {
question: 'Linked Clone',
flag: '--linked-clone'
}
],
[NETWORKS]: [
{
question: 'Size',
flag: '--size'
}, {
question: 'Type',
flag: '--type'
}, {
question: 'MAC',
flag: '--mac'
}, {
question: 'Cluster(s) to import',
flag: '--cluster-imports'
}, {
question: 'Unimported Cluster(s)',
flag: '--cluster-unimported'
}
],
[IMAGES]: []
}
module.exports = { resources, questions }

View File

@ -0,0 +1,262 @@
/* ------------------------------------------------------------------------- *
* 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. *
* ------------------------------------------------------------------------- */
const {
defaultEmptyFunction,
defaultCommandVcenter
} = require('server/utils/constants/defaults')
const {
ok,
internalServerError,
badRequest
} = require('server/utils/constants/http-codes')
const { httpResponse, executeCommand } = require('server/utils/server')
const { consoleParseToString, consoleParseToJSON } = require('server/utils/opennebula')
const { resources } = require('./command-flags')
const { getSunstoneConfig } = require('server/utils/yml')
const httpBadRequest = httpResponse(badRequest, '', '')
const appConfig = getSunstoneConfig()
const prependCommand = appConfig.vcenter_prepend_command || ''
const regexExclude = [
/^Connecting to.*/gi,
/^Exploring vCenter.*/gi,
// eslint-disable-next-line no-control-regex
/^\u001b\[.*?m\u001b\[.*?m# vCenter.*/gi
]
const validObjects = Object.values(resources)
/**
* Import the the desired vCenter object.
*
* @param {object} res - http response
* @param {Function} next - express stepper
* @param {object} params - params of http request
* @param {object} userData - user of http request
*/
const importVcenter = (res = {}, next = defaultEmptyFunction, params = {}, userData = {}) => {
let rtn = httpBadRequest
// check params
if (params && params.vobject && validObjects.includes(params.vobject) && params.host) {
const vobject = `${params.vobject}`.toLowerCase()
let paramsCommand = [
params.answers ? 'import' : 'import_defaults'
]
if (params.id) {
paramsCommand.push(
`${params.id}`
)
}
let vobjectAndHost = [
'-o',
`${vobject}`,
'-h',
`${params.host}`
]
if (vobject === resources.IMAGES && params.datastore) {
const datastoreParameter = ['-d', params.datastore]
vobjectAndHost = [...vobjectAndHost, ...datastoreParameter]
}
// flags by questions import command
/* if (params.answers && questions[vobject]) {
const answers = params.answers.split(',')
} */
paramsCommand = [...paramsCommand, ...vobjectAndHost]
const executedCommand = executeCommand(defaultCommandVcenter, paramsCommand, prependCommand)
const response = executedCommand.success ? ok : internalServerError
let message = ''
if (executedCommand.data) {
message = consoleParseToString(
executedCommand.data,
regexExclude
)
}
rtn = httpResponse(response, message)
}
res.locals.httpCode = rtn
next()
}
/**
* Show a list with unimported vCenter objects excluding all filters.
*
* @param {object} res - http response
* @param {Function} next - express stepper
* @param {object} params - params of http request
* @param {object} userData - user of http request
*/
const list = (res = {}, next = defaultEmptyFunction, params = {}, userData = {}) => {
let rtn = httpBadRequest
if (params && params.vobject && validObjects.includes(params.vobject) && params.host) {
const vobject = `${params.vobject}`.toLowerCase()
let paramsCommand = [
'list',
'-o',
`${vobject}`,
'-h',
`${params.host}`,
'--csv'
]
if (vobject === resources.IMAGES && params.datastore) {
const newParameters = ['-d', params.datastore]
paramsCommand = [...paramsCommand, ...newParameters]
}
const executedCommand = executeCommand(defaultCommandVcenter, paramsCommand, prependCommand)
const response = executedCommand.success ? ok : internalServerError
let message = ''
if (executedCommand.data) {
message = consoleParseToJSON(
consoleParseToString(
executedCommand.data,
regexExclude
),
/^IMID,.*/gi
)
}
rtn = httpResponse(response, message)
}
res.locals.httpCode = rtn
next()
}
/**
* Show a list with unimported vCenter objects excluding all filters.
*
* @param {object} res - http response
* @param {Function} next - express stepper
* @param {object} params - params of http request
* @param {object} userData - user of http request
*/
const listAll = (res = {}, next = defaultEmptyFunction, params = {}, userData = {}) => {
let rtn = httpBadRequest
if (params && params.vobject && validObjects.includes(params.vobject) && params.host) {
const vobject = `${params.vobject}`.toLowerCase()
let paramsCommand = [
'list_all',
'-o',
`${vobject}`,
'-h',
`${params.host}`,
'--csv'
]
if (vobject === resources.IMAGES && params.datastore) {
const newParameters = ['-d', params.datastore]
paramsCommand = [...paramsCommand, ...newParameters]
}
const executedCommand = executeCommand(defaultCommandVcenter, paramsCommand, prependCommand)
const response = executedCommand.success ? ok : internalServerError
let message = ''
if (executedCommand.data) {
message = consoleParseToJSON(
consoleParseToString(
executedCommand.data,
regexExclude
),
/^IMID,.*/gi
)
}
rtn = httpResponse(response, message)
}
res.locals.httpCode = rtn
next()
}
/**
* Clear extraconfig tags from a vCenter VM, useful when a VM has been launched by OpenNebula and needs to be reimported.
*
* @param {object} res - http response
* @param {Function} next - express stepper
* @param {object} params - params of http request
* @param {object} userData - user of http request
*/
const cleartags = (res = {}, next = defaultEmptyFunction, params = {}, userData = {}) => {
let rtn = httpBadRequest
// check params
if (params && params.id) {
const paramsCommand = [
'cleartags',
`${params.id}`
]
const executedCommand = executeCommand(defaultCommandVcenter, paramsCommand, prependCommand)
const response = executedCommand.success ? ok : internalServerError
let message = ''
if (executedCommand.data) {
message = consoleParseToString(
executedCommand.data,
regexExclude
)
}
rtn = httpResponse(response, message)
}
res.locals.httpCode = rtn
next()
}
/**
* Import vCenter cluster as Opennebula host.
*
* @param {object} res - http response
* @param {Function} next - express stepper
* @param {object} params - params of http request
* @param {object} userData - user of http request
*/
const hosts = (res = {}, next = defaultEmptyFunction, params = {}, userData = {}) => {
let rtn = httpBadRequest
// check params
if (params && params.vcenter && params.user && params.pass) {
const paramsCommand = [
'hosts',
'--vcenter',
`${params.vcenter}`.toLowerCase(),
'--vuser',
`${params.user}`,
'--vpass',
`${params.pass}`,
'--use-defaults'
]
const executedCommand = executeCommand(defaultCommandVcenter, paramsCommand, prependCommand)
const response = executedCommand.success ? ok : internalServerError
let message = ''
if (executedCommand.data) {
message = consoleParseToString(
executedCommand.data,
regexExclude
)
}
rtn = httpResponse(response, message)
}
res.locals.httpCode = rtn
next()
}
const functionRoutes = {
importVcenter,
list,
listAll,
cleartags,
hosts
}
module.exports = functionRoutes

View File

@ -14,12 +14,46 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
const privateRoutes = []
const { addFunctionAsRoute, setFunctionRoute } = require('server/utils/server')
const { routes: vcenterRoutes } = require('./vcenter')
const { VCENTER } = require('./string-routes')
const privateRoutes = []
const publicRoutes = []
/**
* Set private routes.
*
* @param {object} routes - object of routes
* @param {string} path - principal route
* @param {Function} action - function of route
*/
const setPrivateRoutes = (routes = {}, path = '', action = () => undefined) => {
if (Object.keys(routes).length > 0 && routes.constructor === Object) {
Object.keys(routes).forEach((route) => {
privateRoutes.push(
setFunctionRoute(route, path,
(req, res, next, connection, userId, user) => {
action(req, res, next, routes[route], user, connection)
}
)
)
})
}
}
/**
* Add routes.
*
* @returns {Array} routes
*/
const generatePrivateRoutes = () => {
setPrivateRoutes(vcenterRoutes, VCENTER, addFunctionAsRoute)
return privateRoutes
}
const functionRoutes = {
private: privateRoutes,
private: generatePrivateRoutes(),
public: publicRoutes
}

View File

@ -0,0 +1,23 @@
/* ------------------------------------------------------------------------- *
* 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. *
* ------------------------------------------------------------------------- */
const VCENTER = 'vcenter'
const Actions = {
VCENTER
}
module.exports = Actions

View File

@ -0,0 +1,116 @@
/* ------------------------------------------------------------------------- *
* 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. *
* ------------------------------------------------------------------------- */
const { httpMethod, from: fromData } = require('server/utils/constants/defaults')
const { importVcenter, list, listAll, cleartags, hosts } = require('./functions')
const { POST, GET } = httpMethod
const routes = {
[POST]: {
import: {
action: importVcenter,
params: {
vobject: {
from: fromData.resource,
name: 'id'
},
host: {
from: fromData.postBody,
name: 'host'
},
datastore: {
from: fromData.postBody,
name: 'datastore'
},
id: {
from: fromData.postBody,
name: 'id'
},
answers: {
from: fromData.postBody,
name: 'answers'
}
}
},
cleartags: {
action: cleartags,
params: {
id: {
from: fromData.resource,
name: 'id'
}
}
},
hosts: {
action: hosts,
params: {
vcenter: {
from: fromData.postBody,
name: 'vcenter'
},
user: {
from: fromData.postBody,
name: 'user'
},
pass: {
from: fromData.postBody,
name: 'pass'
}
}
}
},
[GET]: {
list: {
action: list,
params: {
vobject: {
from: fromData.resource,
name: 'id'
},
host: {
from: fromData.query,
name: 'host'
},
datastore: {
from: fromData.query,
name: 'datastore'
}
}
},
listall: {
action: listAll,
params: {
vobject: {
from: fromData.resource,
name: 'id'
},
host: {
from: fromData.query,
name: 'host'
},
datastore: {
from: fromData.query,
name: 'datastore'
}
}
}
}
}
const authApi = {
routes
}
module.exports = authApi

View File

@ -0,0 +1,65 @@
/* ------------------------------------------------------------------------- *
* 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. *
* ------------------------------------------------------------------------- */
const {
defaultEmptyFunction,
defaultCommandVM
} = require('server/utils/constants/defaults')
const {
ok,
internalServerError,
badRequest
} = require('server/utils/constants/http-codes')
const { httpResponse, executeCommand } = require('server/utils/server')
const { getSunstoneConfig } = require('server/utils/yml')
const httpBadRequest = httpResponse(badRequest, '', '')
const appConfig = getSunstoneConfig()
const prependCommand = appConfig.sunstone_prepend || ''
const regexpSplitLine = /\r|\n/
/**
* Save as template.
*
* @param {object} res - http response
* @param {Function} next - express stepper
* @param {object} params - params of http request
* @param {object} userData - user of http request
*/
const saveAsTemplate = (res = {}, next = defaultEmptyFunction, params = {}, userData = {}) => {
let rtn = httpBadRequest
if (params && params.id && params.name) {
const paramsCommand = ['save', `${params.id}`, `${params.name}`]
const executedCommand = executeCommand(defaultCommandVM, paramsCommand, prependCommand)
const response = executedCommand.success ? ok : internalServerError
let message = ''
if (executedCommand.data) {
message = executedCommand.data.replace(regexpSplitLine, '')
}
rtn = httpResponse(response, message)
}
res.locals.httpCode = rtn
next()
}
const functionRoutes = {
saveAsTemplate
}
module.exports = functionRoutes

View File

@ -0,0 +1,60 @@
/* ------------------------------------------------------------------------- *
* 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. *
* ------------------------------------------------------------------------- */
const { addFunctionAsRoute, setFunctionRoute } = require('server/utils/server')
const { routes: vmRoutes } = require('./vm')
const { VM } = require('./string-routes')
const privateRoutes = []
const publicRoutes = []
/**
* Set private routes.
*
* @param {object} routes - object of routes
* @param {string} path - principal route
* @param {Function} action - function of route
*/
const setPrivateRoutes = (routes = {}, path = '', action = () => undefined) => {
if (Object.keys(routes).length > 0 && routes.constructor === Object) {
Object.keys(routes).forEach((route) => {
privateRoutes.push(
setFunctionRoute(route, path,
(req, res, next, connection, userId, user) => {
action(req, res, next, routes[route], user, connection)
}
)
)
})
}
}
/**
* Add routes.
*
* @returns {Array} routes
*/
const generatePrivateRoutes = () => {
setPrivateRoutes(vmRoutes, VM, addFunctionAsRoute)
return privateRoutes
}
const functionRoutes = {
private: generatePrivateRoutes(),
public: publicRoutes
}
module.exports = functionRoutes

View File

@ -0,0 +1,23 @@
/* ------------------------------------------------------------------------- *
* 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. *
* ------------------------------------------------------------------------- */
const VM = 'vm'
const Actions = {
VM
}
module.exports = Actions

View File

@ -0,0 +1,42 @@
/* ------------------------------------------------------------------------- *
* 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. *
* ------------------------------------------------------------------------- */
const { httpMethod, from: fromData } = require('server/utils/constants/defaults')
const { saveAsTemplate } = require('./functions')
const { POST } = httpMethod
const routes = {
[POST]: {
save: {
action: saveAsTemplate,
params: {
id: {
from: fromData.resource,
name: 'id'
},
name: {
from: fromData.postBody,
name: 'name'
}
}
}
}
}
const authApi = {
routes
}
module.exports = authApi

View File

@ -0,0 +1,48 @@
/* ------------------------------------------------------------------------- *
* 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. *
* ------------------------------------------------------------------------- */
const { global } = require('window-or-global')
/**
* Get data fireedge session.
*
* @param {string} username - username
* @param {string} token - pass
* @returns {object} user session
*/
const getSession = (username = '', token = '') => {
if (
username &&
token &&
global &&
global.users &&
username &&
global.users[username] &&
global.users[username].tokens
) {
const session = global.users[username].tokens.find(
(curr = {}, index = 0) => {
return curr.token && curr.token === token
}
)
return session
}
}
const functions = {
getSession
}
module.exports = functions

View File

@ -14,12 +14,46 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
const privateRoutes = []
const { addFunctionAsRoute, setFunctionRoute } = require('server/utils/server')
const { routes: zendeskRoutes } = require('./zendesk')
const { ZENDESK } = require('./string-routes')
const privateRoutes = []
const publicRoutes = []
/**
* Set private routes.
*
* @param {object} routes - object of routes
* @param {string} path - principal route
* @param {Function} action - function of route
*/
const setPrivateRoutes = (routes = {}, path = '', action = () => undefined) => {
if (Object.keys(routes).length > 0 && routes.constructor === Object) {
Object.keys(routes).forEach((route) => {
privateRoutes.push(
setFunctionRoute(route, path,
(req, res, next, connection, userId, user) => {
action(req, res, next, routes[route], user, connection)
}
)
)
})
}
}
/**
* Add routes.
*
* @returns {Array} routes
*/
const generatePrivateRoutes = () => {
setPrivateRoutes(zendeskRoutes, ZENDESK, addFunctionAsRoute)
return privateRoutes
}
const functionRoutes = {
private: privateRoutes,
private: generatePrivateRoutes(),
public: publicRoutes
}

View File

@ -0,0 +1,23 @@
/* ------------------------------------------------------------------------- *
* 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. *
* ------------------------------------------------------------------------- */
const ZENDESK = 'zendesk'
const Actions = {
ZENDESK
}
module.exports = Actions

View File

@ -0,0 +1,407 @@
/* ------------------------------------------------------------------------- *
* 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. *
* ------------------------------------------------------------------------- */
const { env } = require('process')
const zendesk = require('node-zendesk')
const { getSunstoneConfig } = require('server/utils/yml')
const { defaultEmptyFunction, defaultSeverities, defaultWebpackMode } = require('server/utils/constants/defaults')
const { httpResponse } = require('server/utils/server')
const { getSession } = require('./functions')
const {
ok,
internalServerError,
badRequest,
unauthorized
} = require('server/utils/constants/http-codes')
const formatCreate = (
{
subject = '',
body = '',
version = '',
severity = ''
}
) => {
let rtn
if (subject && body && version && severity) {
rtn = {
request: {
subject,
comment: {
body
},
custom_fields: [
{ id: 391130, value: version }, // version
{ id: 391197, value: severity } // severity
],
can_be_solved_by_me: false,
tags: [severity]
}
}
}
return rtn
}
const formatComment = ({ body = '', solved = '', attachments = [] }) => {
let rtn
if (body) {
rtn = {
request: {
comment: {
body,
public: true
}
}
}
if (solved) {
rtn.solved = 'true'
}
if (attachments && Array.isArray(attachments) && attachments.length > 0) {
rtn.request.comment.uploads = attachments.filter(att => att)
}
}
return rtn
}
const parseBufferError = (err) => {
let rtn = ''
let errorJson = {}
if (err && err.result) {
try {
errorJson = JSON.parse(
err.result.toString()
)
} catch {}
if (errorJson && errorJson.error) {
rtn = errorJson.error.title ? `${errorJson.error.title}: ` : ''
rtn += errorJson.error.message ? errorJson.error.message : ''
}
}
return rtn
}
const httpBadRequest = httpResponse(badRequest, '', '')
/**
* Login on Zendesk.
*
* @param {object} response - http response
* @param {Function} next - express stepper
* @param {object} params - params of http request
* @param {object} userData - user of http request
*/
const login = (response = {}, next = defaultEmptyFunction, params = {}, userData = {}) => {
const sunstoneConfig = getSunstoneConfig()
const remoteUri = sunstoneConfig.support_url || ''
if (
remoteUri &&
params &&
params.user &&
params.pass &&
userData &&
userData.user &&
userData.password
) {
const zendeskData = {
username: params.user,
password: params.pass,
remoteUri,
debug: env.NODE_ENV === defaultWebpackMode
}
const session = getSession(userData.user, userData.password)
/** ZENDESK AUTH */
const zendeskClient = zendesk.createClient(zendeskData)
zendeskClient.users.auth((err, res, result) => {
let method = ok
let data = result
if (err) {
if (session.zendesk) {
delete session.zendesk
}
method = internalServerError
data = parseBufferError(err)
}
if (result && result.authenticity_token) {
const zendeskUserData = {
...zendeskData,
id: result.id
}
session.zendesk = zendeskUserData
}
response.locals.httpCode = httpResponse(method, data)
next()
})
} else {
response.locals.httpCode = httpBadRequest
next()
}
}
/**
* List on Zendesk.
*
* @param {object} response - http response
* @param {Function} next - express stepper
* @param {object} params - params of http request
* @param {object} userData - user of http request
*/
const list = (response = {}, next = defaultEmptyFunction, params = {}, userData = {}) => {
if (
userData &&
userData.user &&
userData.password
) {
const session = getSession(userData.user, userData.password)
if (session.zendesk && session.zendesk.id) {
/** LIST ZENDESK */
const zendeskClient = zendesk.createClient(session.zendesk)
zendeskClient.requests.getRequest(
{ status: 'open,pending' },
(err, req, result) => {
let method = ok
let data = ''
if (err) {
method = internalServerError
data = parseBufferError(err)
} else if (result) {
let pendings = 0
let opens = 0
const tickets = Array.isArray(result) ? result : result
tickets.forEach(ticket => {
if (ticket && ticket.status) {
switch (ticket.status) {
case 'pending':
pendings += 1
break
default:
opens += 1
break
}
}
})
data = {
tickets: result,
pendings,
opens
}
}
response.locals.httpCode = httpResponse(
method,
data
)
next()
}
)
} else {
response.locals.httpCode = httpResponse(unauthorized)
next()
}
} else {
response.locals.httpCode = httpBadRequest
next()
}
}
/**
* Get Comments on ticket.
*
* @param {object} response - http response
* @param {Function} next - express stepper
* @param {object} params - params of http request
* @param {object} userData - user of http request
*/
const comments = (response = {}, next = defaultEmptyFunction, params = {}, userData = {}) => {
if (
params.id &&
userData &&
userData.user &&
userData.password
) {
const session = getSession(userData.user, userData.password)
if (session.zendesk) {
/** GET COMMENTS ON TICKET ZENDESK */
const zendeskClient = zendesk.createClient(session.zendesk)
zendeskClient.requests.listComments(
params.id,
(err, req, result) => {
let method = ok
let data = ''
if (err) {
method = internalServerError
data = parseBufferError(err)
} else if (result) {
data = result
}
response.locals.httpCode = httpResponse(method, data)
next()
}
)
} else {
response.locals.httpCode = httpResponse(unauthorized)
next()
}
} else {
response.locals.httpCode = httpBadRequest
next()
}
}
/**
* Create ticket.
*
* @param {object} response - http response
* @param {Function} next - express stepper
* @param {object} params - params of http request
* @param {object} userData - user of http request
*/
const create = (response = {}, next = defaultEmptyFunction, params = {}, userData = {}) => {
if (
params &&
params.subject &&
params.body &&
params.version &&
params.severity &&
defaultSeverities.includes(params.severity) &&
userData &&
userData.user &&
userData.password
) {
const session = getSession(userData.user, userData.password)
if (session.zendesk && session.zendesk.id) {
/** CREATE TICKET ZENDESK */
const zendeskClient = zendesk.createClient(session.zendesk)
const ticket = formatCreate(params)
zendeskClient.requests.create(
ticket,
(err, req, result) => {
let method = ok
let data = ''
if (err) {
method = internalServerError
data = parseBufferError(err)
} else if (result) {
data = result
}
response.locals.httpCode = httpResponse(method, data)
next()
}
)
} else {
response.locals.httpCode = httpResponse(unauthorized)
next()
}
} else {
response.locals.httpCode = httpBadRequest
next()
}
}
/**
* Update Ticket.
*
* @param {object} response - http response
* @param {Function} next - express stepper
* @param {object} params - params of http request
* @param {object} userData - user of http request
*/
const update = (response = {}, next = defaultEmptyFunction, params = {}, userData = {}) => {
if (
params.id &&
params.body &&
userData &&
userData.user &&
userData.password
) {
const session = getSession(userData.user, userData.password)
if (session.zendesk && session.zendesk.id) {
const zendeskClient = zendesk.createClient(session.zendesk)
const sendRequest = (params = {}) => {
/** UPDATE TICKET ZENDESK */
const ticket = formatComment(params)
zendeskClient.requests.update(
params.id,
ticket,
(err, req, result) => {
let method = ok
let data = ''
if (err) {
method = internalServerError
data = parseBufferError(err)
} else if (result) {
data = result
}
response.locals.httpCode = httpResponse(method, data)
next()
}
)
}
/** UPLOAD FILES */
let attachments
if (params && params.attachments && zendeskClient.attachments && typeof zendeskClient.attachments.upload === 'function') {
params.attachments.forEach((att = {}) => {
if (att && att.originalname && att.path) {
zendeskClient.attachments.upload(
att.path,
{
filename: att.originalname
},
(err, req, result) => {
const token = (result && result.upload && result.upload.token) || ''
if (attachments) {
attachments.push(token)
} else {
attachments = [token]
}
if (!err && token) {
if (attachments && attachments.length === params.attachments.length) {
sendRequest({ ...params, attachments })
}
}
}
)
}
})
} else {
sendRequest({ ...params, attachments })
}
} else {
response.locals.httpCode = httpResponse(unauthorized)
next()
}
} else {
response.locals.httpCode = httpBadRequest
next()
}
}
const functionRoutes = {
login,
list,
comments,
create,
update
}
module.exports = functionRoutes

View File

@ -0,0 +1,101 @@
/* ------------------------------------------------------------------------- *
* 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. *
* ------------------------------------------------------------------------- */
const { httpMethod, from: fromData } = require('server/utils/constants/defaults')
const { login, list, comments, create, update } = require('./zendesk-functions')
const { POST, GET, PUT } = httpMethod
const routes = {
[POST]: {
login: {
action: login,
params: {
user: {
from: fromData.postBody,
name: 'user'
},
pass: {
from: fromData.postBody,
name: 'pass'
}
}
},
create: {
action: create,
params: {
subject: {
from: fromData.postBody,
name: 'subject'
},
body: {
from: fromData.postBody,
name: 'body'
},
version: {
from: fromData.postBody,
name: 'version'
},
severity: {
from: fromData.postBody,
name: 'severity'
}
}
}
},
[PUT]: {
update: {
action: update,
params: {
id: {
from: fromData.resource,
name: 'id'
},
body: {
from: fromData.postBody,
name: 'body'
},
solved: {
from: fromData.postBody,
name: 'solved'
},
attachments: {
from: 'files',
name: 'attachments'
}
}
}
},
[GET]: {
list: {
action: list,
params: {}
},
comments: {
action: comments,
params: {
id: {
from: fromData.resource,
name: 'id'
}
}
}
}
}
const authApi = {
routes
}
module.exports = authApi

View File

@ -124,10 +124,11 @@ router.all(
routeFunction,
httpMethod
)
req.serverDataSource = dataSources
if (valRouteFunction) {
const userIdOpennebula = getIdUserOpennebula()
valRouteFunction(
dataSources,
req,
res,
next,
connectOpennebula,

View File

@ -21,6 +21,7 @@ const internalSunstonePath = `${appName}/${appNameSunstone}`
const internalProvisionPath = `${appName}/${appNameProvision}`
const baseUrl = `${appName ? `/${appName}/` : '/'}`
const baseUrlWebsockets = 'websockets/'
const severityPrepend = 'severity_'
const apps = {
[appNameSunstone]: {
theme: appNameSunstone,
@ -68,6 +69,7 @@ const defaults = {
'oneflow',
'support',
'vcenter',
'vm',
'zendesk',
appNameProvision,
appNameSunstone
@ -111,6 +113,7 @@ const defaults = {
defaultCommandProvisionTemplate: 'oneprovision-template',
defaultCommandProvider: 'oneprovider',
defaultCommandVcenter: 'onevcenter',
defaultCommandVM: 'onevm',
defaultFolderTmpProvision: 'tmp',
defaultHideCredentials: true,
defaultHideCredentialReplacer: '****',
@ -145,6 +148,7 @@ const defaults = {
defaultGetMethod: 'info',
defaultMessageProblemOpennebula: 'Problem with connection or xml parser',
defaultIP: defaultIp,
defaultSeverities: [`${severityPrepend}1`, `${severityPrepend}2`, `${severityPrepend}3`, `${severityPrepend}4`],
defaultProtocolHotReload: 'http',
defaultHost: '0.0.0.0',
defaultPort: 2616,

Some files were not shown because too many files have changed in this diff Show More