Răsfoiți Sursa

Merge pull request #333 from smiclea/CORWEB-179

Add ability to recreate a migration CORWEB-179
Dorin Paslaru 7 ani în urmă
părinte
comite
b5c0807f26

+ 36 - 15
src/components/organisms/EditReplica/EditReplica.jsx

@@ -20,6 +20,7 @@ import styled from 'styled-components'
 
 
 import providerStore, { getFieldChangeDestOptions } from '../../../stores/ProviderStore'
 import providerStore, { getFieldChangeDestOptions } from '../../../stores/ProviderStore'
 import replicaStore from '../../../stores/ReplicaStore'
 import replicaStore from '../../../stores/ReplicaStore'
+import migrationStore from '../../../stores/MigrationStore'
 import endpointStore from '../../../stores/EndpointStore'
 import endpointStore from '../../../stores/EndpointStore'
 
 
 import Button from '../../atoms/Button'
 import Button from '../../atoms/Button'
@@ -31,14 +32,14 @@ import WizardNetworks from '../../organisms/WizardNetworks'
 import WizardOptions from '../../organisms/WizardOptions'
 import WizardOptions from '../../organisms/WizardOptions'
 import WizardStorage from '../WizardStorage/WizardStorage'
 import WizardStorage from '../WizardStorage/WizardStorage'
 
 
-import type { MainItem } from '../../../types/MainItem'
+import type { MainItem, UpdateData } from '../../../types/MainItem'
 import type { NavigationItem } from '../../molecules/Panel'
 import type { NavigationItem } from '../../molecules/Panel'
 import type { Endpoint, StorageBackend, StorageMap } from '../../../types/Endpoint'
 import type { Endpoint, StorageBackend, StorageMap } from '../../../types/Endpoint'
 import type { Field } from '../../../types/Field'
 import type { Field } from '../../../types/Field'
 import type { Instance, Nic, Disk } from '../../../types/Instance'
 import type { Instance, Nic, Disk } from '../../../types/Instance'
 import type { Network, NetworkMap } from '../../../types/Network'
 import type { Network, NetworkMap } from '../../../types/Network'
 
 
-// import { storageProviders } from '../../../config'
+import { storageProviders } from '../../../config'
 import StyleProps from '../../styleUtils/StyleProps'
 import StyleProps from '../../styleUtils/StyleProps'
 
 
 const PanelContent = styled.div`
 const PanelContent = styled.div`
