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

F #5422: Add clone VM template form (#1477)

This commit is contained in:
Sergio Betanzos 2021-09-23 19:10:24 +02:00 committed by GitHub
parent 437da65842
commit bedf1ab6f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 764 additions and 272 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -39,7 +39,6 @@ export const READONLY = {
notOnHypervisors: [vcenter],
type: INPUT_TYPES.SELECT,
values: [
{ text: '', value: '' },
{ text: T.Yes, value: 'YES' },
{ text: T.No, value: 'NO' }
],
@ -47,7 +46,7 @@ export const READONLY = {
.string()
.trim()
.notRequired()
.default(undefined)
.default(() => 'NO')
}
export const DEV_PREFIX = {

View File

@ -14,33 +14,34 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useCallback } from 'react'
import PropTypes from 'prop-types'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import {
SCHEMA,
FIELDS
} from 'client/components/Forms/Vm/AttachDiskForm/ImageSteps/AdvancedOptions/schema'
import { SCHEMA, FIELDS } from 'client/components/Forms/Vm/AttachDiskForm/ImageSteps/AdvancedOptions/schema'
import { T } from 'client/constants'
export const STEP_ID = 'advanced'
const Content = ({ hypervisor }) => (
<FormWithSchema
cy='attach-disk-advanced'
id={STEP_ID}
fields={FIELDS(hypervisor)}
/>
)
const AdvancedOptions = ({ hypervisor } = {}) => ({
id: STEP_ID,
label: T.AdvancedOptions,
resolver: () => SCHEMA(hypervisor),
optionsValidate: { abortEarly: false },
content: useCallback(
() => (
<FormWithSchema
cy='attach-disk-advanced'
id={STEP_ID}
fields={FIELDS(hypervisor)}
/>
),
[hypervisor]
)
content: () => Content({ hypervisor })
})
Content.propTypes = {
hypervisor: PropTypes.any,
data: PropTypes.any,
setFormData: PropTypes.func
}
export default AdvancedOptions

View File

@ -14,55 +14,50 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useCallback } from 'react'
import PropTypes from 'prop-types'
import { useListForm } from 'client/hooks'
import { ImagesTable } from 'client/components/Tables'
import {
SCHEMA
} from 'client/components/Forms/Vm/AttachDiskForm/ImageSteps/ImagesTable/schema'
import { SCHEMA } from 'client/components/Forms/Vm/AttachDiskForm/ImageSteps/ImagesTable/schema'
import { T } from 'client/constants'
export const STEP_ID = 'image'
const Content = ({ data, setFormData }) => {
const { ID } = data?.[0] ?? {}
const {
handleSelect,
handleClear
} = useListForm({ key: STEP_ID, setList: setFormData })
const handleSelectedRows = rows => {
const { original = {} } = rows?.[0] ?? {}
original.ID !== undefined ? handleSelect(original) : handleClear()
}
return (
<ImagesTable
singleSelect
onlyGlobalSearch
onlyGlobalSelectedRows
initialState={{ selectedRowIds: { [ID]: true } }}
onSelectedRowsChange={handleSelectedRows}
/>
)
}
const ImageStep = () => ({
id: STEP_ID,
label: T.Image,
resolver: () => SCHEMA,
content: useCallback(
({ data, setFormData }) => {
const selectedImage = data?.[0]
const {
handleSelect,
handleClear
} = useListForm({ key: STEP_ID, setList: setFormData })
const handleSelectedRows = rows => {
const { original } = rows?.[0] ?? {}
const { ID, NAME, UID, UNAME } = original ?? {}
const image = {
IMAGE_ID: ID,
IMAGE: NAME,
IMAGE_UID: UID,
IMAGE_UNAME: UNAME
}
ID !== undefined ? handleSelect(image) : handleClear()
}
return (
<ImagesTable
singleSelect
onlyGlobalSearch
onlyGlobalSelectedRows
initialState={{ selectedRowIds: { [selectedImage?.IMAGE_ID]: true } }}
onSelectedRowsChange={handleSelectedRows}
/>
)
}, [])
resolver: SCHEMA,
content: Content
})
Content.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func
}
export default ImageStep

View File

@ -20,4 +20,5 @@ export const SCHEMA = yup
.min(1, 'Select image')
.max(1, 'Max. one image selected')
.required('Image field is required')
.default([])
.ensure()
.default(() => [])

View File

@ -13,10 +13,51 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import ImagesTable from 'client/components/Forms/Vm/AttachDiskForm/ImageSteps/ImagesTable'
import AdvancedOptions from 'client/components/Forms/Vm/AttachDiskForm/ImageSteps/AdvancedOptions'
import { createSteps } from 'client/utils'
import ImagesTable, { STEP_ID as STEP_IMAGE } from 'client/components/Forms/Vm/AttachDiskForm/ImageSteps/ImagesTable'
import AdvancedOptions, { STEP_ID as STEP_ADVANCED } from 'client/components/Forms/Vm/AttachDiskForm/ImageSteps/AdvancedOptions'
import { mapUserInputs, createSteps } from 'client/utils'
const Steps = createSteps([ImagesTable, AdvancedOptions])
const Steps = createSteps(
[ImagesTable, AdvancedOptions],
{
transformInitialValue: initialValue => {
const {
IMAGE,
IMAGE_ID,
IMAGE_UID,
IMAGE_UNAME,
IMAGE_STATE,
...diskProps
} = initialValue ?? {}
return {
[STEP_IMAGE]: [{
...diskProps,
NAME: IMAGE,
ID: IMAGE_ID,
UID: IMAGE_UID,
UNAME: IMAGE_UNAME,
STATE: IMAGE_STATE
}],
[STEP_ADVANCED]: initialValue
}
},
transformBeforeSubmit: formData => {
const { [STEP_IMAGE]: [image] = [], [STEP_ADVANCED]: advanced } = formData
const { ID, NAME, UID, UNAME, STATE, SIZE, ...imageProps } = image ?? {}
return {
...imageProps,
IMAGE: NAME,
IMAGE_ID: ID,
IMAGE_UID: UID,
IMAGE_UNAME: UNAME,
IMAGE_STATE: STATE,
ORIGINAL_SIZE: SIZE,
...mapUserInputs(advanced)
}
}
}
)
export default Steps

View File

@ -14,31 +14,34 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useCallback } from 'react'
import PropTypes from 'prop-types'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import {
SCHEMA,
FIELDS
} from 'client/components/Forms/Vm/AttachDiskForm/VolatileSteps/AdvancedOptions/schema'
import { SCHEMA, FIELDS } from 'client/components/Forms/Vm/AttachDiskForm/VolatileSteps/AdvancedOptions/schema'
import { T } from 'client/constants'
export const STEP_ID = 'advanced'
const Content = ({ hypervisor }) => (
<FormWithSchema
cy='attach-disk-advanced'
fields={FIELDS(hypervisor)}
id={STEP_ID}
/>
)
const AdvancedOptions = ({ hypervisor } = {}) => ({
id: STEP_ID,
label: T.AdvancedOptions,
resolver: () => SCHEMA(hypervisor),
optionsValidate: { abortEarly: false },
content: useCallback(
() => <FormWithSchema
cy='attach-disk-advanced'
fields={FIELDS(hypervisor)}
id={STEP_ID}
/>,
[hypervisor]
)
content: () => Content({ hypervisor })
})
Content.propTypes = {
hypervisor: PropTypes.any,
data: PropTypes.any,
setFormData: PropTypes.func
}
export default AdvancedOptions

