1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-01-05 09:17:41 +03:00

F OpenNebula/one#6053: Add VM restore action to FireEdge (#3113)

Signed-off-by: Victor Hansson <vhansson@opennebula.io>
This commit is contained in:
vichansson 2024-06-17 11:40:55 +03:00 committed by GitHub
parent 02e993a456
commit b1c92d811e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 330 additions and 70 deletions

View File

@ -40,6 +40,7 @@ actions:
rdp: true
edit_labels: true
backup: true
restore: true
# Filters - List of criteria to filter the resources

View File

@ -40,6 +40,7 @@ actions:
rdp: true
edit_labels: false
backup: true
restore: false
# Filters - List of criteria to filter the resources

View File

@ -23,12 +23,10 @@ import {
ReactElement,
} from 'react'
import PropTypes from 'prop-types'
import { BaseSchema } from 'yup'
import { useForm, FormProvider, useFormContext } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import { useMediaQuery } from '@mui/material'
import {
useGeneral,
updateDisabledSteps,
@ -99,6 +97,7 @@ const DisableStepContext = createContext(() => {})
* disableStep('step1', true); // This will disable 'step1'
*/
export const useDisableStep = () => useContext(DisableStepContext)
/**
* Represents a form with one or more steps.
* Finally, it submit the result.
@ -123,8 +122,12 @@ const FormStepper = ({
formState: { errors },
setError,
} = useFormContext()
const { setModifiedFields } = useGeneralApi()
const { isLoading } = useGeneral()
const [steps, setSteps] = useState(initialSteps)
const [disabledSteps, setDisabledSteps] = useState({})
const dispatch = useDispatch()
const currentState = useSelector((state) => state)
// Used to control the default visibility of a step
useEffect(() => {
@ -151,29 +154,22 @@ const FormStepper = ({
},
{}
)
// Set the initial state of the steps accessible from redux
dispatch(updateDisabledSteps(newState))
setDisabledSteps(newState)
}, [])
const { isLoading } = useGeneral()
const [steps, setSteps] = useState(initialSteps)
const [disabledSteps, setDisabledSteps] = useState({})
const dispatch = useDispatch()
const currentState = useSelector((state) => state)
const disableStep = useCallback((stepIds, shouldDisable) => {
const ids = Array.isArray(stepIds) ? stepIds : [stepIds]
setDisabledSteps((prev) => {
let newDisabledSteps = { ...prev }
// eslint-disable-next-line no-shadow
ids.forEach((stepId) => {
ids.forEach((sId) => {
newDisabledSteps = shouldDisable
? { ...newDisabledSteps, [stepId]: true }
: (({ [stepId]: _, ...rest }) => rest)(newDisabledSteps)
? { ...newDisabledSteps, [sId]: true }
: (({ [sId]: _, ...rest }) => rest)(newDisabledSteps)
})
return newDisabledSteps
@ -300,12 +296,12 @@ const FormStepper = ({
Number.isInteger(stepToBack) ? stepToBack : prevStep - 1
)
},
[activeStep]
[activeStep, steps]
)
const { id: stepId, content: Content } = useMemo(
() => steps[activeStep] || { id: null, content: null },
[formData, activeStep]
[steps, activeStep]
)
return (
@ -358,5 +354,4 @@ FormStepper.propTypes = {
}
export { DefaultFormStepper, SkeletonStepsForm }
export default FormStepper

View File

@ -0,0 +1,82 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { useFormContext } from 'react-hook-form'
import { BackupsTable } from 'client/components/Tables'
import { SCHEMA } from 'client/components/Forms/Backup/RestoreForm/Steps/BackupsTable/schema'
import { Step } from 'client/utils'
import { T } from 'client/constants'
export const STEP_ID = 'image'
const Content = ({ data, app: { backupIds = [] } = {} }) => {
const { ID } = data?.[0] ?? {}
const { setValue } = useFormContext()
const handleSelectedRows = (rows) => {
const { original = {} } = rows?.[0] ?? {}
setValue(STEP_ID, original.ID !== undefined ? [original] : [])
}
return (
<BackupsTable
singleSelect
disableGlobalSort
displaySelectedRows
pageSize={5}
getRowId={(row) => String(row.ID)}
filter={(images) =>
images?.filter(({ ID: imgId }) => backupIds?.includes(imgId)) ?? []
}
initialState={{
selectedRowIds: { [ID]: true },
}}
onSelectedRowsChange={handleSelectedRows}
/>
)
}
/**
* Step to select the Image.
*
* @param {object} app - Marketplace App resource
* @returns {Step} Image step
*/
const ImageStep = (app) => {
const { disableImageSelection } = app
return {
id: STEP_ID,
label: T.SelectBackupImage,
resolver: SCHEMA,
content: (props) => Content({ ...props, app }),
defaultDisabled: {
condition: () => disableImageSelection,
},
}
}
Content.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
app: PropTypes.object,
}
export default ImageStep