@@ -66,10 +67,12 @@ const Buttons = styled.div`
 `
 `
 
 
 type Props = {
 type Props = {
+  type?: 'replica' | 'migration',
   isOpen: boolean,
   isOpen: boolean,
   onRequestClose: () => void,
   onRequestClose: () => void,
   replica: MainItem,
   replica: MainItem,
   destinationEndpoint: Endpoint,
   destinationEndpoint: Endpoint,
+  sourceEndpoint: Endpoint,
   instancesDetails: Instance[],
   instancesDetails: Instance[],
   instancesDetailsLoading: boolean,
   instancesDetailsLoading: boolean,
   networks: Network[],
   networks: Network[],
@@ -100,7 +103,7 @@ class EditReplica extends React.Component<Props, State> {
       endpointStore.loadStorage(this.props.destinationEndpoint.id, {})
       endpointStore.loadStorage(this.props.destinationEndpoint.id, {})
     }
     }
 
 
-    providerStore.loadDestinationSchema(this.props.destinationEndpoint.type, 'replica').then(() => {
+    providerStore.loadDestinationSchema(this.props.destinationEndpoint.type, this.props.type || 'replica').then(() => {
       return providerStore.getDestinationOptions(this.props.destinationEndpoint.id, this.props.destinationEndpoint.type, undefined, true)
       return providerStore.getDestinationOptions(this.props.destinationEndpoint.id, this.props.destinationEndpoint.type, undefined, true)
     }).then(() => {
     }).then(() => {
       this.loadEnvDestinationOptions()
       this.loadEnvDestinationOptions()
@@ -108,8 +111,12 @@ class EditReplica extends React.Component<Props, State> {
   }
   }
 
 
   hasStorageMap() {
   hasStorageMap() {
-    return false
-    // return Boolean(storageProviders.find(p => p === this.props.destinationEndpoint.type))
+    if (this.props.type === 'replica') {
+      // storage mapping edit is not currently supported by the API
+      return false
+    }
+
+    return Boolean(storageProviders.find(p => p === this.props.destinationEndpoint.type))
   }
   }
 
 
   isUpdateDisabled() {
   isUpdateDisabled() {
@@ -196,14 +203,24 @@ class EditReplica extends React.Component<Props, State> {
   handleUpdateClick() {
   handleUpdateClick() {
     this.setState({ updateDisabled: true })
     this.setState({ updateDisabled: true })
 
 
-    replicaStore.update(this.props.replica, this.props.destinationEndpoint, {
+    let updateData: UpdateData = {
       destination: this.state.destinationData,
       destination: this.state.destinationData,
       network: this.state.selectedNetworks.length > 0 ? this.getSelectedNetworks() : [],
       network: this.state.selectedNetworks.length > 0 ? this.getSelectedNetworks() : [],
       storage: this.state.destinationData.default_storage || this.state.storageMap.length > 0 ? this.getStorageMap() : [],
       storage: this.state.destinationData.default_storage || this.state.storageMap.length > 0 ? this.getStorageMap() : [],
-    }).then(() => {
-      window.location.href = `/#/replica/executions/${this.props.replica.id}`
-      this.props.onRequestClose()
-    })
+    }
+    if (this.props.type === 'replica') {
+      replicaStore.update(this.props.replica, this.props.destinationEndpoint, updateData).then(() => {
+        window.location.href = `/#/replica/executions/${this.props.replica.id}`
+        this.props.onRequestClose()
+      })
+    } else {
+      migrationStore.recreate(this.props.replica, this.props.sourceEndpoint, this.props.destinationEndpoint, updateData)
+        .then((migration: MainItem) => {
+          migrationStore.clearDetails()
+          window.location.href = `/#/migration/${migration.id}`
+          this.props.onRequestClose()
+        })
+    }
   }
   }
 
 
   handleNetworkChange(sourceNic: Nic, targetNetwork: Network) {
   handleNetworkChange(sourceNic: Nic, targetNetwork: Network) {
@@ -279,8 +296,7 @@ class EditReplica extends React.Component<Props, State> {
     this.state.storageMap.forEach(mapping => {
     this.state.storageMap.forEach(mapping => {
       let fieldName = mapping.type === 'backend' ? 'storage_backend_identifier' : 'id'
       let fieldName = mapping.type === 'backend' ? 'storage_backend_identifier' : 'id'
       let existingMapping = storageMap.find(m => m.type === mapping.type &&
       let existingMapping = storageMap.find(m => m.type === mapping.type &&
-        // $FlowIgnore
-        m[fieldName] === mapping[fieldName]
+        m.source[fieldName] === mapping.source[fieldName]
       )
       )
       if (existingMapping) {
       if (existingMapping) {
         existingMapping.target = mapping.target
         existingMapping.target = mapping.target
@@ -296,12 +312,14 @@ class EditReplica extends React.Component<Props, State> {
     if (providerStore.destinationSchemaLoading || providerStore.destinationOptionsLoading) {
     if (providerStore.destinationSchemaLoading || providerStore.destinationOptionsLoading) {
       return this.renderLoading('Loading target options ...')
       return this.renderLoading('Loading target options ...')
     }
     }
+    let fields = this.props.type === 'replica' ? providerStore.destinationSchema.filter(f => !f.readOnly) :
+      providerStore.destinationSchema
 
 
     return (
     return (
       <WizardOptions
       <WizardOptions
         wizardType="replica-dest-options-edit"
         wizardType="replica-dest-options-edit"
         getFieldValue={(f, d) => this.getFieldValue(f, d)}
         getFieldValue={(f, d) => this.getFieldValue(f, d)}
-        fields={providerStore.destinationSchema.filter(f => !f.readOnly)}
+        fields={fields}
         hasStorageMap={this.hasStorageMap()}
         hasStorageMap={this.hasStorageMap()}
         onChange={(f, v) => { this.handleDestinationFieldChange(f, v) }}
         onChange={(f, v) => { this.handleDestinationFieldChange(f, v) }}
         storageBackends={endpointStore.storageBackends}
         storageBackends={endpointStore.storageBackends}
@@ -309,6 +327,7 @@ class EditReplica extends React.Component<Props, State> {
         columnStyle={{ marginRight: 0 }}
         columnStyle={{ marginRight: 0 }}
         fieldWidth={StyleProps.inputSizes.large.width}
         fieldWidth={StyleProps.inputSizes.large.width}
         onScrollableRef={ref => { this.scrollableRef = ref }}
         onScrollableRef={ref => { this.scrollableRef = ref }}
+        availableHeight={384}
       />
       />
     )
     )
   }
   }
@@ -373,7 +392,9 @@ class EditReplica extends React.Component<Props, State> {
             large
             large
             onClick={() => { this.handleUpdateClick() }}
             onClick={() => { this.handleUpdateClick() }}
             disabled={this.isUpdateDisabled()}
             disabled={this.isUpdateDisabled()}
-          >Update</Button>
+          >
+            {this.props.type === 'replica' ? 'Update' : 'Create'}
+          </Button>
         </Buttons>
         </Buttons>
       </PanelContent>
       </PanelContent>
     )
     )
@@ -403,7 +424,7 @@ class EditReplica extends React.Component<Props, State> {
     return (
     return (
       <Modal
       <Modal
         isOpen={this.props.isOpen}
         isOpen={this.props.isOpen}
-        title="Edit Replica"
+        title={`${this.props.type === 'replica' ? 'Edit Replica' : 'Recreate Migration'}`}
         onRequestClose={this.props.onRequestClose}
         onRequestClose={this.props.onRequestClose}
         contentStyle={{ width: '800px' }}
         contentStyle={{ width: '800px' }}
         onScrollableRef={() => this.scrollableRef}
         onScrollableRef={() => this.scrollableRef}

+ 4 - 1
src/components/organisms/WizardOptions/WizardOptions.jsx

@@ -87,6 +87,7 @@ type Props = {
   columnStyle?: { [string]: mixed },
   columnStyle?: { [string]: mixed },
   fieldWidth?: number,
   fieldWidth?: number,
   onScrollableRef?: (ref: HTMLElement) => void,
   onScrollableRef?: (ref: HTMLElement) => void,
+  availableHeight?: number,
 }
 }
 @observer
 @observer
 class WizardOptions extends React.Component<Props> {
 class WizardOptions extends React.Component<Props> {
@@ -209,7 +210,9 @@ class WizardOptions extends React.Component<Props> {
       }
       }
     })
     })
 
 
-    if (fields.length * 96 < window.innerHeight - 450) {
+    let availableHeight = this.props.availableHeight || (window.innerHeight - 450)
+
+    if (fields.length * 96 < availableHeight) {
       return (
       return (
         <Fields>
         <Fields>
           <OneColumn>
           <OneColumn>

+ 3 - 0
src/components/organisms/WizardSummary/WizardSummary.jsx

@@ -240,6 +240,9 @@ class WizardSummary extends React.Component<Props> {
   renderSourceOptionsSection() {
   renderSourceOptionsSection() {
     let data = this.props.data
     let data = this.props.data
     let type = this.props.wizardType.charAt(0).toUpperCase() + this.props.wizardType.substr(1)
     let type = this.props.wizardType.charAt(0).toUpperCase() + this.props.wizardType.substr(1)
+    if (!data.sourceOptions) {
+      return null
+    }
 
 
     return (
     return (
       <Section>
       <Section>

+ 73 - 13
src/components/pages/MigrationDetailsPage/MigrationDetailsPage.jsx

@@ -23,11 +23,13 @@ import DetailsPageHeader from '../../organisms/DetailsPageHeader'
 import DetailsContentHeader from '../../organisms/DetailsContentHeader'
 import DetailsContentHeader from '../../organisms/DetailsContentHeader'
 import MigrationDetailsContent from '../../organisms/MigrationDetailsContent'
 import MigrationDetailsContent from '../../organisms/MigrationDetailsContent'
 import AlertModal from '../../organisms/AlertModal'
 import AlertModal from '../../organisms/AlertModal'
+import EditReplica from '../../organisms/EditReplica'
 
 
 import migrationStore from '../../../stores/MigrationStore'
 import migrationStore from '../../../stores/MigrationStore'
 import userStore from '../../../stores/UserStore'
 import userStore from '../../../stores/UserStore'
 import endpointStore from '../../../stores/EndpointStore'
 import endpointStore from '../../../stores/EndpointStore'
 import notificationStore from '../../../stores/NotificationStore'
 import notificationStore from '../../../stores/NotificationStore'
+import networkStore from '../../../stores/NetworkStore'
 import instanceStore from '../../../stores/InstanceStore'
 import instanceStore from '../../../stores/InstanceStore'
 import { requestPollTimeout } from '../../../config'
 import { requestPollTimeout } from '../../../config'
 
 
@@ -42,45 +44,56 @@ type Props = {
 type State = {
 type State = {
   showDeleteMigrationConfirmation: boolean,
   showDeleteMigrationConfirmation: boolean,
   showCancelConfirmation: boolean,
   showCancelConfirmation: boolean,
+  showEditModal: boolean,
 }
 }
 @observer
 @observer
 class MigrationDetailsPage extends React.Component<Props, State> {
 class MigrationDetailsPage extends React.Component<Props, State> {
   state = {
   state = {
     showDeleteMigrationConfirmation: false,
     showDeleteMigrationConfirmation: false,
     showCancelConfirmation: false,
     showCancelConfirmation: false,
+    showEditModal: false,
   }
   }
 
 
-  pollInterval: IntervalID
+  pollTimeout: TimeoutID
 
 
   componentDidMount() {
   componentDidMount() {
     document.title = 'Migration Details'
     document.title = 'Migration Details'
 
 
     endpointStore.getEndpoints()
     endpointStore.getEndpoints()
     this.loadMigrationWithInstances(this.props.match.params.id)
     this.loadMigrationWithInstances(this.props.match.params.id)
-    this.pollInterval = setInterval(() => { this.pollData() }, requestPollTimeout)
+    this.pollData()
   }
   }
 
 
   componentWillReceiveProps(newProps: any) {
   componentWillReceiveProps(newProps: any) {
-    if (newProps.match.params.id !== this.props.match.params.id) {
-      this.loadMigrationWithInstances(newProps.match.params.id)
+    if (newProps.match.params.id === this.props.match.params.id) {
+      return
     }
     }
+
+    endpointStore.getEndpoints()
+    this.loadMigrationWithInstances(newProps.match.params.id)
   }
   }
 
 
   componentWillUnmount() {
   componentWillUnmount() {
     migrationStore.clearDetails()
     migrationStore.clearDetails()
-    clearInterval(this.pollInterval)
+    clearTimeout(this.pollTimeout)
   }
   }
 
 
   loadMigrationWithInstances(migrationId: string) {
   loadMigrationWithInstances(migrationId: string) {
     migrationStore.getMigration(migrationId, true).then(() => {
     migrationStore.getMigration(migrationId, true).then(() => {
-      if (migrationStore.migrationDetails) {
-        instanceStore.loadInstancesDetails(
-          migrationStore.migrationDetails.origin_endpoint_id,
-          // $FlowIgnore
-          migrationStore.migrationDetails.instances.map(n => { return { instance_name: n } }),
-          false, true
-        )
+      let details = migrationStore.migrationDetails
+      if (!details) {
+        return
       }
       }
+
+      networkStore.loadNetworks(details.destination_endpoint_id, details.destination_environment, {
+        quietError: true,
+      })
+      instanceStore.loadInstancesDetails(
+        details.origin_endpoint_id,
+        // $FlowIgnore
+        details.instances.map(n => { return { instance_name: n } }),
+        false, true
+      )
     })
     })
   }
   }
 
 
@@ -120,6 +133,12 @@ class MigrationDetailsPage extends React.Component<Props, State> {
     this.setState({ showCancelConfirmation: true })
     this.setState({ showCancelConfirmation: true })
   }
   }
 
 
+  handleRecreateClick() {
+    this.setState({
+      showEditModal: true,
+    })
+  }
+
   handleCloseCancelConfirmation() {
   handleCloseCancelConfirmation() {
     this.setState({ showCancelConfirmation: false })
     this.setState({ showCancelConfirmation: false })
   }
   }
@@ -139,18 +158,58 @@ class MigrationDetailsPage extends React.Component<Props, State> {
   }
   }
 
 
   pollData() {
   pollData() {
-    migrationStore.getMigration(this.props.match.params.id, false)
+    if (this.state.showEditModal) {
+      return
+    }
+    migrationStore.getMigration(this.props.match.params.id, false).then(() => {
+      this.pollTimeout = setTimeout(() => { this.pollData() }, requestPollTimeout)
+    })
   }
   }
 
 
   getStatus() {
   getStatus() {
     return migrationStore.migrationDetails && migrationStore.migrationDetails.status
     return migrationStore.migrationDetails && migrationStore.migrationDetails.status
   }
   }
 
 
+  closeEditModal() {
+    this.setState({ showEditModal: false }, () => {
+      this.pollData()
+    })
+  }
+
+  renderEditModal() {
+    let sourceEndpoint = endpointStore.endpoints
+      .find(e => migrationStore.migrationDetails && e.id === migrationStore.migrationDetails.origin_endpoint_id)
+    let destinationEndpoint = endpointStore.endpoints
+      .find(e => migrationStore.migrationDetails && e.id === migrationStore.migrationDetails.destination_endpoint_id)
+
+    if (!this.state.showEditModal || !migrationStore.migrationDetails || !destinationEndpoint || !sourceEndpoint) {
+      return null
+    }
+
+    return (
+      <EditReplica
+        type="migration"
+        isOpen
+        onRequestClose={() => { this.closeEditModal() }}
+        sourceEndpoint={sourceEndpoint}
+        replica={migrationStore.migrationDetails}
+        destinationEndpoint={destinationEndpoint}
+        instancesDetails={instanceStore.instancesDetails}
+        instancesDetailsLoading={instanceStore.loadingInstancesDetails}
+        networks={networkStore.networks}
+        networksLoading={networkStore.loading}
+      />
+    )
+  }
+
   render() {
   render() {
     let dropdownActions = [{
     let dropdownActions = [{
       label: 'Cancel',
       label: 'Cancel',
       disabled: this.getStatus() !== 'RUNNING',
       disabled: this.getStatus() !== 'RUNNING',
       action: () => { this.handleCancelMigrationClick() },
       action: () => { this.handleCancelMigrationClick() },
+    }, {
+      label: 'Recreate Migration',
+      action: () => { this.handleRecreateClick() },
     }, {
     }, {
       label: 'Delete Migration',
       label: 'Delete Migration',
       color: Palette.alert,
       color: Palette.alert,
@@ -198,6 +257,7 @@ class MigrationDetailsPage extends React.Component<Props, State> {
           onConfirmation={() => { this.handleCancelConfirmation() }}
           onConfirmation={() => { this.handleCancelConfirmation() }}
           onRequestClose={() => { this.handleCloseCancelConfirmation() }}
           onRequestClose={() => { this.handleCloseCancelConfirmation() }}
         />
         />
+        {this.renderEditModal()}
       </Wrapper>
       </Wrapper>
     )
     )
   }
   }

+ 4 - 1
src/components/pages/ReplicaDetailsPage/ReplicaDetailsPage.jsx

@@ -298,16 +298,19 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
   }
   }
 
 
   renderEditReplica() {
   renderEditReplica() {
+    let sourceEndpoint = endpointStore.endpoints
+      .find(e => replicaStore.replicaDetails && e.id === replicaStore.replicaDetails.origin_endpoint_id)
     let destinationEndpoint = endpointStore.endpoints
     let destinationEndpoint = endpointStore.endpoints
       .find(e => replicaStore.replicaDetails && e.id === replicaStore.replicaDetails.destination_endpoint_id)
       .find(e => replicaStore.replicaDetails && e.id === replicaStore.replicaDetails.destination_endpoint_id)
 
 
-    if (!this.state.showEditModal || !replicaStore.replicaDetails || !destinationEndpoint) {
+    if (!this.state.showEditModal || !replicaStore.replicaDetails || !destinationEndpoint || !sourceEndpoint) {
       return null
       return null
     }
     }
 
 
     return (
     return (
       <EditReplica
       <EditReplica
         isOpen
         isOpen
+        sourceEndpoint={sourceEndpoint}
         onRequestClose={() => { this.closeEditModal() }}
         onRequestClose={() => { this.closeEditModal() }}
         replica={replicaStore.replicaDetails}
         replica={replicaStore.replicaDetails}
         destinationEndpoint={destinationEndpoint}
         destinationEndpoint={destinationEndpoint}

+ 15 - 7
src/plugins/endpoint/default/OptionsSchemaPlugin.js

@@ -16,7 +16,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 
 import type { Field } from '../../../types/Field'
 import type { Field } from '../../../types/Field'
 import type { DestinationOption, StorageMap } from '../../../types/Endpoint'
 import type { DestinationOption, StorageMap } from '../../../types/Endpoint'
-import type { WizardData } from '../../../types/WizardData'
+import type { NetworkMap } from '../../../types/Network'
 import { executionOptions } from '../../../config'
 import { executionOptions } from '../../../config'
 
 
 const migrationImageOsTypes = ['windows', 'linux']
 const migrationImageOsTypes = ['windows', 'linux']
@@ -127,20 +127,28 @@ export default class OptionsSchemaParser {
     return env
     return env
   }
   }
 
 
-  static getNetworkMap(data: WizardData) {
+  static getNetworkMap(networkMappings: ?NetworkMap[]) {
     let payload = {}
     let payload = {}
-    if (data.networks && data.networks.length) {
-      data.networks.forEach(mapping => {
+    if (networkMappings && networkMappings.length) {
+      networkMappings.forEach(mapping => {
         payload[mapping.sourceNic.network_name] = mapping.targetNetwork.id
         payload[mapping.sourceNic.network_name] = mapping.targetNetwork.id
       })
       })
     }
     }
     return payload
     return payload
   }
   }
 
 
-  static getStorageMap(data: any, storageMap: StorageMap[]) {
+  static getStorageMap(defaultStorage: ?string, storageMap: ?StorageMap[]) {
+    if (!defaultStorage && !storageMap) {
+      return null
+    }
+
     let payload = {}
     let payload = {}
-    if (data && data.default_storage) {
-      payload.default = data.default_storage
+    if (defaultStorage) {
+      payload.default = defaultStorage
+    }
+
+    if (!storageMap) {
+      return payload
     }
     }
 
 
     storageMap.forEach(mapping => {
     storageMap.forEach(mapping => {

+ 70 - 0
src/sources/MigrationSource.js

@@ -16,9 +16,13 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 
 import moment from 'moment'
 import moment from 'moment'
 
 
+import { OptionsSchemaPlugin } from '../plugins/endpoint'
+
 import Api from '../utils/ApiCaller'
 import Api from '../utils/ApiCaller'
 import type { MainItem } from '../types/MainItem'
 import type { MainItem } from '../types/MainItem'
 import type { Field } from '../types/Field'
 import type { Field } from '../types/Field'
+import type { NetworkMap } from '../types/Network'
+import type { Endpoint, StorageMap } from '../types/Endpoint'
 
 
 import { servicesUrl } from '../config'
 import { servicesUrl } from '../config'
 
 
@@ -65,6 +69,72 @@ class MigrationSource {
     })
     })
   }
   }
 
 
+  static recreate(opts: {
+    sourceEndpoint: Endpoint,
+    destEndpoint: Endpoint,
+    instanceNames: string[],
+    destEnv: ?{ [string]: any },
+    updatedDestEnv: ?{ [string]: any },
+    sourceEnv?: ?{ [string]: any },
+    updatedSourceEnv?: ?{ [string]: any },
+    storageMappings: ?{ [string]: any },
+    updatedStorageMappings: ?StorageMap[],
+    networkMappings: ?{ [string]: any },
+    updatedNetworkMappings: ?NetworkMap[],
+  }): Promise<MainItem> {
+    const getValue = (fieldName: string): ?string => {
+      return (opts.updatedDestEnv && opts.updatedDestEnv[fieldName]) ||
+        (opts.destEnv && opts.destEnv[fieldName])
+    }
+
+    const sourceParser = OptionsSchemaPlugin[opts.sourceEndpoint.type] || OptionsSchemaPlugin.default
+    const destParser = OptionsSchemaPlugin[opts.destEndpoint.type] || OptionsSchemaPlugin.default
+    let payload: any = {}
+
+    payload.migration = {
+      origin_endpoint_id: opts.sourceEndpoint.id,
+      destination_endpoint_id: opts.destEndpoint.id,
+      destination_environment: {
+        ...opts.destEnv,
+        ...destParser.getDestinationEnv(opts.updatedDestEnv),
+      },
+      instances: opts.instanceNames,
+      notes: getValue('description') || '',
+    }
+
+    if (getValue('skip_os_morphing') != null) {
+      payload.migration.skip_os_morphing = getValue('skip_os_morphing')
+    }
+
+    if (opts.networkMappings || (opts.updatedNetworkMappings && opts.updatedNetworkMappings.length)) {
+      payload.migration.network_map = {
+        ...opts.networkMappings,
+        ...destParser.getNetworkMap(opts.updatedNetworkMappings),
+      }
+    }
+
+    if ((opts.storageMappings && Object.keys(opts.storageMappings).length)
+      || (opts.updatedStorageMappings && opts.updatedStorageMappings.length)) {
+      payload.migration.storage_mappings = {
+        ...opts.storageMappings,
+        ...destParser.getStorageMap(getValue('default_storage'), opts.updatedStorageMappings),
+      }
+    }
+
+    if (opts.sourceEnv || opts.updatedSourceEnv) {
+      payload.migration.source_environment = {
+        ...opts.sourceEnv,
+        ...sourceParser.getDestinationEnv(opts.updatedSourceEnv),
+      }
+    }
+
+    return Api.send({
+      url: `${servicesUrl.coriolis}/${Api.projectId}/migrations`,
+      method: 'POST',
+      data: payload,
+    }).then(response => response.data.migration)
+  }
+
   static cancel(migrationId: string): Promise<string> {
   static cancel(migrationId: string): Promise<string> {
     return Api.send({
     return Api.send({
       url: `${servicesUrl.coriolis}/${Api.projectId}/migrations/${migrationId}/actions`,
       url: `${servicesUrl.coriolis}/${Api.projectId}/migrations/${migrationId}/actions`,

+ 7 - 5
src/sources/WizardSource.js

@@ -25,15 +25,17 @@ import type { MainItem } from '../types/MainItem'
 
 
 class WizardSource {
 class WizardSource {
   static create(type: string, data: WizardData, storageMap: StorageMap[]): Promise<MainItem> {
   static create(type: string, data: WizardData, storageMap: StorageMap[]): Promise<MainItem> {
-    const parser = data.target ? OptionsSchemaPlugin[data.target.type] || OptionsSchemaPlugin.default : OptionsSchemaPlugin.default
+    const sourceParser = data.source ? OptionsSchemaPlugin[data.source.type] || OptionsSchemaPlugin.default : OptionsSchemaPlugin.default
+    const destParser = data.target ? OptionsSchemaPlugin[data.target.type] || OptionsSchemaPlugin.default : OptionsSchemaPlugin.default
     let payload = {}
     let payload = {}
+    let defaultStorage: ?string = data.destOptions && data.destOptions.default_storage
     payload[type] = {
     payload[type] = {
       origin_endpoint_id: data.source ? data.source.id : 'null',
       origin_endpoint_id: data.source ? data.source.id : 'null',
       destination_endpoint_id: data.target ? data.target.id : 'null',
       destination_endpoint_id: data.target ? data.target.id : 'null',
-      destination_environment: parser.getDestinationEnv(data.destOptions),
-      network_map: parser.getNetworkMap(data),
+      destination_environment: destParser.getDestinationEnv(data.destOptions),
+      network_map: destParser.getNetworkMap(data.networks),
       instances: data.selectedInstances ? data.selectedInstances.map(i => i.instance_name) : 'null',
       instances: data.selectedInstances ? data.selectedInstances.map(i => i.instance_name) : 'null',
-      storage_mappings: parser.getStorageMap(data.destOptions, storageMap),
+      storage_mappings: destParser.getStorageMap(defaultStorage, storageMap),
       notes: data.destOptions ? data.destOptions.description || '' : '',
       notes: data.destOptions ? data.destOptions.description || '' : '',
     }
     }
 
 
@@ -42,7 +44,7 @@ class WizardSource {
     }
     }
 
 
     if (data.sourceOptions) {
     if (data.sourceOptions) {
-      payload[type].source_environment = parser.getDestinationEnv(data.sourceOptions)
+      payload[type].source_environment = sourceParser.getDestinationEnv(data.sourceOptions)
     }
     }
 
 
     return Api.send({
     return Api.send({

+ 17 - 1
src/stores/MigrationStore.js

@@ -16,8 +16,9 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 
 import { observable, action } from 'mobx'
 import { observable, action } from 'mobx'
 
 
-import type { MainItem } from '../types/MainItem'
+import type { MainItem, UpdateData } from '../types/MainItem'
 import type { Field } from '../types/Field'
 import type { Field } from '../types/Field'
+import type { Endpoint } from '../types/Endpoint'
 import notificationStore from '../stores/NotificationStore'
 import notificationStore from '../stores/NotificationStore'
 import MigrationSource from '../sources/MigrationSource'
 import MigrationSource from '../sources/MigrationSource'
 
 
@@ -51,6 +52,20 @@ class MigrationStore {
     })
     })
   }
   }
 
 
+  @action recreate(migration: MainItem, sourceEndpoint: Endpoint, destEndpoint: Endpoint, updateData: UpdateData): Promise<MainItem> {
+    return MigrationSource.recreate({
+      sourceEndpoint,
+      destEndpoint,
+      instanceNames: migration.instances,
+      destEnv: migration.destination_environment,
+      updatedDestEnv: updateData.destination,
+      storageMappings: migration.storage_mappings,
+      updatedStorageMappings: updateData.storage,
+      networkMappings: migration.network_map,
+      updatedNetworkMappings: updateData.network,
+    })
+  }
+
   @action getMigration(migrationId: string, showLoading: boolean) {
   @action getMigration(migrationId: string, showLoading: boolean) {
     this.detailsLoading = showLoading
     this.detailsLoading = showLoading
 
 
@@ -97,6 +112,7 @@ class MigrationStore {
 
 
   @action clearDetails() {
   @action clearDetails() {
     this.detailsLoading = true
     this.detailsLoading = true
+    this.migrationDetails = null
   }
   }
 }
 }
 
 

+ 2 - 2
src/types/WizardData.js

@@ -19,8 +19,8 @@ import type { NetworkMap } from './Network'
 import type { Endpoint } from './Endpoint'
 import type { Endpoint } from './Endpoint'
 
 
 export type WizardData = {
 export type WizardData = {
-  destOptions?: ?{ [string]: mixed },
-  sourceOptions?: ?{ [string]: mixed },
+  destOptions?: ?{ [string]: any },
+  sourceOptions?: ?{ [string]: any },
   selectedInstances?: ?Instance[],
   selectedInstances?: ?Instance[],
   networks?: ?NetworkMap[],
   networks?: ?NetworkMap[],
   source?: ?Endpoint,
   source?: ?Endpoint,