View File

@ -14,31 +14,34 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useCallback } from 'react'
import PropTypes from 'prop-types'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import {
SCHEMA,
FIELDS
} from 'client/components/Forms/Vm/AttachDiskForm/VolatileSteps/BasicConfiguration/schema'
import { SCHEMA, FIELDS } from 'client/components/Forms/Vm/AttachDiskForm/VolatileSteps/BasicConfiguration/schema'
import { T } from 'client/constants'
export const STEP_ID = 'configuration'
const Content = ({ hypervisor }) => (
<FormWithSchema
cy='attach-disk-configuration'
fields={FIELDS(hypervisor)}
id={STEP_ID}
/>
)
const BasicConfiguration = ({ hypervisor } = {}) => ({
id: STEP_ID,
label: T.Configuration,
resolver: () => SCHEMA(hypervisor),
optionsValidate: { abortEarly: false },
content: useCallback(
() => <FormWithSchema
cy='attach-disk-configuration'
fields={FIELDS(hypervisor)}
id={STEP_ID}
/>,
[hypervisor]
)
content: () => Content({ hypervisor })
})
Content.propTypes = {
hypervisor: PropTypes.any,
data: PropTypes.any,
setFormData: PropTypes.func
}
export default BasicConfiguration

View File

@ -13,10 +13,28 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import BasicConfiguration from 'client/components/Forms/Vm/AttachDiskForm/VolatileSteps/BasicConfiguration'
import AdvancedOptions from 'client/components/Forms/Vm/AttachDiskForm/VolatileSteps/AdvancedOptions'
import { createSteps } from 'client/utils'
import BasicConfiguration, { STEP_ID as BASIC_ID } from 'client/components/Forms/Vm/AttachDiskForm/VolatileSteps/BasicConfiguration'
import AdvancedOptions, { STEP_ID as ADVANCED_ID } from 'client/components/Forms/Vm/AttachDiskForm/VolatileSteps/AdvancedOptions'
import { mapUserInputs, createSteps } from 'client/utils'
const Steps = createSteps([BasicConfiguration, AdvancedOptions])
const Steps = createSteps(
[BasicConfiguration, AdvancedOptions],
{
transformInitialValue: (disk = {}, schema) => ({
...schema.cast({
[BASIC_ID]: disk,
[ADVANCED_ID]: disk
}, { stripUnknown: true })
}),
transformBeforeSubmit: formData => {
const {
[BASIC_ID]: configuration = {},
[ADVANCED_ID]: advanced = {}
} = formData ?? {}
return { ...mapUserInputs(advanced), ...mapUserInputs(configuration) }
}
}
)
export default Steps

View File

@ -20,4 +20,5 @@ export const SCHEMA = yup
.min(1, 'Select network')
.max(1, 'Max. one network selected')
.required('Network field is required')
.default([])
.ensure()
.default(() => [])

View File