View File

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

View File

@ -18,11 +18,17 @@ import { timeFromMilliseconds } from 'client/models/Helper'
import { Field, arrayToOptions, getValidationFromFields } from 'client/utils'
import { ObjectSchema, boolean, object, string } from 'yup'
import { STEP_ID as VM_DISK_ID } from 'client/components/Forms/Backup/RestoreForm/Steps/VmDisksTable'
import { STEP_ID as BACKUP_IMG_ID } from 'client/components/Forms/Backup/RestoreForm/Steps/BackupsTable'
const NO_NIC = {
name: 'no_nic',
label: T.DoNotRestoreNICAttributes,
type: INPUT_TYPES.SWITCH,
htmlType: (deps) => {
const selectedImage = deps?.[BACKUP_IMG_ID]?.[0]
return selectedImage ? INPUT_TYPES.HIDDEN : INPUT_TYPES.SWITCH
},
validation: boolean().yesOrNo(),
grid: { md: 12 },
}
@ -31,6 +37,11 @@ const NO_IP = {
name: 'no_ip',
label: T.DoNotRestoreIPAttributes,
type: INPUT_TYPES.SWITCH,
htmlType: (deps) => {
const selectedImage = deps?.[BACKUP_IMG_ID]?.[0]
return selectedImage ? INPUT_TYPES.HIDDEN : INPUT_TYPES.SWITCH
},
validation: boolean().yesOrNo(),
grid: { md: 12 },
}
@ -61,19 +72,37 @@ const INCREMENT_ID = ({ increments = [] }) => ({
name: 'increment_id',
label: T.IncrementId,
type: INPUT_TYPES.SELECT,
values: arrayToOptions(increments, {
addEmpty: true,
getText: (increment) =>
`${increment.id}: ${timeFromMilliseconds(increment.date)
.toFormat('ff')
.replace(',', '')} (${increment.source})`,
getValue: (increment) => increment.id,
}),
values: (deps) => {
const selectedImage = deps?.[BACKUP_IMG_ID]?.[0]
let backupIncrements = [].concat(
selectedImage?.BACKUP_INCREMENTS?.INCREMENT ?? []
)
backupIncrements = backupIncrements.map((increment) => ({
id: increment.ID,
date: increment.DATE,
source: increment.SOURCE,
}))
return arrayToOptions(
backupIncrements?.length > 0 ? backupIncrements : increments,
{
addEmpty: true,
getText: (increment) =>
`${increment.id}: ${timeFromMilliseconds(increment.date)
.toFormat('ff')
.replace(',', '')} (${increment.source})`,
getValue: (increment) => increment.id,
}
)
},
validation: string(),
grid: { md: 6 },
fieldProps: {
disabled: increments.length === 0,
},
fieldProps: (deps) => ({
disabled:
deps?.[BACKUP_IMG_ID]?.[0]?.BACKUP_INCRMENETS?.INCREMENT?.length === 0 &&
increments.length === 0,
}),
})
/**

View File

@ -63,12 +63,20 @@ const Content = ({ data, app }) => {
* @param {object} app - Marketplace App resource
* @returns {Step} Datastore step
*/
const DatastoreStep = (app) => ({
id: STEP_ID,
label: T.SelectDatastoreImage,
resolver: SCHEMA,
content: (props) => Content({ ...props, app }),
})
const DatastoreStep = (app) => {
const { disableImageSelection } = app
return {
id: STEP_ID,
label: T.SelectDatastoreImage,
resolver: SCHEMA,
content: (props) => Content({ ...props, app }),
defaultDisabled: {
// Disabled when image selection is enabled, aka when in restore operation
condition: () => !disableImageSelection,
},
}
}
Content.propTypes = {
data: PropTypes.any,

View File

@ -49,6 +49,8 @@ const Content = ({ data, app: { backupDiskIds = [], vmsId = [] } = {} }) => {
selectedRowIds: { [selectedRow]: true },
}}
filter={(disks) =>
disks &&
disks?.length > 0 &&
disks?.filter((disk) => backupDiskIds?.includes(disk?.DISK_ID))
}
/>

View File

