1
0
mirror of https://github.com/OpenNebula/one.git synced 2024-12-25 23:21:29 +03:00

F #5422: Add vm deploy and migrate forms (#1510)

This commit is contained in:
Sergio Betanzos 2021-10-07 15:53:16 +02:00 committed by GitHub
parent 6f6d0fb55f
commit 292b62d7ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 407 additions and 65 deletions

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. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import { SCHEMA, FIELDS } from 'client/components/Forms/Vm/MigrateForm/Steps/AdvancedOptions/schema'
import { T } from 'client/constants'
export const STEP_ID = 'advanced'
const Content = () => (
<FormWithSchema
cy='migrate-vm-advanced'
id={STEP_ID}
fields={FIELDS}
/>
)
const AdvancedOptions = () => ({
id: STEP_ID,
label: T.AdvancedOptions,
resolver: SCHEMA,
optionsValidate: { abortEarly: false },
content: Content
})
Content.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
nics: PropTypes.array
}
export default AdvancedOptions

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. *
* ------------------------------------------------------------------------- */
import { boolean, string, object } from 'yup'
import { DatastoresTable } from 'client/components/Tables'
import { getValidationFromFields } from 'client/utils'
import { INPUT_TYPES } from 'client/constants'
const ENFORCE = {
name: 'enforce',
label: 'Enforce capacity checks',
tooltip: `
If it is set to true, the host capacity will be checked.
This will only affect oneadmin requests, regular users
resize requests will always be enforced.`,
type: INPUT_TYPES.SWITCH,
validation: boolean().default(() => false),
grid: { md: 12 }
}
const DATASTORE = {
name: 'datastore',
label: 'Select the new datastore',
type: INPUT_TYPES.TABLE,
Table: DatastoresTable,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { md: 12 }
}
export const FIELDS = [ENFORCE, DATASTORE]
export const SCHEMA = object(getValidationFromFields(FIELDS))

View File

@ -0,0 +1,64 @@
/* ------------------------------------------------------------------------- *
* 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 PropTypes from 'prop-types'
import { useListForm } from 'client/hooks'
import { HostsTable } from 'client/components/Tables'
import { SCHEMA } from 'client/components/Forms/Vm/MigrateForm/Steps/HostsTable/schema'
import { T } from 'client/constants'
export const STEP_ID = 'host'
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 (
<HostsTable
singleSelect
onlyGlobalSearch
onlyGlobalSelectedRows
initialState={{ selectedRowIds: { [ID]: true } }}
onSelectedRowsChange={handleSelectedRows}
/>
)
}
const NetworkStep = () => ({
id: STEP_ID,
label: T.SelectHost,
resolver: SCHEMA,
content: Content
})
Content.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func
}
export default NetworkStep

View File

@ -0,0 +1,24 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import * as yup from 'yup'
export const SCHEMA = yup
.array(yup.object())
.min(1, 'Select the new Host')
.max(1, 'Max. one host selected')
.required('Host field is required')
.ensure()
.default(() => [])

View File

@ -0,0 +1,31 @@
/* ------------------------------------------------------------------------- *
* 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 HostsTable, { STEP_ID as HOST_ID } from 'client/components/Forms/Vm/MigrateForm/Steps/HostsTable'
import AdvancedOptions, { STEP_ID as ADVANCED_ID } from 'client/components/Forms/Vm/MigrateForm/Steps/AdvancedOptions'
import { createSteps } from 'client/utils'
const Steps = createSteps(
[HostsTable, AdvancedOptions],
{
transformBeforeSubmit: formData => {
const { [HOST_ID]: [host] = [], [ADVANCED_ID]: advanced } = formData
return { host: host?.ID, ...advanced }
}
}
)
export default Steps

View File

@ -0,0 +1,16 @@
/* ------------------------------------------------------------------------- *
* 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. *
* ------------------------------------------------------------------------- */
export { default } from 'client/components/Forms/Vm/MigrateForm/Steps'

View File