@ -13,16 +13,38 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import NetworksTable, { STEP_ID as NETWORK_STEP } from 'client/components/Forms/Vm/AttachNicForm/Steps/NetworksTable'
import AdvancedOptions, { STEP_ID as ADVANCED_STEP } from 'client/components/Forms/Vm/AttachNicForm/Steps/AdvancedOptions'
import NetworksTable, { STEP_ID as NETWORK_ID } from 'client/components/Forms/Vm/AttachNicForm/Steps/NetworksTable'
import AdvancedOptions, { STEP_ID as ADVANCED_ID } from 'client/components/Forms/Vm/AttachNicForm/Steps/AdvancedOptions'
import { mapUserInputs, createSteps } from 'client/utils'
const Steps = createSteps(
[NetworksTable, AdvancedOptions],
{
transformInitialValue: nic => {
const {
NETWORK,
NETWORK_ID: ID,
NETWORK_UID,
NETWORK_UNAME,
SECURITY_GROUPS,
...rest
} = nic ?? {}
return {
[NETWORK_ID]: [{
...nic,
ID,
NAME: NETWORK,
UID: NETWORK_UID,
UNAME: NETWORK_UNAME,
SECURITY_GROUPS
}],
[ADVANCED_ID]: rest
}
},
transformBeforeSubmit: formData => {
const { [NETWORK_STEP]: network, [ADVANCED_STEP]: advanced } = formData
const { ID, NAME, UID, UNAME, SECURITY_GROUPS } = network?.[0]
const { [NETWORK_ID]: [network] = [], [ADVANCED_ID]: advanced } = formData
const { ID, NAME, UID, UNAME, SECURITY_GROUPS } = network ?? {}
return {
NETWORK_ID: ID,
@ -32,19 +54,6 @@ const Steps = createSteps(
SECURITY_GROUPS,
...mapUserInputs(advanced)
}
},
transformInitialValue: initialValue => {
const { NETWORK_ID, NETWORK, NETWORK_UID, NETWORK_UNAME, ...rest } = initialValue ?? {}
return {
[NETWORK_STEP]: [{
ID: NETWORK_ID,
NAME: NETWORK,
UID: NETWORK_UID,
UNAME: NETWORK_UNAME
}],
[ADVANCED_STEP]: rest
}
}
}
)

View File

@ -0,0 +1,21 @@
/* ------------------------------------------------------------------------- *
* 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 { createForm } from 'client/utils'
import { SCHEMA, FIELDS } from 'client/components/Forms/VmTemplate/CloneForm/schema'
const CloneForm = createForm(SCHEMA, FIELDS)
export default CloneForm

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. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { string, boolean, object } from 'yup'
import { INPUT_TYPES } from 'client/constants'
import { getValidationFromFields } from 'client/utils'
const PREFIX = {
name: 'prefix',
label: 'Prefix',
tooltip: `
Several templates are selected,
please choose prefix to name the new copies.`,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required('Prefix field is required')
.default(() => 'Copy of ')
}
const NAME = {
name: 'name',
label: 'Name',
tooltip: 'Name for the new template.',
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required('Name field is required')
.default(() => '')
}
const IMAGES = {
name: 'image',
label: 'Clone with images',
tooltip: `
You can also clone any Image referenced inside this Template.
They will be cloned to a new Image, and made persistent.`,
type: INPUT_TYPES.CHECKBOX,
validation: boolean().default(() => false),
grid: { md: 12 }
}
export const FIELDS = ({ isMultiple } = {}) =>
[isMultiple ? PREFIX : NAME, IMAGES]
export const SCHEMA = props => object(getValidationFromFields(FIELDS(props)))

View File

@ -14,13 +14,9 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useMemo } from 'react'
import { useFormContext } from 'react-hook-form'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import useStyles from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/BasicConfiguration/styles'
import { STEP_ID as TEMPLATE_ID } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/VmTemplatesTable'
import { SCHEMA, FIELDS } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/BasicConfiguration/schema'
import { Tr } from 'client/components/HOC'
import { T } from 'client/constants'
@ -29,8 +25,6 @@ export const STEP_ID = 'configuration'
const Content = () => {
const classes = useStyles()
const { watch } = useFormContext()
const selectedVmTemplate = useMemo(() => watch(`${TEMPLATE_ID}[0]`), [])
return (
<div className={classes.root}>
@ -47,12 +41,6 @@ const Content = () => {
legend={Tr(T.Capacity)}
id={STEP_ID}
/>
<FormWithSchema
cy='instantiate-vm-template-configuration.disk'
fields={FIELDS.DISK(selectedVmTemplate)}
legend={Tr(T.Disks)}
id={STEP_ID}
/>
<FormWithSchema
cy='instantiate-vm-template-configuration.ownership'
fields={FIELDS.OWNERSHIP}

View File

@ -13,12 +13,11 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { object } from 'yup'
import { FIELDS as INFORMATION_FIELDS, SCHEMA as INFORMATION_SCHEMA } from './informationSchema'
import { FIELDS as CAPACITY_FIELDS, SCHEMA as CAPACITY_SCHEMA } from './capacitySchema'
import { FIELDS as DISK_FIELDS, SCHEMA as DISK_SCHEMA } from './diskSchema'
// import { FIELDS as DISK_FIELDS, SCHEMA as DISK_SCHEMA } from './diskSchema'
import { FIELDS as VM_GROUP_FIELDS, SCHEMA as VM_GROUP_SCHEMA } from './vmGroupSchema'
import { FIELDS as OWNERSHIP_FIELDS, SCHEMA as OWNERSHIP_SCHEMA } from './ownershipSchema'
import { FIELDS as VCENTER_FIELDS, SCHEMA as VCENTER_SCHEMA } from './vcenterSchema'
@ -26,7 +25,7 @@ import { FIELDS as VCENTER_FIELDS, SCHEMA as VCENTER_SCHEMA } from './vcenterSch
export const FIELDS = {
INFORMATION: INFORMATION_FIELDS,
CAPACITY: CAPACITY_FIELDS,
DISK: vmTemplate => DISK_FIELDS(vmTemplate),
// DISK: vmTemplate => DISK_FIELDS(vmTemplate),
OWNERSHIP: OWNERSHIP_FIELDS,
VM_GROUP: VM_GROUP_FIELDS,
VCENTER: VCENTER_FIELDS
@ -35,7 +34,7 @@ export const FIELDS = {
export const SCHEMA = object()
.concat(INFORMATION_SCHEMA)
.concat(CAPACITY_SCHEMA)
.concat(DISK_SCHEMA)
// .concat(DISK_SCHEMA)
.concat(OWNERSHIP_SCHEMA)
.concat(VM_GROUP_SCHEMA)
.concat(VCENTER_SCHEMA)

View File

@ -14,7 +14,7 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useMemo, SetStateAction } from 'react'
import { SetStateAction } from 'react'
import PropTypes from 'prop-types'
import {
@ -25,11 +25,10 @@ import {
} from 'iconoir-react'
import { Divider, makeStyles } from '@material-ui/core'
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'
import { useFormContext } from 'react-hook-form'
import { Translate } from 'client/components/HOC'
import { Action } from 'client/components/Cards/SelectCard'
import { STEP_ID as TEMPLATE_ID } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/VmTemplatesTable'
import { TAB_ID as STORAGE_ID } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration/storage'
import { TAB_ID as NIC_ID } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration/networking'
import { set } from 'client/utils'
import { T } from 'client/constants'
@ -54,10 +53,40 @@ const useStyles = makeStyles(theme => ({
}))
/**
* @param {string[]} newBootOrder - New boot order
* @param {SetStateAction} setFormData - New boot order
* @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
*/
export const reorder = (newBootOrder, setFormData) => {
export const reorderBootAfterRemove = (id, list, formData, setFormData) => {
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)
.map(resource => String(resource.NAME).toLowerCase())
const newBootOrder = [...formData?.OS?.BOOT?.split(',').filter(Boolean)]
.filter(bootId => !bootId.startsWith(type) || ids.includes(bootId))
.map(bootId => {
if (!bootId.startsWith(type)) return bootId
const resourceId = getIndexFromId(bootId)
return resourceId < idxToRemove
? bootId
: `${type}${resourceId - 1}`
})
reorder(newBootOrder, setFormData)
}
/**
* @param {string[]} newBootOrder - New boot order
* @param {SetStateAction} setFormData - React set state action
*/
const reorder = (newBootOrder, setFormData) => {
setFormData(prev => {
const newData = set({ ...prev }, 'extra.OS.BOOT', newBootOrder.join(','))
@ -67,41 +96,32 @@ export const reorder = (newBootOrder, setFormData) => {
const Booting = ({ data, setFormData }) => {
const classes = useStyles()
const { watch } = useFormContext()
const bootOrder = data?.OS?.BOOT?.split(',').filter(Boolean) ?? []
const disks = useMemo(() => {
const templateSeleted = watch(`${TEMPLATE_ID}[0]`)
const listOfDisks = [templateSeleted?.TEMPLATE?.DISK ?? []].flat()
return listOfDisks?.map(disk => {
const { DISK_ID, IMAGE, IMAGE_ID } = disk
const isVolatile = !IMAGE && !IMAGE_ID
const name = isVolatile
? <>`DISK ${DISK_ID}: `<Translate word={T.VolatileDisk} /></>
: `DISK ${DISK_ID}: ${IMAGE}`
const disks = data?.[STORAGE_ID]
?.map((disk, idx) => {
const isVolatile = !disk?.IMAGE && !disk?.IMAGE_ID
return {
ID: `disk${DISK_ID}`,
ID: `disk${idx}`,
NAME: (
<>
<ImageIcon size={16} />
{name}
{isVolatile
? <>{`${disk?.NAME}: `}<Translate word={T.VolatileDisk} /></>
: [disk?.NAME, disk?.IMAGE].filter(Boolean).join(': ')}
</>
)
}
})
}, [])
}) ?? []
const nics = data?.[NIC_ID]
?.map((nic, idx) => ({ ...nic, NAME: nic?.NAME ?? `NIC${idx}` }))
?.map((nic, idx) => ({
ID: `nic${idx}`,
NAME: (
<>
<NetworkIcon size={16} />
{[nic?.NAME ?? `NIC${idx}`, nic.NETWORK].filter(Boolean).join(': ')}
{[nic?.NAME, nic.NETWORK].filter(Boolean).join(': ')}
</>
)
})) ?? []

View File

@ -22,6 +22,7 @@ import { WarningCircledOutline as WarningIcon } from 'iconoir-react'
import Tabs from 'client/components/Tabs'
import { SCHEMA } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration/schema'
import Storage from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration/storage'
import Networking from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration/networking'
import Placement from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration/placement'
import ScheduleAction from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration/scheduleAction'
@ -35,6 +36,10 @@ const Content = ({ data, setFormData }) => {
const { errors } = useFormContext()
const tabs = [
{
name: 'storage',
renderContent: Storage({ data, setFormData })
},
{
name: 'network',
renderContent: Networking({ data, setFormData })

View File

@ -25,8 +25,9 @@ import { AttachNicForm } from 'client/components/Forms/Vm'
import { Tr, Translate } from 'client/components/HOC'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration'
import { NIC_SCHEMA } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration/schema'
import { reorder } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration/booting'
import { SCHEMA as EXTRA_SCHEMA } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration/schema'
import { reorderBootAfterRemove } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration/booting'
import { stringToBoolean } from 'client/models/Helper'
import { T } from 'client/constants'
const useStyles = makeStyles({
@ -43,36 +44,21 @@ export const TAB_ID = 'NIC'
const Networking = ({ data, setFormData }) => {
const classes = useStyles()
const nics = data?.[TAB_ID]
?.map((nic, idx) => ({ ...nic, NAME: nic?.NAME ?? `NIC${idx}` }))
const { handleRemove, handleSave } = useListForm({
const { handleSetList, handleRemove, handleSave } = useListForm({
parent: EXTRA_ID,
key: TAB_ID,
list: nics,
setList: setFormData,
getItemId: (item) => item.NAME ?? `NIC${data.length + 1}`,
addItemId: (item, id) => ({ ...item, NAME: id })
getItemId: item => item.NAME,
addItemId: (item, _, itemIndex) => ({ ...item, NAME: `${TAB_ID}${itemIndex}` })
})
const reorderBootOrder = nicId => {
const getIndexFromNicId = id => String(id).toLowerCase().replace('nic', '')
const idxToRemove = getIndexFromNicId(nicId)
const reorderNics = () => {
const diskSchema = EXTRA_SCHEMA.pick([TAB_ID])
const { [TAB_ID]: newList } = diskSchema.cast({ [TAB_ID]: data?.[TAB_ID] })
const nicIds = nics
.filter(nic => nic.NAME !== nicId)
.map(nic => String(nic.NAME).toLowerCase())
const newBootOrder = [...data?.OS?.BOOT?.split(',').filter(Boolean)]
.filter(bootId => !bootId.startsWith('nic') || nicIds.includes(bootId))
.map(bootId => {
if (!bootId.startsWith('nic')) return bootId
const nicId = getIndexFromNicId(bootId)
return nicId < idxToRemove ? bootId : `nic${nicId - 1}`
})
reorder(newBootOrder, setFormData)
handleSetList(newList)
}
return (
@ -88,8 +74,7 @@ const Networking = ({ data, setFormData }) => {
}}
options={[{
form: () => AttachNicForm({ nics }),
onSubmit: formData =>
handleSave(NIC_SCHEMA.cast(formData))
onSubmit: handleSave
}]}
/>
<div className={classes.root}>
@ -103,7 +88,12 @@ const Networking = ({ data, setFormData }) => {
title={[NAME, NETWORK].filter(Boolean).join(' - ')}
subheader={<>
{Object
.entries({ RDP, SSH, ALIAS: PARENT, EXTERNAL })
.entries({
RDP: stringToBoolean(RDP),
SSH: stringToBoolean(SSH),
EXTERNAL: stringToBoolean(EXTERNAL),
ALIAS: PARENT
})
.map(([k, v]) => v ? `${k}` : '')
.filter(Boolean)
.join(' | ')
@ -116,7 +106,8 @@ const Networking = ({ data, setFormData }) => {
data-cy={`remove-${NAME}`}
handleClick={() => {
handleRemove(NAME)
reorderBootOrder(NAME)
reorderNics()
reorderBootAfterRemove(NAME, nics, data, setFormData)
}}
icon={<Trash size={18} />}
/>

View File

@ -17,7 +17,6 @@
import { array, object, string, lazy } from 'yup'
import { v4 as uuidv4 } from 'uuid'
import { SCHEMA as NETWORK_SCHEMA } from 'client/components/Forms/Vm/AttachNicForm/Steps/AdvancedOptions/schema'
import { SCHEMA as PUNCTUAL_SCHEMA } from 'client/components/Forms/Vm/CreateSchedActionForm/PunctualForm/schema'
import { SCHEMA as RELATIVE_SCHEMA } from 'client/components/Forms/Vm/CreateSchedActionForm/RelativeForm/schema'
import { INPUT_TYPES } from 'client/constants'
@ -65,14 +64,6 @@ export const DS_RANK_FIELD = {
validation: string().trim().notRequired()
}
export const NIC_SCHEMA = object({
NAME: string().trim(),
NETWORK_ID: string().trim(),
NETWORK: string().trim(),
NETWORK_UNAME: string().trim(),
SECURITY_GROUPS: string().trim()
}).concat(NETWORK_SCHEMA)
export const SCHED_ACTION_SCHEMA = lazy(({ TIME } = {}) => {
const isRelative = String(TIME).includes('+')
const schema = isRelative ? RELATIVE_SCHEMA : PUNCTUAL_SCHEMA
@ -81,7 +72,22 @@ export const SCHED_ACTION_SCHEMA = lazy(({ TIME } = {}) => {
})
export const SCHEMA = object({
NIC: array(NIC_SCHEMA).ensure(),
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(SCHED_ACTION_SCHEMA).ensure(),
OS: object({
BOOT: string().trim().notRequired()
@ -92,4 +98,4 @@ export const SCHEMA = object({
DS_REQ_FIELD,
DS_RANK_FIELD
])
})
}).noUnknown(false)

View File

@ -0,0 +1,194 @@
/* ------------------------------------------------------------------------- *
* 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 { useMemo } from 'react'
import PropTypes from 'prop-types'
import { useFormContext } from 'react-hook-form'
import { makeStyles } from '@material-ui/core'
import { Edit, Trash } from 'iconoir-react'
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 { Tr, Translate } from 'client/components/HOC'
import { STEP_ID as TEMPLATE_ID } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/VmTemplatesTable'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration'
import { SCHEMA as EXTRA_SCHEMA } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration/schema'
import { reorderBootAfterRemove } from 'client/components/Forms/VmTemplate/InstantiateForm/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 }) => {
const classes = useStyles()
const { watch } = useFormContext()
const { HYPERVISOR } = useMemo(() => watch(`${TEMPLATE_ID}[0]`) ?? {}, [])
const disks = data?.[TAB_ID]
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 reorderDisks = () => {
const diskSchema = EXTRA_SCHEMA.pick([TAB_ID])
const { [TAB_ID]: newList } = diskSchema.cast({ [TAB_ID]: data?.[TAB_ID] })
handleSetList(newList)
}
return (
<>
<ButtonToTriggerForm
buttonProps={{
color: 'secondary',
'data-cy': 'add-disk',
label: 'Add disk'
}}
dialogProps={{
title: `Add new: ${Tr(T.Disk)}`
}}
options={[
{
cy: 'attach-image-disk',
name: T.Image,
form: () => ImageSteps({ hypervisor: HYPERVISOR }),
onSubmit: handleSave
},
{
cy: 'attach-volatile-disk',
name: T.Volatile,
form: () => VolatileSteps({ hypervisor: HYPERVISOR }),
onSubmit: handleSave
}
]}
/>
<div className={classes.root}>
{disks?.map(item => {
const {
NAME,
TYPE,
IMAGE,
IMAGE_ID,
IMAGE_STATE,
ORIGINAL_SIZE,
SIZE = ORIGINAL_SIZE,
READONLY,
DATASTORE,
PERSISTENT
} = item
const isVolatile = !IMAGE && !IMAGE_ID
const isPersistent = stringToBoolean(PERSISTENT)
const state = !isVolatile && getState({ STATE: IMAGE_STATE })
const type = isVolatile ? TYPE : getDiskType(item)
const originalSize = +ORIGINAL_SIZE ? prettyBytes(+ORIGINAL_SIZE, 'MB') : '-'
const size = prettyBytes(+SIZE, 'MB')
return (
<SelectCard
key={NAME}
title={isVolatile ? (
<>
{`${NAME} - `}
<Translate word={T.VolatileDisk} />
</>
) : (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5em' }}>
<StatusCircle color={state?.color} tooltip={state?.name} />
{`${NAME}: ${IMAGE}`}
{isPersistent && <StatusChip text='PERSISTENT' />}
</span>
)}
subheader={<>
{Object
.entries({
[DATASTORE]: DATASTORE,
READONLY: stringToBoolean(READONLY),
PERSISTENT: stringToBoolean(PERSISTENT),
[isVolatile || ORIGINAL_SIZE === SIZE ? size : `${originalSize}/${size}`]: true,
[type]: type
})
.map(([k, v]) => v ? `${k}` : '')
.filter(Boolean)
.join(' | ')
}
</>}
action={
<>
<Action
data-cy={`remove-${NAME}`}
tooltip={<Translate word={T.Remove} />}
handleClick={() => {
handleRemove(NAME)
reorderDisks()
reorderBootAfterRemove(NAME, disks, data, setFormData)
}}
icon={<Trash size={18} />}
/>
<ButtonToTriggerForm
buttonProps={{
'data-cy': `edit-${NAME}`,
icon: <Edit size={18} />,
tooltip: <Translate word={T.Edit} />
}}
dialogProps={{
title: <><Translate word={T.Edit} />{`: ${NAME}`}</>
}}
options={[{
form: () => isVolatile
? VolatileSteps({ hypervisor: HYPERVISOR }, item)
: ImageSteps({ hypervisor: HYPERVISOR }, item),
onSubmit: newValues => handleSave(newValues, NAME)
}]}
/>
</>
}
/>
)
})}
</div>
</>
)
}
Storage.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func
}
Storage.displayName = 'Storage'
export default Storage

View File

@ -15,6 +15,7 @@
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import { makeStyles } from '@material-ui/core'
import { useListForm } from 'client/hooks'
import { useVmTemplateApi } from 'client/features/One'
@ -29,7 +30,14 @@ import { T } from 'client/constants'
export const STEP_ID = 'template'
const useStyles = makeStyles({
body: {
gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))'
}
})
const Content = ({ data, setFormData }) => {
const classes = useStyles()
const selectedTemplate = data?.[0]
const { getVmTemplate } = useVmTemplateApi()
@ -62,6 +70,7 @@ const Content = ({ data, setFormData }) => {
singleSelect
onlyGlobalSearch
onlyGlobalSelectedRows
classes={classes}
initialState={{ selectedRowIds: { [selectedTemplate?.ID]: true } }}
onSelectedRowsChange={handleSelectedRows}
/>

View File

@ -17,7 +17,7 @@ import VmTemplatesTable, { STEP_ID as TEMPLATE_ID } from 'client/components/Form
import BasicConfiguration, { STEP_ID as BASIC_ID } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/BasicConfiguration'
import ExtraConfiguration, { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration'
import { jsonToXml } from 'client/models/Helper'
import { createSteps, deepmerge } from 'client/utils'
import { createSteps } from 'client/utils'
const Steps = createSteps(
[VmTemplatesTable, BasicConfiguration, ExtraConfiguration],
@ -27,7 +27,7 @@ const Steps = createSteps(
[TEMPLATE_ID]: [vmTemplate],
[BASIC_ID]: vmTemplate?.TEMPLATE,
[EXTRA_ID]: vmTemplate?.TEMPLATE
}, { stripUnknown: true })
}, { stripUnknown: true, context: { [TEMPLATE_ID]: [vmTemplate] } })
}),
transformBeforeSubmit: formData => {
const {
@ -37,8 +37,7 @@ const Steps = createSteps(
} = formData ?? {}
// merge with template disks to get TYPE attribute
const DISK = deepmerge(templateSelected.TEMPLATE?.DISK, restOfConfig?.DISK)
const templateXML = jsonToXml({ ...extraTemplate, ...restOfConfig, DISK })
const templateXML = jsonToXml({ ...extraTemplate, ...restOfConfig })
const data = { instances, hold, persistent, template: templateXML }
const templates = [...new Array(instances)]

View File

@ -13,8 +13,10 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import CloneForm from 'client/components/Forms/VmTemplate/CloneForm'
import InstantiateForm from 'client/components/Forms/VmTemplate/InstantiateForm'
export {
CloneForm,
InstantiateForm
}

View File

@ -60,26 +60,28 @@ const OpenNebulaLogo = memo(({ width, height, spinner, withText, viewBox, ...pro
</defs>
)}
{/* --------------- CLOUD ------------------ */}
<path
fill={spinner ? 'url(#gradient__child1)' : cloudColor.child1.static}
d="M124.9,130.9c-4.6,1.1-9.1,2.2-13.7,3.2c-28,6-56.5,9.8-85.1,11.3c-1.8-0.2,0.7,1.1,1.1,1.5c7.6,5.5,17.5,6.5,26.9,6.6c22.9,0,45.7,0,68.6,0c0.8,0,1.3-0.2,1.7-0.7c0.5-0.5,0.5-1.7,0.5-1.7V130.9z"
/>
<path
fill={spinner ? 'url(#gradient__child2)' : cloudColor.child2.static}
d="M124.9,106.1c-14.4,6.8-29.5,12.3-44.8,16.9c-20.6,5.9-41.7,10.3-63,12.7c1.2,1.8,2.1,4.5,4.2,5.3c34.8-1.7,69.7-6.4,103.6-14.9V106.1z"
/>
<path
fill={spinner ? 'url(#gradient__child3)' : cloudColor.child3.static}
d="M124.9,81.6c-26,17.6-55.7,29.3-85.9,37.2c-8.5,2.1-17.1,4.4-25.7,5.8c0.4,2.3,0.9,4.4,1.8,6.6c36.5-4.3,72.7-13.2,106.2-28.6c1.2-0.6,2.5-1.2,3.7-1.8V81.6z"
/>
<path
fill={spinner ? 'url(#gradient__child4)' : cloudColor.child4.static}
d="M124.9,47.1c-13.1,15.6-29.7,28.1-47.4,38.2c-18.8,10.6-39,18.8-59.6,24.9c-1.5,0.8-4.4,0.5-4.3,2.8c-0.5,2.3-0.7,4.6-0.7,6.9c33.6-6.4,66.7-17,96.5-34.1c5.3-3.1,10.5-6.4,15.6-9.9V47.1z"
/>
<path
fill={spinner ? 'url(#gradient__child5)' : cloudColor.child5.static}
d="M124.9,12.9c-0.4-0.9-1.8-0.4-3.3,0.3c-5.7,2.4-28.8,13.7-32.3,28.7c-1.4,1.9-3.5-0.5-5.1-1c-13.1-6.4-29.7-4.3-40.9,5.1c-11.2,9-17.3,25.1-12.2,39c0.8,1.6,1.6,3.8-0.1,5.2c-2.1,1.4-4.4,2.5-6.2,4.3c-3.7,3.1-6.9,7.1-8.9,11.3c30.7-9.3,60.8-22.5,85.9-42.8c8.4-7,16.3-14.7,22.9-23.4V12.9z"
/>
<g id='logo__cloud'>
<path
fill={spinner ? 'url(#gradient__child1)' : cloudColor.child1.static}
d="M124.9,130.9c-4.6,1.1-9.1,2.2-13.7,3.2c-28,6-56.5,9.8-85.1,11.3c-1.8-0.2,0.7,1.1,1.1,1.5c7.6,5.5,17.5,6.5,26.9,6.6c22.9,0,45.7,0,68.6,0c0.8,0,1.3-0.2,1.7-0.7c0.5-0.5,0.5-1.7,0.5-1.7V130.9z"
/>
<path
fill={spinner ? 'url(#gradient__child2)' : cloudColor.child2.static}
d="M124.9,106.1c-14.4,6.8-29.5,12.3-44.8,16.9c-20.6,5.9-41.7,10.3-63,12.7c1.2,1.8,2.1,4.5,4.2,5.3c34.8-1.7,69.7-6.4,103.6-14.9V106.1z"
/>
<path
fill={spinner ? 'url(#gradient__child3)' : cloudColor.child3.static}
d="M124.9,81.6c-26,17.6-55.7,29.3-85.9,37.2c-8.5,2.1-17.1,4.4-25.7,5.8c0.4,2.3,0.9,4.4,1.8,6.6c36.5-4.3,72.7-13.2,106.2-28.6c1.2-0.6,2.5-1.2,3.7-1.8V81.6z"
/>
<path
fill={spinner ? 'url(#gradient__child4)' : cloudColor.child4.static}
d="M124.9,47.1c-13.1,15.6-29.7,28.1-47.4,38.2c-18.8,10.6-39,18.8-59.6,24.9c-1.5,0.8-4.4,0.5-4.3,2.8c-0.5,2.3-0.7,4.6-0.7,6.9c33.6-6.4,66.7-17,96.5-34.1c5.3-3.1,10.5-6.4,15.6-9.9V47.1z"
/>
<path
fill={spinner ? 'url(#gradient__child5)' : cloudColor.child5.static}
d="M124.9,12.9c-0.4-0.9-1.8-0.4-3.3,0.3c-5.7,2.4-28.8,13.7-32.3,28.7c-1.4,1.9-3.5-0.5-5.1-1c-13.1-6.4-29.7-4.3-40.9,5.1c-11.2,9-17.3,25.1-12.2,39c0.8,1.6,1.6,3.8-0.1,5.2c-2.1,1.4-4.4,2.5-6.2,4.3c-3.7,3.1-6.9,7.1-8.9,11.3c30.7-9.3,60.8-22.5,85.9-42.8c8.4-7,16.3-14.7,22.9-23.4V12.9z"
/>
</g>
{withText && (
<g id='logo__text'>

View File

@ -47,7 +47,12 @@ const Image = memo(({ src, imageInError, withSources, imgProps }) => {
}
if (error.fail) {
return <img src={imageInError} draggable={false} onError={addRetry} />
return <img
{...imgProps}
src={imageInError}
draggable={false}
onError={addRetry}
/>
}
return (

View File

@ -17,6 +17,7 @@
import { useMemo } from 'react'
import PropTypes from 'prop-types'
import clsx from 'clsx'
import { InfoEmpty } from 'iconoir-react'
import { Box, LinearProgress } from '@material-ui/core'
import {
@ -54,9 +55,10 @@ const EnhancedTable = ({
pageSize = 10,
RowComponent,
showPageCount,
singleSelect = false
singleSelect = false,
classes = {}
}) => {
const classes = EnhancedTableStyles()
const styles = EnhancedTableStyles()
const isFetching = isLoading && data === undefined
@ -127,8 +129,8 @@ const EnhancedTable = ({
}
return (
<Box {...getTableProps()} className={classes.root}>
<div className={classes.toolbar}>
<Box {...getTableProps()} className={clsx(styles.root, classes.root)}>
<div className={styles.toolbar}>
{/* TOOLBAR */}
{!isFetching && (
<Toolbar
@ -139,7 +141,7 @@ const EnhancedTable = ({
)}
{/* PAGINATION */}
<div className={classes.pagination}>
<div className={styles.pagination}>
{page?.length > 0 && (
<Pagination
handleChangePage={handleChangePage}
@ -152,10 +154,10 @@ const EnhancedTable = ({
</div>
{isLoading && (
<LinearProgress size='1em' color='secondary' className={classes.loading} />
<LinearProgress size='1em' color='secondary' className={styles.loading} />
)}
<div className={classes.table}>
<div className={styles.table}>
{/* FILTERS */}
{!isFetching && (
<Filters
@ -164,10 +166,10 @@ const EnhancedTable = ({
/>
)}
<div className={classes.body}>
<div className={clsx(styles.body, classes.body)}>
{/* NO DATA MESSAGE */}
{!isFetching && page?.length === 0 && (
<span className={classes.noDataMessage}>
<span className={styles.noDataMessage}>
<InfoEmpty />
<Translate word={T.NoDataAvailable} />
</span>
@ -209,6 +211,10 @@ export const EnhancedTableProps = {
fetchMore: PropTypes.func,
getRowId: PropTypes.func,
initialState: PropTypes.object,
classes: PropTypes.shape({
root: PropTypes.string,
body: PropTypes.string
}),
isLoading: PropTypes.bool,
onlyGlobalSearch: PropTypes.bool,
onlyGlobalSelectedRows: PropTypes.bool,

View File

@ -58,6 +58,7 @@ export default makeStyles(
display: 'grid',
gap: '1em',
gridTemplateColumns: 'minmax(0, 1fr)',
// gridTemplateRows: 'repeat(auto-fill, 10em)',
gridAutoRows: 'max-content',
paddingBlock: '0.8em',
'& > [role=row]': {
@ -68,9 +69,9 @@ export default makeStyles(
fontWeight: typography.fontWeightMedium,
fontSize: '1em',
border: `1px solid ${palette.divider}`,
borderRadius: 6,
borderRadius: '0.5em',
display: 'flex',
gap: 8,
gap: '1em',
'&:hover': {
backgroundColor: palette.action.hover
},

View File

@ -33,6 +33,7 @@ import {
import { useAuth } from 'client/features/Auth'
import { useVmTemplateApi } from 'client/features/One'
import { CloneForm } from 'client/components/Forms/VmTemplate'
import { createActions } from 'client/components/Tables/Enhanced/Utils'
import { PATH } from 'client/apps/sunstone/routesOne'
import { VM_TEMPLATE_ACTIONS, MARKETPLACE_APP_ACTIONS } from 'client/constants'
@ -40,7 +41,14 @@ import { VM_TEMPLATE_ACTIONS, MARKETPLACE_APP_ACTIONS } from 'client/constants'
const Actions = () => {
const history = useHistory()
const { view, getResourceView } = useAuth()
const { getVmTemplate, getVmTemplates, lock, unlock, remove } = useVmTemplateApi()
const {
getVmTemplate,
getVmTemplates,
lock,
unlock,
clone,
remove
} = useVmTemplateApi()
const vmTemplateActions = useMemo(() => createActions({
filters: getResourceView('VM-TEMPLATE')?.actions,
@ -106,8 +114,42 @@ const Actions = () => {
label: 'Clone',
tooltip: 'Clone',
selected: true,
disabled: true,
options: [{ onSubmit: () => undefined }]
dialogProps: {
title: rows => {
const isMultiple = rows?.length > 1
const { ID, NAME } = rows?.[0]?.original
return [
isMultiple ? 'Clone several Templates' : 'Clone Template',
!isMultiple && `#${ID} ${NAME}`
].filter(Boolean).join(' - ')
}
},
options: [{
form: rows => {
const vmTemplates = rows?.map(({ original }) => original)
const stepProps = { isMultiple: vmTemplates.length > 1 }
const initialValues = { name: `Copy of ${vmTemplates?.[0]?.NAME}` }
return CloneForm(stepProps, initialValues)
},
onSubmit: async (formData, rows) => {
try {
const { prefix } = formData
const vmTemplates = rows?.map?.(({ original: { ID, NAME } = {} }) => {
// overwrite all names with prefix+NAME
const formatData = prefix ? { name: `${prefix} ${NAME}` } : {}
return { id: ID, data: { ...formData, ...formatData } }
})
await Promise.all(vmTemplates.map(({ id, data }) => clone(id, data)))
} finally {
await getVmTemplates()
}
}
}]
},
{
tooltip: 'Change ownership',
@ -157,7 +199,7 @@ const Actions = () => {
isConfirmDialog: true,
onSubmit: async (_, rows) => {
const templateIds = rows?.map?.(({ original }) => original?.ID)
await Promise.all([...new Array(templateIds)].map(id => lock(id)))
await Promise.all(templateIds.map(id => lock(id)))
await Promise.all(templateIds.map(id => getVmTemplate(id)))
}
}]
@ -178,7 +220,7 @@ const Actions = () => {
options: [{
isConfirmDialog: true,
onSubmit: async (_, rows) => {
const templateIds = [...new Array(rows?.map?.(({ original }) => original?.ID))]
const templateIds = rows?.map?.(({ original }) => original?.ID)
await Promise.all(templateIds.map(id => unlock(id)))
await Promise.all(templateIds.map(id => getVmTemplate(id)))
}
@ -199,7 +241,7 @@ const Actions = () => {
options: [{
isConfirmDialog: true,
onSubmit: async (_, rows) => {
const templateIds = [...new Array(rows?.map?.(({ original }) => original?.ID))]
const templateIds = rows?.map?.(({ original }) => original?.ID)
await Promise.all(templateIds.map(id => remove(id)))
await getVmTemplates()
}

View File

@ -16,6 +16,7 @@
/* eslint-disable jsdoc/require-jsdoc */
import { CategoryFilter } from 'client/components/Tables/Enhanced/Utils'
import * as Helper from 'client/models/Helper'
import { } from 'client/constants'
export default [
{ Header: 'ID', accessor: 'ID', sortType: 'number' },
@ -24,6 +25,11 @@ export default [
{ Header: 'Group', accessor: 'GNAME' },
{ Header: 'Start Time', accessor: 'REGTIME' },
{ Header: 'Locked', accessor: 'LOCK' },
{
Header: 'Logo',
id: 'LOGO',
accessor: row => row?.TEMPLATE?.LOGO
},
{
Header: 'Virtual Router',
id: 'VROUTER',

View File

@ -14,6 +14,7 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useMemo } from 'react'
import PropTypes from 'prop-types'
import { User, Group, Lock } from 'iconoir-react'
@ -21,18 +22,38 @@ import { Typography } from '@material-ui/core'
import { StatusChip } from 'client/components/Status'
import { rowStyles } from 'client/components/Tables/styles'
import Image from 'client/components/Image'
import * as Helper from 'client/models/Helper'
import { isExternalURL } from 'client/utils'
import { LOGO_IMAGES_URL } from 'client/constants'
const Row = ({ original, value, ...props }) => {
const classes = rowStyles()
const { ID, NAME, UNAME, GNAME, REGTIME, LOCK, VROUTER } = value
const { ID, NAME, UNAME, GNAME, REGTIME, LOCK, VROUTER, LOGO = '' } = value
const [logoSource] = useMemo(() => {
const external = isExternalURL(LOGO)
const cleanLogoAttribute = String(LOGO).split('/').at(-1)
const src = external ? LOGO : `${LOGO_IMAGES_URL}/${cleanLogoAttribute}`
return [src, external]
}, [LOGO])
const logo = String(LOGO).split('/').at(-1)
const time = Helper.timeFromMilliseconds(+REGTIME)
const timeAgo = `registered ${time.toRelative()}`
return (
<div {...props}>
{logo && (
<div className={classes.figure}>
<Image
src={logoSource}
imgProps={{ className: classes.image }}
/>
</div>
)}
<div className={classes.main}>
<div className={classes.title}>
<Typography component='span'>
@ -44,7 +65,7 @@ const Row = ({ original, value, ...props }) => {
</span>
</div>
<div className={classes.caption}>
<span title={time.toFormat('ff')}>
<span title={time.toFormat('ff')} className='full-width'>
{`#${ID} ${timeAgo}`}
</span>
<span title={`Owner: ${UNAME}`}>

View File

@ -31,9 +31,25 @@ export const rowStyles = makeStyles(
flexWrap: 'wrap'
}
},
figure: {
flexBasis: '20%',
paddingTop: '12.5%',
overflow: 'hidden',
position: 'relative'
},
image: {
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
position: 'absolute',
userSelect: 'none'
},
main: {
flex: 'auto',
overflow: 'hidden'
overflow: 'hidden',
alignSelf: 'center'
},
title: {
color: palette.text.primary,
@ -52,8 +68,12 @@ export const rowStyles = makeStyles(
color: palette.text.secondary,
marginTop: 4,
display: 'flex',
gap: 8,
wordWrap: 'break-word'
gap: '0.5em',
flexWrap: 'wrap',
wordWrap: 'break-word',
'& > .full-width': {
flexBasis: '100%'
}
},
secondary: {
width: '25%',

View File

@ -38,11 +38,8 @@ const VmStorageTab = ({ tabProps: { actions } = {} }) => {
const hypervisor = VirtualMachine.getHypervisor(vm)
const actionsAvailable = Helper.getActionsAvailable(actions, hypervisor)
const handleAttachDisk = async ({ image, advanced, configuration }) => {
const imageSelected = image?.[0]
const root = { ...imageSelected, ...advanced, ...configuration }
const template = Helper.jsonToXml({ DISK: root })
const handleAttachDisk = async formData => {
const template = Helper.jsonToXml({ DISK: formData })
await attachDisk(vm.ID, template)
}

View File

@ -16,31 +16,51 @@
import * as STATES from 'client/constants/states'
import COLOR from 'client/constants/color'
/**
* @enum {(
* 'OS'|
* 'CD ROM'|
* 'DATABLOCK'|
* 'KERNEL'|
* 'RAMDISK'|
* 'CONTEXT'
* )} Image type
*/
/** @enum {string} Image type */
export const IMAGE_TYPES_STR = {
OS: 'OS',
CDROM: 'CDROM',
DATABLOCK: 'DATABLOCK',
KERNEL: 'KERNEL',
RAMDISK: 'RAMDISK',
CONTEXT: 'CONTEXT'
}
/** @type {IMAGE_TYPES_STR[]} Return the string representation of an Image type */
export const IMAGE_TYPES = [
'OS',
'CD ROM',
'DATABLOCK',
'KERNEL',
'RAMDISK',
'CONTEXT'
IMAGE_TYPES_STR.OS,
IMAGE_TYPES_STR.CDROM,
IMAGE_TYPES_STR.DATABLOCK,
IMAGE_TYPES_STR.KERNEL,
IMAGE_TYPES_STR.RAMDISK,
IMAGE_TYPES_STR.CONTEXT
]
/** @enum {('FILE'|'CD ROM'|'BLOCK'|'RBD')} Disk type */
/** @enum {string} Disk type */
export const DISK_TYPES_STR = {
FILE: 'FILE',
CDROM: 'CDROM',
BLOCK: 'BLOCK',
RBD: 'RBD',
RBD_CDROM: 'RBD CDROM',
GLUSTER: 'GLUSTER',
GLUSTER_CDROM: 'GLUSTER CDROM',
SHEEPDOG: 'SHEEPDOG',
SHEEPDOG_CDROM: 'SHEEPDOG CDROM',
ISCSI: 'ISCII'
}
/** @enum {DISK_TYPES_STR[]} Return the string representation of a Disk type */
export const DISK_TYPES = [
'FILE',
'CD ROM',
'BLOCK',
'RBD'
DISK_TYPES_STR.FILE,
DISK_TYPES_STR.CDROM,
DISK_TYPES_STR.BLOCK,
DISK_TYPES_STR.RBD,
DISK_TYPES_STR.RBD_CDROM,
DISK_TYPES_STR.GLUSTER,
DISK_TYPES_STR.GLUSTER_CDROM,
DISK_TYPES_STR.SHEEPDOG,
DISK_TYPES_STR.SHEEPDOG_CDROM,
DISK_TYPES_STR.ISCSI
]
/** @type {STATES.StateInfo[]} Image states */

View File

@ -31,6 +31,7 @@ export const WEBSOCKET_URL = `${APP_URL}/websockets`
export const STATIC_FILES_URL = `${APP_URL}/client/assets`
export const IMAGES_URL = `${STATIC_FILES_URL}/images`
export const LOGO_IMAGES_URL = `${IMAGES_URL}/logos`
export const PROVIDER_IMAGES_URL = `${IMAGES_URL}/providers`
export const PROVISION_IMAGES_URL = `${IMAGES_URL}/provisions`
export const DEFAULT_IMAGE = `${IMAGES_URL}/default.webp`

View File

@ -61,7 +61,7 @@ export const updateResourceList = (currentList, value) => {
// update if exists in current list, if not add it to list
const updatedList = currentItem
? currentList?.map(item => item?.ID === id ? value : item)
: [value, currentList]
: [value, ...currentList]
return updatedList
}

View File

@ -40,4 +40,4 @@ export const changePermissions = createAction(`${TEMPLATE}/chmod`, vmTemplateSer
export const changeOwnership = createAction(`${TEMPLATE}/chown`, vmTemplateService.changeOwnership)
export const rename = createAction(`${TEMPLATE}/rename`, vmTemplateService.rename)
export const lock = createAction(`${TEMPLATE}/lock`, vmTemplateService.lock)
export const unlock = createAction(`${TEMPLATE}/unlock`, vmTemplateService.lock)
export const unlock = createAction(`${TEMPLATE}/unlock`, vmTemplateService.unlock)

View File

@ -61,7 +61,7 @@ import { set } from 'client/utils'
* @property {SimpleCallback} handleSelect - Add an item to data form list
* @property {SimpleCallback} handleClone - Clones an item and change two attributes
* @property {SimpleCallback} handleEdit - Find the element by id and set value to editing state
* @property {function(newValues, id)} handleSave - Saves the data from editing state
* @property {SaveCallback} handleSave - Saves the data from editing state
*/
// ----------------------------------------------------------
@ -182,7 +182,7 @@ const useListForm = ({
const index = EXISTS_INDEX(itemIndex) ? itemIndex : list.length
const newList = Object.assign([], [...list],
{ [index]: getItemId(values) ? values : addItemId(values, id, itemIndex) }
{ [index]: getItemId(values) ? values : addItemId(values, id, index) }
)
handleSetList(newList)

View File

@ -22,7 +22,8 @@ import { IMAGE_TYPES, DISK_TYPES, IMAGE_STATES, StateInfo } from 'client/constan
* @param {number|string} image.TYPE - Type numeric code
* @returns {IMAGE_TYPES} - Image type
*/
export const getType = ({ TYPE } = {}) => IMAGE_TYPES[+TYPE]
export const getType = ({ TYPE } = {}) =>
isNaN(+TYPE) ? TYPE : IMAGE_TYPES[+TYPE]
/**
* Returns the image state.
@ -40,4 +41,5 @@ export const getState = ({ STATE } = {}) => IMAGE_STATES[+STATE]
* @param {number|string} image.DISK_TYPE - Disk type numeric code
* @returns {DISK_TYPES} - Disk type
*/
export const getDiskType = ({ DISK_TYPE } = {}) => DISK_TYPES[+DISK_TYPE]
export const getDiskType = ({ DISK_TYPE } = {}) =>
isNaN(+DISK_TYPE) ? DISK_TYPE : DISK_TYPES[+DISK_TYPE]

View File

@ -111,7 +111,7 @@ import { INPUT_TYPES } from 'client/constants'
/**
* @typedef {object} StepsForm
* @property {Step[]} steps - Steps
* @property {BaseSchema} resolver - Schema
* @property {BaseSchema|function():BaseSchema} resolver - Schema
* @property {object} defaultValues - Default values
*/
@ -347,8 +347,13 @@ export const createForm = (schema, fields, extraParams = {}) =>
const schemaCallback = typeof schema === 'function' ? schema(props) : schema
const fieldsCallback = typeof fields === 'function' ? fields(props) : fields
const defaultTransformInitialValue = (values, schema) =>
schema.cast(values, { stripUnknown: true })
const { transformInitialValue = defaultTransformInitialValue } = extraParams
const defaultValues = initialValues
? schemaCallback.cast(initialValues, { stripUnknown: true })
? transformInitialValue(initialValues, schemaCallback)
: schemaCallback.default()
return {