@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import BackupsTable, {
STEP_ID as BACKUP_IMG_ID,
} from 'client/components/Forms/Backup/RestoreForm/Steps/BackupsTable'
import BasicConfiguration, {
STEP_ID as BASIC_ID,
} from 'client/components/Forms/Backup/RestoreForm/Steps/BasicConfiguration'
@ -24,37 +27,42 @@ import VmDisksTable, {
} from 'client/components/Forms/Backup/RestoreForm/Steps/VmDisksTable'
import { createSteps } from 'client/utils'
const Steps = createSteps([BasicConfiguration, VmDisksTable, DatastoresTable], {
transformInitialValue: (initialValues, schema) => {
const { increments } = initialValues
const castedValuesBasic = schema.cast(
{ [BASIC_ID]: { increments } },
{ stripUnknown: true }
)
const Steps = createSteps(
[BackupsTable, BasicConfiguration, VmDisksTable, DatastoresTable],
{
transformInitialValue: (initialValues, schema) => {
const { increments } = initialValues
const castedValuesBasic = schema.cast(
{ [BASIC_ID]: { increments } },
{ stripUnknown: true }
)
const castedValuesDatastore = schema.cast(
{ [DATASTORE_ID]: {} },
{ stripUnknown: true }
)
const castedValuesDatastore = schema.cast(
{ [DATASTORE_ID]: {} },
{ stripUnknown: true }
)
return {
[BASIC_ID]: castedValuesBasic[BASIC_ID],
[DATASTORE_ID]: castedValuesDatastore[DATASTORE_ID],
}
},
transformBeforeSubmit: (formData) => {
const {
[BASIC_ID]: configuration,
[VM_DISK_ID]: individualDisk = [],
[DATASTORE_ID]: [datastore] = [],
} = formData
return {
[BASIC_ID]: castedValuesBasic[BASIC_ID],
[DATASTORE_ID]: castedValuesDatastore[DATASTORE_ID],
}
},
transformBeforeSubmit: (formData) => {
const {
[BACKUP_IMG_ID]: backupImgId = [],
[BASIC_ID]: configuration,
[VM_DISK_ID]: individualDisk = [],
[DATASTORE_ID]: [datastore] = [],
} = formData
return {
datastore: datastore?.ID,
individualDisk: individualDisk?.[0] ?? [],
...configuration,
}
},
})
return {
datastore: datastore?.ID,
individualDisk: individualDisk?.[0] ?? [],
backupImgId: backupImgId?.[0] ?? [],
...configuration,
}
},
}
)
export default Steps

View File