@ -18,6 +18,7 @@ import ChangeUserForm from 'client/components/Forms/Vm/ChangeUserForm'
import ChangeGroupForm from 'client/components/Forms/Vm/ChangeGroupForm'
import CreateDiskSnapshotForm from 'client/components/Forms/Vm/CreateDiskSnapshotForm'
import CreateSnapshotForm from 'client/components/Forms/Vm/CreateSnapshotForm'
import MigrateForm from 'client/components/Forms/Vm/MigrateForm'
import RecoverForm from 'client/components/Forms/Vm/RecoverForm'
import ResizeCapacityForm from 'client/components/Forms/Vm/ResizeCapacityForm'
import ResizeDiskForm from 'client/components/Forms/Vm/ResizeDiskForm'
@ -31,6 +32,7 @@ export {
ChangeGroupForm,
CreateDiskSnapshotForm,
CreateSnapshotForm,
MigrateForm,
RecoverForm,
ResizeCapacityForm,
ResizeDiskForm,

View File

@ -21,15 +21,20 @@ import { useFetch } from 'client/hooks'
import { useDatastore, useDatastoreApi } from 'client/features/One'
import { SkeletonTable, EnhancedTable } from 'client/components/Tables'
import { createColumns } from 'client/components/Tables/Enhanced/Utils'
import DatastoreColumns from 'client/components/Tables/Datastores/columns'
import DatastoreRow from 'client/components/Tables/Datastores/row'
const DatastoresTable = () => {
const columns = useMemo(() => DatastoreColumns, [])
const DatastoresTable = props => {
const { view, getResourceView, filterPool } = useAuth()
const columns = useMemo(() => createColumns({
filters: getResourceView('DATASTORE')?.filters,
columns: DatastoreColumns
}), [view])
const datastores = useDatastore()
const { getDatastores } = useDatastoreApi()
const { filterPool } = useAuth()
const { status, fetchRequest, loading, reloading, STATUS } = useFetch(getDatastores)
const { INIT, PENDING } = STATUS
@ -47,6 +52,7 @@ const DatastoresTable = () => {
isLoading={loading || reloading}
getRowId={row => String(row.ID)}
RowComponent={DatastoreRow}
{...props}
/>
)
}

View File

@ -59,7 +59,6 @@ export default makeStyles(
gap: '1em',
gridTemplateColumns: 'minmax(0, 1fr)',
gridAutoRows: 'max-content',
paddingBlock: '0.8em',
'& > [role=row]': {
padding: '0.8em',
cursor: 'pointer',

View File

@ -21,15 +21,20 @@ import { useFetch } from 'client/hooks'
import { useHost, useHostApi } from 'client/features/One'
import { SkeletonTable, EnhancedTable, EnhancedTableProps } from 'client/components/Tables'
import { createColumns } from 'client/components/Tables/Enhanced/Utils'
import HostColumns from 'client/components/Tables/Hosts/columns'
import HostRow from 'client/components/Tables/Hosts/row'
const HostsTable = props => {
const columns = useMemo(() => HostColumns, [])
const { view, getResourceView, filterPool } = useAuth()
const columns = useMemo(() => createColumns({
filters: getResourceView('HOST')?.filters,
columns: HostColumns
}), [view])
const hosts = useHost()
const { getHosts } = useHostApi()
const { filterPool } = useAuth()
const { status, fetchRequest, loading, reloading, STATUS } = useFetch(getHosts)
const { INIT, PENDING } = STATUS

View File

@ -31,12 +31,13 @@ import {
} from 'iconoir-react'
import { useAuth } from 'client/features/Auth'
import { useVmApi } from 'client/features/One'
import { useDatastore, useVmApi } from 'client/features/One'
import { Translate } from 'client/components/HOC'
import { RecoverForm, ChangeUserForm, ChangeGroupForm } from 'client/components/Forms/Vm'
import { RecoverForm, ChangeUserForm, ChangeGroupForm, MigrateForm } from 'client/components/Forms/Vm'
import { createActions } from 'client/components/Tables/Enhanced/Utils'
import { PATH } from 'client/apps/sunstone/routesOne'
import { getLastHistory } from 'client/models/VirtualMachine'
import { T, VM_ACTIONS, MARKETPLACE_APP_ACTIONS, VM_ACTIONS_BY_STATE } from 'client/constants'
const isDisabled = action => rows => {
@ -47,22 +48,31 @@ const isDisabled = action => rows => {
return states.some(state => !VM_ACTIONS_BY_STATE[action]?.includes(state))
}
const ListVmNames = ({ rows = [] }) => (
<Typography>
<Translate word={T.VMs} />
{`: ${rows?.map?.(({ original }) => original?.NAME).join(', ')}`}
</Typography>
)
const ListVmNames = ({ rows = [] }) => {
const datastores = useDatastore()
const SubHeader = rows => {
const isMultiple = rows?.length > 1
const firstRow = rows?.[0]?.original
return rows?.map?.(({ id, original }) => {
const { ID, NAME } = original
const { HID = '', HOSTNAME = '--', DS_ID = '' } = getLastHistory(original)
const DS_NAME = datastores?.find(ds => ds?.ID === DS_ID)?.NAME ?? '--'
return isMultiple
? <ListVmNames rows={rows} />
: <>{`#${firstRow?.ID} ${firstRow?.NAME}`}</>
return (
<Typography key={`vm-${id}`} variant='inherit'>
<Translate
word={T.WhereIsRunning}
values={[
`#${ID} ${NAME}`,
`#${HID} ${HOSTNAME}`,
`#${DS_ID} ${DS_NAME}`
]}
/>
</Typography>
)
})
}
const SubHeader = rows => <ListVmNames rows={rows} />
const MessageToConfirmAction = rows => (
<>
<ListVmNames rows={rows} />
@ -70,10 +80,6 @@ const MessageToConfirmAction = rows => (
</>
)
ListVmNames.displayName = 'ListVmNames'
SubHeader.displayName = 'SubHeader'
MessageToConfirmAction.displayName = 'MessageToConfirmAction'
const Actions = () => {
const history = useHistory()
const { view, getResourceView } = useAuth()
@ -97,6 +103,9 @@ const Actions = () => {
unresched,
recover,
changeOwnership,
deploy,
migrate,
migrateLive,
lock,
unlock
} = useVmApi()
@ -270,20 +279,44 @@ const Actions = () => {
accessor: VM_ACTIONS.DEPLOY,
disabled: isDisabled(VM_ACTIONS.DEPLOY),
name: T.Deploy,
form: () => undefined,
onSubmit: () => undefined
form: MigrateForm,
dialogProps: {
title: T.Deploy,
subheader: SubHeader
},
onSubmit: async (formData, rows) => {
const ids = rows?.map?.(({ original }) => original?.ID)
await Promise.all(ids.map(id => deploy(id, formData)))
await Promise.all(ids.map(id => getVm(id)))
}
}, {
accessor: VM_ACTIONS.MIGRATE,
disabled: isDisabled(VM_ACTIONS.MIGRATE),
name: T.Migrate,
form: () => undefined,
onSubmit: () => undefined
form: MigrateForm,
dialogProps: {
title: T.Migrate,
subheader: SubHeader
},
onSubmit: async (formData, rows) => {
const ids = rows?.map?.(({ original }) => original?.ID)
await Promise.all(ids.map(id => migrate(id, formData)))
await Promise.all(ids.map(id => getVm(id)))
}
}, {
accessor: VM_ACTIONS.MIGRATE_LIVE,
disabled: isDisabled(VM_ACTIONS.MIGRATE_LIVE),
name: T.MigrateLive,
form: () => undefined,
onSubmit: () => undefined
form: MigrateForm,
dialogProps: {
title: T.Migrate,
subheader: SubHeader
},
onSubmit: async (formData, rows) => {
const ids = rows?.map?.(({ original }) => original?.ID)
await Promise.all(ids.map(id => migrateLive(id, formData)))
await Promise.all(ids.map(id => getVm(id)))
}
}, {
accessor: VM_ACTIONS.HOLD,
disabled: isDisabled(VM_ACTIONS.HOLD),

View File

@ -16,32 +16,35 @@
import { memo } from 'react'
import PropTypes from 'prop-types'
import { Typography, Chip } from '@mui/material'
import makeStyles from '@mui/styles/makeStyles'
import { Typography, Chip, Box } from '@mui/material'
const useStyles = makeStyles({
root: {
display: 'inline-flex',
gap: '1em',
width: '100%'
},
label: {
flexGrow: 1
}
})
const DevTypography = memo(({ label, labelProps, color, chipProps }) => {
const classes = useStyles()
return (
<span className={classes.root}>
<Typography {...labelProps} className={classes.label}>
{label}
</Typography>
<Chip size='small' label='DEV' color={color} {...chipProps} />
</span>
)
})
const DevTypography = memo(({ label, labelProps, color, chipProps }) => (
<Box
component='span'
display='inline-flex'
gap='1em'
width='100%'
>
<Typography
flexGrow={1}
variant='inherit'
sx={{ textTransform: 'capitalize' }}
{...labelProps}
>
{label}
</Typography>
<Chip
size='small'
label='DEV'
color={color}
sx={{
height: 'auto',
cursor: 'inherit'
}}
{...chipProps}
/>
</Box>
))
DevTypography.propTypes = {
chipProps: PropTypes.object,

View File

@ -92,6 +92,7 @@ module.exports = {
SaveAsTemplate: 'Save as Template',
Search: 'Search',
Select: 'Select',
SelectHost: 'Select a host',
SelectGroup: 'Select a group',
SelectRequest: 'Select request',
SelectVmTemplate: 'Select a VM Template',
@ -309,6 +310,7 @@ module.exports = {
/* VM schema - info */
UserTemplate: 'User Template',
Template: 'Template',
WhereIsRunning: 'VM %1$s is currently running on Host %2$s and Datastore %3$s',
/* VM schema - capacity */
Capacity: 'Capacity',
PhysicalCpu: 'Physical CPU',

View File

@ -19,7 +19,7 @@ import { Container, Box, Grid } from '@mui/material'
import { useAuth } from 'client/features/Auth'
import { useFetchAll } from 'client/hooks'
import { useUserApi, useImageApi, useVNetworkApi } from 'client/features/One'
import { useUserApi, useImageApi, useVNetworkApi, useDatastoreApi } from 'client/features/One'
import * as Widgets from 'client/components/Widgets'
import dashboardStyles from 'client/containers/Dashboard/Provision/styles'
@ -31,6 +31,7 @@ function Dashboard () {
const { getUsers } = useUserApi()
const { getImages } = useImageApi()
const { getVNetworks } = useVNetworkApi()
const { getDatastores } = useDatastoreApi()
const { settings: { disableanimations } = {} } = useAuth()
const classes = dashboardStyles({ disableanimations })
@ -41,7 +42,8 @@ function Dashboard () {
fetchRequestAll([
getUsers(),
getImages(),
getVNetworks()
getVNetworks(),
getDatastores()
])
}, [])

View File

@ -86,3 +86,6 @@ export const addScheduledAction = createAction(`${VM}/add/scheduled-action`, vmS
export const updateScheduledAction = createAction(`${VM}/update/scheduled-action`, vmService.updateScheduledAction)
export const deleteScheduledAction = createAction(`${VM}/delete/scheduled-action`, vmService.deleteScheduledAction)
export const recover = createAction(`${VM}/recover`, vmService.recover)
export const deploy = createAction(`${VM}/deploy`, vmService.deploy)
export const migrate = createAction(`${VM}/migrate`, vmService.migrate)
export const migrateLive = createAction(`${VM}/migrate-live`, vmService.migrate)

View File

@ -85,6 +85,9 @@ export const useVmApi = () => {
unwrapDispatch(actions.updateScheduledAction({ id, ...data })),
deleteScheduledAction: (id, data) =>
unwrapDispatch(actions.deleteScheduledAction({ id, ...data })),
recover: (id, operation) => unwrapDispatch(actions.recover({ id, operation }))
recover: (id, operation) => unwrapDispatch(actions.recover({ id, operation })),
deploy: (id, data) => unwrapDispatch(actions.deploy({ id, ...data })),
migrate: (id, data) => unwrapDispatch(actions.migrate({ id, ...data, live: false })),
migrateLive: (id, data) => unwrapDispatch(actions.migrate({ id, ...data, live: true }))
}
}

View File

@ -594,6 +594,59 @@ export const vmService = ({
if (!res?.id || res?.id !== httpCodes.ok.id) throw res?.data
return res?.data
},
/**
* Initiates the instance of the given VM id on the target host.
*
* @param {object} params - Request parameters
* @param {string|number} params.id - Virtual machine id
* @param {string|number} params.host - The target host id
* @param {boolean} params.enforce
* - If `true`, will enforce the Host capacity isn't over committed.
* @param {string|number} params.datastore - The target datastore id.
* It is optional, and can be set to -1 to let OpenNebula choose the datastore
* @returns {number} Virtual machine id
* @throws Fails when response isn't code 200
*/
deploy: async params => {
const name = Actions.VM_DEPLOY
const command = { name, ...Commands[name] }
const config = requestConfig(params, command)
const res = await RestClient.request(config)
if (!res?.id || res?.id !== httpCodes.ok.id) throw res?.data
return res?.data
},
/**
* Migrates one virtual machine to the target host.
*
* @param {object} params - Request parameters
* @param {string|number} params.id - Virtual machine id
* @param {string|number} params.host - The target host id
* @param {boolean} params.live
* - If `true` we are indicating that we want live migration, otherwise `false`.
* @param {boolean} params.enforce
* - If `true`, will enforce the Host capacity isn't over committed.
* @param {string|number} params.datastore - The target datastore id.
* It is optional, and can be set to -1 to let OpenNebula choose the datastore
* @param {0|1|2} params.type - Migration type: save (0), poweroff (1), poweroff-hard (2)
* @returns {number} Virtual machine id
* @throws Fails when response isn't code 200
*/
migrate: async params => {
const name = Actions.VM_MIGRATE
const command = { name, ...Commands[name] }
const config = requestConfig(params, command)
const res = await RestClient.request(config)
if (!res?.id || res?.id !== httpCodes.ok.id) throw res?.data
return res?.data
}
})

View File

@ -120,11 +120,11 @@ module.exports = {
params: {
id: {
from: resource,
default: 0
default: -1
},
host: {
from: postBody,
default: 0
default: -1
},
enforce: {
from: postBody,
@ -156,13 +156,13 @@ module.exports = {
params: {
id: {
from: resource,
default: 0
default: -1
},
host: {
from: postBody,
default: 0
default: -1
},
liveMigration: {
live: {
from: postBody,
default: false
},
@ -172,9 +172,9 @@ module.exports = {
},
datastore: {
from: postBody,
default: 0
default: -1
},
migration: {
type: {
from: postBody,
default: 0
}