@ -114,7 +114,12 @@ const Actions = () => {
}))
return RestoreForm({
stepProps: { increments, backupDiskIds, vmsId },
stepProps: {
increments,
backupDiskIds,
vmsId,
disableImageSelection: true,
},
initialValues: {
increments: increments,
backupDiskIds: backupDiskIds,

View File

@ -35,6 +35,7 @@ const BackupsTable = (props) => {
searchProps = {},
vm,
refetchVm,
filter,
isFetchingVm,
...rest
} = props ?? {}
@ -54,11 +55,13 @@ const BackupsTable = (props) => {
: [vm?.BACKUPS?.BACKUP_IDS?.ID]
: []
const backupData = result?.data?.filter((backup) =>
vm ? backupsIds?.includes(backup.ID) : true
)
return {
...result,
data: result?.data?.filter((backup) =>
vm ? backupsIds?.includes(backup.ID) : true
),
data: typeof filter === 'function' ? filter(backupData) : backupData,
}
},
})
@ -76,10 +79,13 @@ const BackupsTable = (props) => {
* Refetch vms and backups. If a new backup is created, the id of the backup will be in the data of a vm, so we need to refetch also the vms query.
*/
const refetchAll = () => {
refetchVm()
refetchVm && refetchVm()
refetch()
}
const isFetchingAll = () =>
isFetchingVm ? !!(isFetchingVm && isFetching) : isFetching
return (
<EnhancedTable
columns={columns}
@ -87,7 +93,7 @@ const BackupsTable = (props) => {
rootProps={rootProps}
searchProps={searchProps}
refetch={refetchAll}
isLoading={isFetching && isFetchingVm}
isLoading={isFetchingAll()}
getRowId={(row) => String(row.ID)}
RowComponent={BackupRow}
{...rest}

View File

@ -41,7 +41,7 @@ const VmDisksTable = (props) => {
const { data, isFetching, refetch } = useGetVmQuery({ id: vmId })
const disks =
typeof filter === 'function'
typeof filter === 'function' && Array.isArray(data?.TEMPLATE?.DISK)
? filter(data?.TEMPLATE?.DISK ?? [])
: data?.TEMPLATE?.DISK ?? []

View File

@ -35,6 +35,7 @@ import { useGetDatastoresQuery } from 'client/features/OneApi/datastore'
import {
useActionVmMutation,
useBackupMutation,
useRestoreMutation,
useChangeVmOwnershipMutation,
useDeployMutation,
useLockVmMutation,
@ -52,6 +53,7 @@ import {
RecoverForm,
SaveAsTemplateForm,
} from 'client/components/Forms/Vm'
import { RestoreForm } from 'client/components/Forms/Backup'
import {
GlobalAction,
createActions,
@ -124,6 +126,7 @@ const Actions = () => {
const [actionVm] = useActionVmMutation()
const [recover] = useRecoverMutation()
const [backup] = useBackupMutation()
const [restore] = useRestoreMutation()
const [changeOwnership] = useChangeVmOwnershipMutation()
const [deploy] = useDeployMutation()
const [migrate] = useMigrateMutation()
@ -522,6 +525,41 @@ const Actions = () => {
)
},
},
{
accessor: VM_ACTIONS.RESTORE,
disabled: isDisabled(VM_ACTIONS.RESTORE),
name: T.Restore,
selected: { max: 1 },
dialogProps: {
title: T.RestoreVm,
subheader: SubHeader,
},
form: (row) => {
const vm = row?.[0]?.original
const vmId = vm?.ID
const backupIds = [].concat(vm?.BACKUPS?.BACKUP_IDS?.ID ?? [])
return RestoreForm({
stepProps: {
disableImageSelection: false,
vmsId: [vmId],
backupIds,
},
})
},
onSubmit: (rows) => async (formData) => {
const vmId = rows?.[0]?.id
const imageId = formData?.backupImgId?.ID
const incrementId = formData?.increment_id
const diskId = formData?.individualDisk
await restore({
id: vmId,
imageId,
incrementId,
diskId,
})
},
},
],
},
{

View File

@ -123,6 +123,7 @@ export const PROLOG_RESUME_FAILURE = 'PROLOG_RESUME_FAILURE'
export const PROLOG_UNDEPLOY = 'PROLOG_UNDEPLOY'
export const PROLOG_UNDEPLOY_FAILURE = 'PROLOG_UNDEPLOY_FAILURE'
export const READY = 'READY'
export const RESTORE = 'RESTORE'
export const RUNNING = 'RUNNING'
export const SAVE_MIGRATE = 'SAVE_MIGRATE'
export const SAVE_STOP = 'SAVE_STOP'

View File

@ -611,6 +611,7 @@ module.exports = {
IncrementMode: 'Increment Mode',
IncrementId: 'Increment ID',
RestoreBackup: 'Restore backup',
RestoreVm: 'Restore VM',
BackupJobs: 'BackupJobs',
BackupJob: 'BackupJob',
@ -1689,6 +1690,7 @@ module.exports = {
ImportAssociateApp: 'Import associated VM templates/images',
SelectResourceToCreateTheApp: 'Select the resource to create the App',
SelectImageToCreateTheApp: 'Select the Image to create the App',
SelectBackupImage: 'Select backup image',
SelectVmToCreateTheApp: 'Select the VM to create the App',
SelectVmTemplateToCreateTheApp: 'Select the VM Template to create the App',

View File

@ -724,11 +724,18 @@ export const VM_LCM_STATES = [
color: COLOR.info.main,
meaning: '',
},
{
// 71
name: STATES.RESTORE,
color: COLOR.info.main,
meaning: '',
},
]
/** @enum {string} Virtual machine actions */
export const VM_ACTIONS = {
BACKUP: 'backup',
RESTORE: 'restore',
CREATE_DIALOG: 'create_dialog',
CREATE_APP_DIALOG: 'create_app_dialog',
DEPLOY: 'deploy',
@ -846,6 +853,7 @@ export const DEFAULT_VM_ACTIONS_BY_STATE = {
STATES.PROLOG_UNDEPLOY_FAILURE,
STATES.UPDATE_FAILURE,
],
[VM_ACTIONS.RESTORE]: [STATES.POWEROFF],
[VM_ACTIONS.TERMINATE_HARD]: [
STATES.INIT,
STATES.PENDING,
@ -972,6 +980,7 @@ export const DUMMY_VM_ACTIONS_BY_STATE = {
STATES.PROLOG_UNDEPLOY_FAILURE,
STATES.UPDATE_FAILURE,
],
[VM_ACTIONS.RESTORE]: [STATES.POWEROFF],
[VM_ACTIONS.RELEASE]: [STATES.HOLD],
[VM_ACTIONS.RESCHED]: [STATES.POWEROFF, STATES.RUNNING, STATES.UNKNOWN],
[VM_ACTIONS.RESUME]: [
@ -1203,6 +1212,7 @@ export const KVM_VM_ACTIONS_BY_STATE = {
STATES.PROLOG_UNDEPLOY_FAILURE,
STATES.UPDATE_FAILURE,
],
[VM_ACTIONS.RESTORE]: [STATES.POWEROFF],
[VM_ACTIONS.RELEASE]: [STATES.HOLD],
[VM_ACTIONS.RESCHED]: [STATES.POWEROFF, STATES.RUNNING, STATES.UNKNOWN],
[VM_ACTIONS.RESUME]: [
@ -1433,6 +1443,7 @@ export const VCENTER_VM_ACTIONS_BY_STATE = {
STATES.PROLOG_UNDEPLOY_FAILURE,
STATES.UPDATE_FAILURE,
],
[VM_ACTIONS.RESTORE]: [STATES.POWEROFF],
[VM_ACTIONS.RELEASE]: [STATES.HOLD],
[VM_ACTIONS.RESCHED]: [STATES.POWEROFF, STATES.RUNNING, STATES.UNKNOWN],
[VM_ACTIONS.RESUME]: [
@ -1648,6 +1659,7 @@ export const FIRECRACKER_VM_ACTIONS_BY_STATE = {
STATES.PROLOG_UNDEPLOY_FAILURE,
STATES.UPDATE_FAILURE,
],
[VM_ACTIONS.RESTORE]: [STATES.POWEROFF],
[VM_ACTIONS.RELEASE]: [STATES.HOLD],
[VM_ACTIONS.RESCHED]: [STATES.POWEROFF, STATES.RUNNING, STATES.UNKNOWN],
[VM_ACTIONS.RESUME]: [
@ -1863,6 +1875,7 @@ export const LXC_VM_ACTIONS_BY_STATE = {
STATES.PROLOG_UNDEPLOY_FAILURE,
STATES.UPDATE_FAILURE,
],
[VM_ACTIONS.RESTORE]: [STATES.POWEROFF],
[VM_ACTIONS.RELEASE]: [STATES.HOLD],
[VM_ACTIONS.RESCHED]: [STATES.POWEROFF, STATES.RUNNING, STATES.UNKNOWN],
[VM_ACTIONS.RESUME]: [

View File

@ -959,6 +959,26 @@ const vmApi = oneApi.injectEndpoints({
},
invalidatesTags: (_, __, { id }) => [{ type: VM, id }],
}),
restore: builder.mutation({
/**
* Restore the VM.
*
* @param {object} params - Request parameters
* @param {string} params.id - Virtual machine id
* @param {number} params.imageId - Image backup id
* @param {boolean} params.incrementId - Backup increment ID
* @param {number} params.diskId - Individual disk id
* @returns {number} Virtual machine id
* @throws Fails when response isn't code 200
*/
query: (params) => {
const name = Actions.VM_RESTORE
const command = { name, ...Commands[name] }
return { params, command }
},
invalidatesTags: (_, __, { id }) => [{ type: VM, id }],
}),
lockVm: builder.mutation({
/**
* Locks a Virtual Machine. Lock certain actions depending on blocking level.
@ -1191,6 +1211,7 @@ export const {
useUpdateConfigurationMutation,
useRecoverMutation,
useBackupMutation,
useRestoreMutation,
useLockVmMutation,
useUnlockVmMutation,
useAddScheduledActionMutation,

View File

@ -31,6 +31,7 @@ const VM_RESIZE = 'vm.resize'
const VM_UPDATE = 'vm.update'
const VM_CONF_UPDATE = 'vm.updateconf'
const VM_RECOVER = 'vm.recover'
const VM_RESTORE = 'vm.restore'
const VM_INFO = 'vm.info'
const VM_MONITORING = 'vm.monitoring'
const VM_LOCK = 'vm.lock'
@ -75,6 +76,7 @@ const Actions = {
VM_UPDATE,
VM_CONF_UPDATE,
VM_RECOVER,
VM_RESTORE,
VM_INFO,
VM_MONITORING,
VM_LOCK,
@ -606,6 +608,28 @@ module.exports = {
},
},
},
[VM_RESTORE]: {
// inspected
httpMethod: POST,
params: {
id: {
from: postBody,
default: -1,
},
imageId: {
from: postBody,
default: -1,
},
incrementId: {
from: postBody,
default: -1,
},
diskId: {
from: postBody,
default: -1,
},
},
},
[VM_INFO]: {
// inspected
httpMethod: GET,