Browse Source

Add ability to update a replica

Sergiu Miclea 7 years ago
parent
commit
50aaccc707
30 changed files with 911 additions and 207 deletions
  1. 2 0
      src/components/atoms/ToggleButtonBar/ToggleButtonBar.jsx
  2. 2 2
      src/components/molecules/MainDetailsTable/MainDetailsTable.jsx
  3. 15 8
      src/components/molecules/Modal/Modal.jsx
  4. BIN
      src/components/molecules/Modal/images/header-background-wide.png
  5. 90 0
      src/components/molecules/Panel/Panel.jsx
  6. 6 0
      src/components/molecules/Panel/package.json
  7. 38 0
      src/components/molecules/Panel/story.jsx
  8. 7 3
      src/components/molecules/WizardOptionsField/WizardOptionsField.jsx
  9. 422 0
      src/components/organisms/EditReplica/EditReplica.jsx
  10. 6 0
      src/components/organisms/EditReplica/package.json
  11. 10 12
      src/components/organisms/MainDetails/MainDetails.jsx
  12. 3 4
      src/components/organisms/WizardNetworks/WizardNetworks.jsx
  13. 43 22
      src/components/organisms/WizardOptions/WizardOptions.jsx
  14. 40 42
      src/components/organisms/WizardPageContent/WizardPageContent.jsx
  15. 3 4
      src/components/organisms/WizardStorage/WizardStorage.jsx
  16. 4 5
      src/components/pages/AssessmentDetailsPage/AssessmentDetailsPage.jsx
  17. 0 1
      src/components/pages/EndpointsPage/EndpointsPage.jsx
  18. 47 0
      src/components/pages/ReplicaDetailsPage/ReplicaDetailsPage.jsx
  19. 15 40
      src/components/pages/WizardPage/WizardPage.jsx
  20. 2 2
      src/config.js
  21. 28 26
      src/plugins/endpoint/default/OptionsSchemaPlugin.js
  22. 1 1
      src/sources/ProviderSource.js
  23. 30 1
      src/sources/ReplicaSource.js
  24. 2 2
      src/sources/WizardSource.js
  25. 6 0
      src/stores/EndpointStore.js
  26. 60 13
      src/stores/ProviderStore.js
  27. 13 8
      src/stores/ReplicaStore.js
  28. 1 0
      src/types/Field.js
  29. 14 10
      src/types/MainItem.js
  30. 1 1
      src/utils/LabelDictionary.js

+ 2 - 0
src/components/atoms/ToggleButtonBar/ToggleButtonBar.jsx

@@ -58,6 +58,7 @@ type Props = {
   onChange?: (item: ItemType) => void,
   className?: string,
   'data-test-id'?: string,
+  style?: { [string]: mixed },
 }
 @observer
 class ToggleButtonBar extends React.Component<Props> {
@@ -70,6 +71,7 @@ class ToggleButtonBar extends React.Component<Props> {
       <Wrapper
         data-test-id={this.props['data-test-id'] || 'toggleButtonBar-wrapper'}
         className={this.props.className}
+        style={this.props.style}
       >
         {this.props.items.map(item => {
           return (

+ 2 - 2
src/components/molecules/MainDetailsTable/MainDetailsTable.jsx

@@ -268,8 +268,8 @@ class MainDetailsTable extends React.Component<Props, State> {
 
   renderNetworks(instance: Instance) {
     let destinationNetworkMap = null
-    if (this.props.item && this.props.item.destination_environment.network_map) {
-      destinationNetworkMap = this.props.item.destination_environment.network_map
+    if (this.props.item && this.props.item.network_map) {
+      destinationNetworkMap = this.props.item.network_map
     }
     if (destinationNetworkMap == null) {
       return null

+ 15 - 8
src/components/molecules/Modal/Modal.jsx

@@ -24,15 +24,18 @@ import StyleProps from '../../styleUtils/StyleProps'
 import KeyboardManager from '../../../utils/KeyboardManager'
 
 import headerBackground from './images/header-background.png'
+import headerBackgroundWide from './images/header-background-wide.png'
+
+let headerHeight = 48
 
 const Title = styled.div`
-  height: 48px;
+  height: ${headerHeight}px;
   font-size: 24px;
   font-weight: ${StyleProps.fontWeights.light};
   text-align: center;
   line-height: 48px;
   color: white;
-  background: url('${headerBackground}') center/contain no-repeat;
+  background: url('${props => props.wide ? headerBackgroundWide : headerBackground}') center/contain no-repeat;
 `
 
 type Props = {
@@ -44,6 +47,8 @@ type Props = {
   topBottomMargin: number,
   title: string,
   componentRef?: (ref: any) => void,
+  onScrollableRef?: () => HTMLElement,
+  fixedHeight?: number,
 }
 @observer
 class NewModal extends React.Component<Props> {
@@ -126,12 +131,14 @@ class NewModal extends React.Component<Props> {
     if (!contentNode || !pageNode) {
       return
     }
-    let scrollableNode = this.scrollableRef || contentNode
+    let scrollableRef = this.props.onScrollableRef && this.props.onScrollableRef()
+    let scrollableNode = scrollableRef || this.scrollableRef || contentNode
     let scrollTop = scrollableNode.scrollTop
     contentNode.style.height = 'auto'
+    let contentDesiredHeight = this.props.fixedHeight ? this.props.fixedHeight + headerHeight : contentNode.offsetHeight
     let left = (pageNode.offsetWidth / 2) - (contentNode.offsetWidth / 2)
-    let top = (pageNode.offsetHeight / 2) - (contentNode.offsetHeight / 2)
-    let height = 'auto'
+    let top = (pageNode.offsetHeight / 2) - (contentDesiredHeight / 2)
+    let height = this.props.fixedHeight ? `${this.props.fixedHeight + headerHeight}px` : 'auto'
 
     if (top < this.props.topBottomMargin) {
       top = this.props.topBottomMargin
@@ -146,12 +153,12 @@ class NewModal extends React.Component<Props> {
     scrollableNode.scrollTop = scrollTop + scrollOffset
   }
 
-  renderTitle() {
+  renderTitle(contentWidth: string) {
     if (!this.props.title) {
       return null
     }
 
-    return <Title data-test-id="modal-title">{this.props.title}</Title>
+    return <Title data-test-id="modal-title" wide={contentWidth === '800px'}>{this.props.title}</Title>
   }
 
   render() {
@@ -202,7 +209,7 @@ class NewModal extends React.Component<Props> {
         onRequestClose={this.props.onRequestClose}
         onAfterOpen={() => { this.handleModalOpen() }}
       >
-        {this.renderTitle()}
+        {this.renderTitle(modalStyle.content.width)}
         {children}
       </Modal>
     )

BIN
src/components/molecules/Modal/images/header-background-wide.png


+ 90 - 0
src/components/molecules/Panel/Panel.jsx

@@ -0,0 +1,90 @@
+/*
+Copyright (C) 2019  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import * as React from 'react'
+import styled, { css } from 'styled-components'
+import { observer } from 'mobx-react'
+import Palette from '../../styleUtils/Palette'
+
+const Wrapper = styled.div`
+  display: flex;
+  min-height: 0;
+  flex-grow: 1;
+`
+const Navigation = styled.div`
+  width: 224px;
+  background-image: linear-gradient(rgba(200, 204, 215, 0.54), rgba(164, 170, 181, 0.54));
+`
+const NavigationItemDiv = styled.div`
+  height: 47px;
+  border-bottom: 1px solid ${Palette.grayscale[2]};
+  color: black;
+  display: flex;
+  align-items: center;
+  padding: 0 24px;
+  font-size: 18px;
+  cursor: pointer;
+  ${props => props.selected ? css`
+    color: ${Palette.primary};
+    background: ${Palette.grayscale[2]};
+    cursor: default;
+  ` : ''}
+`
+const Content = styled.div`
+  width: 576px;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+`
+
+export type NavigationItem = {
+  label: string,
+  value: string,
+}
+
+type Props = {
+  navigationItems: NavigationItem[],
+  content: React.Node,
+  selectedValue: string,
+  onChange: (item: NavigationItem) => void,
+  style?: any,
+}
+
+@observer
+class Panel extends React.Component<Props> {
+  handleItemClick(item: NavigationItem) {
+    if (item.value !== this.props.selectedValue) {
+      this.props.onChange(item)
+    }
+  }
+
+  render() {
+    return (
+      <Wrapper style={this.props.style}>
+        <Navigation>{this.props.navigationItems.map(item => (
+          <NavigationItemDiv
+            key={item.value}
+            selected={this.props.selectedValue === item.value}
+            onClick={() => { this.handleItemClick(item) }}
+          >{item.label}</NavigationItemDiv>
+        ))}</Navigation>
+        <Content>{this.props.content}</Content>
+      </Wrapper>
+    )
+  }
+}
+
+export default Panel

+ 6 - 0
src/components/molecules/Panel/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "Panel",
+  "version": "0.0.0",
+  "private": true,
+  "main": "./Panel.jsx"
+}

+ 38 - 0
src/components/molecules/Panel/story.jsx

@@ -0,0 +1,38 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import Panel from '.'
+
+const navigationItems = [
+  { value: 'dest_options', label: 'Destination Options' },
+  { value: 'network', label: 'Network Mapping' },
+  { value: 'storage', label: 'Storage Mapping' },
+]
+
+storiesOf('Panel', module)
+  .add('default', () => (
+    <div style={{ width: '800px', height: '560px' }}>
+      <Panel
+        content={<div>Content</div>}
+        navigationItems={navigationItems}
+        selectedValue="network"
+        onChange={item => console.log(item, 'clicked')}
+      />
+    </div>
+  ))
+

+ 7 - 3
src/components/molecules/WizardOptionsField/WizardOptionsField.jsx

@@ -46,10 +46,11 @@ const Wrapper = styled.div`
 `
 const Label = styled.div`
   font-weight: ${StyleProps.fontWeights.medium};
+  margin-bottom: 8px;
   ${props => getDirection(props) === 'column' ? 'margin-bottom: 8px;' : ''}
 `
 const LabelText = styled.span`
-  margin-right: 24px;
+  margin-right: ${props => props.noMargin ? 0 : 24}px;
 `
 const Asterisk = styled.div`
   ${StyleProps.exactSize('16px')}
@@ -71,6 +72,7 @@ type Props = {
   width?: number,
   skipNullValue?: boolean,
   'data-test-id'?: string,
+  style?: { [string]: mixed },
 }
 @observer
 class WizardOptionsField extends React.Component<Props> {
@@ -79,6 +81,7 @@ class WizardOptionsField extends React.Component<Props> {
       <Switch
         width="112px"
         justifyContent="flex-end"
+        height={16}
         triState={propss.triState}
         checked={this.props.value}
         onChange={checked => { this.props.onChange(checked) }}
@@ -92,7 +95,7 @@ class WizardOptionsField extends React.Component<Props> {
   renderTextInput() {
     return (
       <TextInput
-        width={`${StyleProps.inputSizes.wizard.width}px`}
+        width={`${this.props.width || StyleProps.inputSizes.wizard.width}px`}
         value={this.props.value}
         onChange={e => { this.props.onChange(e.target.value) }}
         placeholder={LabelDictionary.get(this.props.name)}
@@ -219,7 +222,7 @@ class WizardOptionsField extends React.Component<Props> {
     let description = LabelDictionary.getDescription(this.props.name)
     return (
       <Label>
-        <LabelText data-test-id="wOptionsField-label">
+        <LabelText data-test-id="wOptionsField-label" noMargin={!description && !this.props.required}>
           {LabelDictionary.get(this.props.name)}
         </LabelText>
         {description ? <InfoIcon text={description} marginLeft={-20} /> : null}
@@ -240,6 +243,7 @@ class WizardOptionsField extends React.Component<Props> {
         data-test-id={this.props['data-test-id'] || 'wOptionsField-wrapper'}
         type={this.props.type}
         className={this.props.className}
+        style={this.props.style}
       >
         {this.renderLabel()}
         {field}

+ 422 - 0
src/components/organisms/EditReplica/EditReplica.jsx

@@ -0,0 +1,422 @@
+/*
+Copyright (C) 2019  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+
+import providerStore, { getFieldChangeDestOptions } from '../../../stores/ProviderStore'
+import replicaStore from '../../../stores/ReplicaStore'
+import endpointStore from '../../../stores/EndpointStore'
+
+import Button from '../../atoms/Button'
+import StatusImage from '../../atoms/StatusImage'
+import Modal from '../../molecules/Modal'
+import Panel from '../../molecules/Panel'
+import { isOptionsPageValid } from '../../organisms/WizardPageContent'
+import WizardNetworks from '../../organisms/WizardNetworks'
+import WizardOptions from '../../organisms/WizardOptions'
+import WizardStorage from '../WizardStorage/WizardStorage'
+
+import type { MainItem } from '../../../types/MainItem'
+import type { NavigationItem } from '../../molecules/Panel'
+import type { Endpoint, StorageBackend, StorageMap } from '../../../types/Endpoint'
+import type { Field } from '../../../types/Field'
+import type { Instance, Nic, Disk } from '../../../types/Instance'
+import type { Network, NetworkMap } from '../../../types/Network'
+
+import { storageProviders } from '../../../config'
+import StyleProps from '../../styleUtils/StyleProps'
+
+const PanelContent = styled.div`
+  padding: 32px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  flex-grow: 1;
+`
+const LoadingWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin: 32px 0;
+`
+const LoadingText = styled.div`
+  font-size: 18px;
+  margin-top: 32px;
+`
+const Buttons = styled.div`
+  margin-top: 32px;
+  display: flex;
+  flex-shrink: 0;
+  justify-content: space-between;
+`
+const Empty = styled.div``
+
+type Props = {
+  isOpen: boolean,
+  onRequestClose: () => void,
+  replica: MainItem,
+  destinationEndpoint: Endpoint,
+  instancesDetails: Instance[],
+  instancesDetailsLoading: boolean,
+  networks: Network[],
+}
+type State = {
+  selectedPanel: string,
+  destinationData: any,
+  updateDisabled: boolean,
+  selectedNetworks: NetworkMap[],
+  storageMap: StorageMap[],
+}
+
+@observer
+class EditReplica extends React.Component<Props, State> {
+  state = {
+    selectedPanel: 'dest_options',
+    destinationData: {},
+    updateDisabled: false,
+    selectedNetworks: [],
+    storageMap: [],
+  }
+
+  scrollableRef: HTMLElement
+
+  componentWillMount() {
+    if (this.hasStorageMap()) {
+      endpointStore.loadStorage(this.props.destinationEndpoint.id, {})
+    }
+
+    providerStore.loadDestinationSchema(this.props.destinationEndpoint.type, 'replica').then(() => {
+      return providerStore.getDestinationOptions(this.props.destinationEndpoint.id, this.props.destinationEndpoint.type)
+    }).then(() => {
+      this.loadEnvDestinationOptions()
+    })
+  }
+
+  hasStorageMap() {
+    return Boolean(storageProviders.find(p => p === this.props.destinationEndpoint.type))
+  }
+
+  isUpdateDisabled() {
+    let isLoadingDestOptions = this.state.selectedPanel === 'dest_options'
+      && (providerStore.destinationSchemaLoading || providerStore.destinationOptionsLoading)
+    let isLoadingNetwork = this.state.selectedPanel === 'network_mapping' && this.props.instancesDetailsLoading
+    let isLoadingStorage = this.state.selectedPanel === 'storage_mapping'
+      && (this.props.instancesDetailsLoading || endpointStore.storageLoading)
+    return this.state.updateDisabled || isLoadingDestOptions || isLoadingNetwork || isLoadingStorage
+  }
+
+  parseReplicaData() {
+    let data = {}
+    let destEnv = this.props.replica.destination_environment
+    if (!destEnv) {
+      return data
+    }
+    Object.keys(destEnv).forEach(key => {
+      if (destEnv[key] && typeof destEnv[key] === 'object') {
+        Object.keys(destEnv[key]).forEach(subkey => {
+          let destParent: any = destEnv[key]
+          if (destParent[subkey]) {
+            data[`${key}/${subkey}`] = destParent[subkey]
+          }
+        })
+      } else {
+        data[key] = destEnv[key]
+      }
+    })
+    return data
+  }
+
+  loadEnvDestinationOptions(field?: Field) {
+    let envData = getFieldChangeDestOptions({
+      provider: this.props.destinationEndpoint.type,
+      destSchema: providerStore.destinationSchema,
+      data: {
+        ...this.parseReplicaData(),
+        ...this.state.destinationData,
+      },
+      field,
+    })
+
+    if (envData) {
+      providerStore.getDestinationOptions(this.props.destinationEndpoint.id, this.props.destinationEndpoint.type, envData)
+    }
+  }
+
+  validateDestinationOptions() {
+    let isValid = isOptionsPageValid({
+      ...this.parseReplicaData(),
+      ...this.state.destinationData,
+    }, providerStore.destinationSchema)
+
+    this.setState({ updateDisabled: !isValid })
+  }
+
+  handlePanelChange(panel: string) {
+    this.setState({ selectedPanel: panel })
+  }
+
+  handleDestinationFieldChange(field: Field, value: any) {
+    let destinationData = { ...this.state.destinationData }
+    if (field.type === 'array') {
+      let oldValues: string[] = destinationData[field.name] || []
+      if (oldValues.find(v => v === value)) {
+        destinationData[field.name] = oldValues.filter(v => v !== value)
+      } else {
+        destinationData[field.name] = [...oldValues, value]
+      }
+    } else {
+      destinationData[field.name] = value
+    }
+
+    this.setState({ destinationData }, () => {
+      if (field.type !== 'string' || field.enum) {
+        this.loadEnvDestinationOptions(field)
+      }
+
+      this.validateDestinationOptions()
+    })
+  }
+
+  handleUpdateClick() {
+    this.setState({ updateDisabled: true })
+
+    replicaStore.update(this.props.replica, this.props.destinationEndpoint, {
+      destination: this.state.destinationData,
+      network: this.state.selectedNetworks.length > 0 ? this.getSelectedNetworks() : [],
+      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()
+    })
+  }
+
+  handleNetworkChange(sourceNic: Nic, targetNetwork: Network) {
+    this.setState({
+      selectedNetworks: [...this.state.selectedNetworks, { sourceNic, targetNetwork }],
+    })
+  }
+
+  handleStorageChange(source: Disk, target: StorageBackend, type: 'backend' | 'disk') {
+    let diskFieldName = type === 'backend' ? 'storage_backend_identifier' : 'id'
+    let storageMap = this.state.storageMap
+      .filter(n => n.type !== type || n.source[diskFieldName] !== source[diskFieldName])
+    storageMap.push({ source, target, type })
+
+    this.setState({ storageMap })
+  }
+
+  getFieldValue(fieldName: string, defaultValue: any) {
+    if (this.state.destinationData[fieldName] === undefined) {
+      let replicaData = this.parseReplicaData()
+      if (replicaData[fieldName] !== undefined) {
+        return replicaData[fieldName]
+      }
+      return defaultValue
+    }
+    return this.state.destinationData[fieldName]
+  }
+
+  getSelectedNetworks(): NetworkMap[] {
+    let selectedNetworks: NetworkMap[] = []
+    let networkMap = this.props.replica.network_map
+
+    if (networkMap) {
+      Object.keys(networkMap).forEach(sourceNetworkName => {
+        let network = this.props.networks.find(n => n.name === networkMap[sourceNetworkName])
+        if (!network) {
+          return
+        }
+        selectedNetworks.push({
+          sourceNic: { id: '', network_name: sourceNetworkName, mac_address: '', network_id: '' },
+          targetNetwork: network,
+        })
+      })
+    }
+    selectedNetworks = selectedNetworks.map(mapping => {
+      let updatedMapping = this.state.selectedNetworks.find(m => m.sourceNic.network_name === mapping.sourceNic.network_name)
+      return updatedMapping || mapping
+    })
+    return selectedNetworks
+  }
+
+  getStorageMap(): StorageMap[] {
+    let storageMap: StorageMap[] = []
+    let currentStorage = this.props.replica.storage_mappings || {}
+    let buildStorageMap = (type: 'backend' | 'disk', mapping: any) => {
+      return {
+        type,
+        source: { storage_backend_identifier: mapping.source, id: mapping.disk_id },
+        target: { name: mapping.destination, id: mapping.destination },
+      }
+    }
+    let backendMappings = currentStorage.backend_mappings || []
+    backendMappings.forEach(mapping => {
+      storageMap.push(buildStorageMap('backend', mapping))
+    })
+
+    let diskMappings = currentStorage.disk_mappings || []
+    diskMappings.forEach(mapping => {
+      storageMap.push(buildStorageMap('disk', mapping))
+    })
+
+    this.state.storageMap.forEach(mapping => {
+      let fieldName = mapping.type === 'backend' ? 'storage_backend_identifier' : 'id'
+      let existingMapping = storageMap.find(m => m.type === mapping.type &&
+        // $FlowIgnore
+        m[fieldName] === mapping[fieldName]
+      )
+      if (existingMapping) {
+        existingMapping.target = mapping.target
+      } else {
+        storageMap.push(mapping)
+      }
+    })
+
+    return storageMap
+  }
+
+  renderDestinationOptions() {
+    if (providerStore.destinationSchemaLoading || providerStore.destinationOptionsLoading) {
+      return this.renderLoading('Loading destination options ...')
+    }
+
+    return (
+      <WizardOptions
+        wizardType="dest-edit"
+        getFieldValue={(f, d) => this.getFieldValue(f, d)}
+        fields={providerStore.destinationSchema.filter(f => !f.readOnly)}
+        hasStorageMap={this.hasStorageMap()}
+        onChange={(f, v) => { this.handleDestinationFieldChange(f, v) }}
+        storageBackends={endpointStore.storageBackends}
+        useAdvancedOptions
+        columnStyle={{ marginRight: 0 }}
+        fieldWidth={StyleProps.inputSizes.large.width}
+        onScrollableRef={ref => { this.scrollableRef = ref }}
+      />
+    )
+  }
+
+  renderStorageMapping() {
+    if (!this.hasStorageMap()) {
+      return <Empty>The destination endpoint does not have storage listing.</Empty>
+    }
+
+    if (this.props.instancesDetailsLoading) {
+      return this.renderLoading('Loading instances details ...')
+    }
+    if (endpointStore.storageLoading) {
+      return this.renderLoading('Loading storage ...')
+    }
+
+    return (
+      <WizardStorage
+        storageBackends={endpointStore.storageBackends}
+        instancesDetails={this.props.instancesDetails}
+        storageMap={this.getStorageMap()}
+        defaultStorage={this.getFieldValue('default_storage')}
+        onChange={(s, t, type) => { this.handleStorageChange(s, t, type) }}
+      />
+    )
+  }
+
+  renderNetworkMapping() {
+    return (
+      <WizardNetworks
+        instancesDetails={this.props.instancesDetails}
+        loadingInstancesDetails={this.props.instancesDetailsLoading}
+        networks={this.props.networks}
+        loading={false}
+        onChange={(nic, network) => { this.handleNetworkChange(nic, network) }}
+        selectedNetworks={this.getSelectedNetworks()}
+      />
+    )
+  }
+
+  renderContent() {
+    let content = null
+    switch (this.state.selectedPanel) {
+      case 'dest_options':
+        content = this.renderDestinationOptions()
+        break
+      case 'network_mapping':
+        content = this.renderNetworkMapping()
+        break
+      case 'storage_mapping':
+        content = this.renderStorageMapping()
+        break
+      default:
+        content = null
+    }
+    return (
+      <PanelContent>
+        {content}
+        <Buttons>
+          <Button
+            large
+            onClick={this.props.onRequestClose}
+            secondary
+          >Cancel</Button>
+          <Button
+            large
+            onClick={() => { this.handleUpdateClick() }}
+            disabled={this.isUpdateDisabled()}
+          >Update</Button>
+        </Buttons>
+      </PanelContent>
+    )
+  }
+
+  renderLoading(message: string) {
+    let loadingMessage = message || 'Loading ...'
+
+    return (
+      <LoadingWrapper>
+        <StatusImage loading />
+        <LoadingText>{loadingMessage}</LoadingText>
+      </LoadingWrapper>
+    )
+  }
+
+  render() {
+    const navigationItems: NavigationItem[] = [
+      { value: 'dest_options', label: 'Destination Options' },
+      { value: 'network_mapping', label: 'Network Mapping' },
+      { value: 'storage_mapping', label: 'Storage Mapping' },
+    ]
+
+    return (
+      <Modal
+        isOpen={this.props.isOpen}
+        title="Edit Replica"
+        onRequestClose={this.props.onRequestClose}
+        contentStyle={{ width: '800px' }}
+        onScrollableRef={() => this.scrollableRef}
+        fixedHeight={512}
+      >
+        <Panel
+          navigationItems={navigationItems}
+          content={this.renderContent()}
+          onChange={navItem => { this.handlePanelChange(navItem.value) }}
+          selectedValue={this.state.selectedPanel}
+        />
+      </Modal>
+    )
+  }
+}
+
+export default EditReplica

+ 6 - 0
src/components/organisms/EditReplica/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "EditReplica",
+  "version": "0.0.0",
+  "private": true,
+  "main": "./EditReplica.jsx"
+}

+ 10 - 12
src/components/organisms/MainDetails/MainDetails.jsx

@@ -162,25 +162,26 @@ class MainDetails extends React.Component<Props> {
   }
 
   getNetworks() {
-    if (!this.props.item || !this.props.item.destination_environment || !this.props.item.destination_environment.network_map) {
+    let networkMap = this.props.item && this.props.item.network_map
+    if (!networkMap) {
       return null
     }
     let networks = []
-    Object.keys(this.props.item.destination_environment.network_map).forEach(key => {
+    Object.keys(networkMap).forEach(key => {
       let newItem
-      if (this.props.item && typeof this.props.item.destination_environment.network_map[key] === 'object') {
+      if (typeof networkMap[key] === 'string') {
         newItem = [
-          this.props.item.destination_environment.network_map[key].source_network,
+          key,
           this.getConnectedVms(key),
-          // $FlowIssue
-          this.props.item.destination_environment.network_map[key].destination_network,
+          networkMap[key],
           'Existing network',
         ]
       } else {
         newItem = [
-          key,
+          networkMap[key].source_network,
           this.getConnectedVms(key),
-          this.props.item ? this.props.item.destination_environment.network_map[key] : '-',
+          // $FlowIssue
+          networkMap[key].destination_network,
           'Existing network',
         ]
       }
@@ -262,10 +263,7 @@ class MainDetails extends React.Component<Props> {
 
     return (
       <PropertiesTable>
-        {properties.map(prop => {
-          if (prop == null) {
-            return null
-          }
+        {properties.filter(Boolean).filter(p => p.value != null && p.value !== '').map(prop => {
           return (
             <PropertyRow key={prop.label}>
               <PropertyName>{prop.label}</PropertyName>

+ 3 - 4
src/components/organisms/WizardNetworks/WizardNetworks.jsx

@@ -61,8 +61,7 @@ const Nic = styled.div`
   }
 `
 const NetworkImage = styled.div`
-  width: 48px;
-  height: 48px;
+  ${StyleProps.exactSize('48px')}
   background: url('${networkImage}') center no-repeat;
   margin-right: 16px;
 `
@@ -78,8 +77,8 @@ const NetworkSubtitle = styled.div`
   margin-top: 1px;
 `
 const ArrowImage = styled.div`
-  width: 32px;
-  height: 16px;
+  ${StyleProps.exactWidth('32px')}
+  ${StyleProps.exactHeight('16px')}
   background: url('${arrowImage}') center no-repeat;
   flex-grow: 1;
   margin-right: 16px;

+ 43 - 22
src/components/organisms/WizardOptions/WizardOptions.jsx

@@ -30,20 +30,30 @@ import type { StorageBackend } from '../../../types/Endpoint'
 
 import { executionOptions } from '../../../config'
 
-const Wrapper = styled.div``
-const Options = styled.div``
+const Wrapper = styled.div`
+  display: flex;
+  min-height: 0;
+  flex-direction: column;
+`
+const Options = styled.div`
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+`
 const Fields = styled.div`
-  margin-top: 46px;
   display: flex;
+  overflow: auto;
+  justify-content: space-between;
 `
 const OneColumn = styled.div``
 const Column = styled.div`
   ${props => props.left ? 'margin-right: 160px;' : ''}
+  margin-top: -16px;
 `
 const WizardOptionsFieldStyled = styled(WizardOptionsField)`
-  width: ${StyleProps.inputSizes.wizard.width}px;
+  width: ${props => props.width || StyleProps.inputSizes.wizard.width}px;
   justify-content: space-between;
-  margin-bottom: 39px;
+  margin-top: 16px;
 `
 const LoadingWrapper = styled.div`
   margin-top: 32px;
@@ -56,17 +66,27 @@ const LoadingText = styled.div`
   font-size: 18px;
 `
 
+export const shouldRenderField = (field: Field) => {
+  return (field.type !== 'array' || (field.enum && field.enum.length && field.enum.length > 0)) &&
+    (field.type !== 'integer' || (field.minimum && field.maximum)) &&
+    (field.type !== 'object' || field.properties)
+}
+
 type Props = {
   fields: Field[],
-  selectedInstances: ?Instance[],
-  data: ?{ [string]: mixed },
+  selectedInstances?: ?Instance[],
+  data?: ?{ [string]: mixed },
+  getFieldValue?: (fieldName: string, defaultValue: any) => any,
   onChange: (field: Field, value: any) => void,
-  useAdvancedOptions: boolean,
+  useAdvancedOptions?: boolean,
   hasStorageMap: boolean,
   storageBackends: StorageBackend[],
-  onAdvancedOptionsToggle: (showAdvanced: boolean) => void,
+  onAdvancedOptionsToggle?: (showAdvanced: boolean) => void,
   wizardType: string,
-  loading: boolean,
+  loading?: boolean,
+  columnStyle?: { [string]: mixed },
+  fieldWidth?: number,
+  onScrollableRef?: (ref: HTMLElement) => void,
 }
 @observer
 class WizardOptions extends React.Component<Props> {
@@ -83,6 +103,10 @@ class WizardOptions extends React.Component<Props> {
   }
 
   getFieldValue(fieldName: string, defaultValue: any) {
+    if (this.props.getFieldValue) {
+      return this.props.getFieldValue(fieldName, defaultValue)
+    }
+
     if (!this.props.data || this.props.data[fieldName] === undefined) {
       return defaultValue
     }
@@ -130,12 +154,6 @@ class WizardOptions extends React.Component<Props> {
     this.setState({})
   }
 
-  shouldRenderField(field: Field) {
-    return (field.type !== 'array' || (field.enum && field.enum.length && field.enum.length > 0)) &&
-      (field.type !== 'integer' || (field.minimum && field.maximum)) &&
-      (field.type !== 'object' || field.properties)
-  }
-
   renderOptionsField(field: Field) {
     let additionalProps
     if (field.type === 'object' && field.properties) {
@@ -158,6 +176,7 @@ class WizardOptions extends React.Component<Props> {
         enum={field.enum}
         required={field.required}
         data-test-id={`wOptions-field-${field.name}`}
+        width={this.props.fieldWidth}
         {...additionalProps}
       />
     )
@@ -173,7 +192,7 @@ class WizardOptions extends React.Component<Props> {
     }
 
     let executeNowColumn
-    let fields = fieldsSchema.filter(f => this.shouldRenderField(f)).map((field, i) => {
+    let fields = fieldsSchema.filter(f => shouldRenderField(f)).map((field, i) => {
       let column = i % 2 === 0 ? 'left' : 'right'
       if (field.name === 'execute_now') {
         executeNowColumn = column
@@ -200,8 +219,8 @@ class WizardOptions extends React.Component<Props> {
     }
 
     return (
-      <Fields>
-        <Column left>
+      <Fields innerRef={this.props.onScrollableRef}>
+        <Column left style={this.props.columnStyle}>
           {fields.map(f => f.column === 'left' && f.component)}
         </Column>
         <Column right>
@@ -230,13 +249,15 @@ class WizardOptions extends React.Component<Props> {
       return null
     }
 
+    let onAdvancedOptionsToggle = this.props.onAdvancedOptionsToggle
     return (
       <Options>
-        <ToggleButtonBar
+        {onAdvancedOptionsToggle ? <ToggleButtonBar
+          style={{ marginBottom: '46px' }}
           items={[{ label: 'Simple', value: 'simple' }, { label: 'Advanced', value: 'advanced' }]}
           selectedValue={this.props.useAdvancedOptions ? 'advanced' : 'simple'}
-          onChange={item => { this.props.onAdvancedOptionsToggle(item.value === 'advanced') }}
-        />
+          onChange={item => { onAdvancedOptionsToggle(item.value === 'advanced') }}
+        /> : null}
         {this.renderOptionsFields()}
       </Options>
     )

+ 40 - 42
src/components/organisms/WizardPageContent/WizardPageContent.jsx

@@ -92,6 +92,43 @@ const WizardTypeIcon = styled.div`
   align-items: center;
   margin: 0 32px;
 `
+export const isOptionsPageValid = (data: ?any, schema: Field[]) => {
+  const isValid = (field: Field): boolean => {
+    if (data) {
+      let fieldValue = data[field.name]
+      if (fieldValue === null) {
+        return false
+      }
+      if (fieldValue === undefined) {
+        return field.default != null
+      }
+      return Boolean(fieldValue)
+    }
+    return field.default != null
+  }
+
+  if (schema && schema.length > 0) {
+    let required = schema.filter(f => f.required && f.type !== 'object')
+    schema.forEach(f => {
+      if (f.type === 'object' && f.properties && f.properties.filter && f.properties.filter(p => isValid(p)).length > 0) {
+        required = required.concat(f.properties.filter(p => p.required))
+      }
+    })
+
+    let validFieldsCount = 0
+    required.forEach(f => {
+      if (isValid(f)) {
+        validFieldsCount += 1
+      }
+    })
+
+    if (validFieldsCount === required.length) {
+      return true
+    }
+  }
+
+  return false
+}
 type Props = {
   page: { id: string, title: string },
   type: 'replica' | 'migration',
@@ -195,45 +232,6 @@ class WizardPageContent extends React.Component<Props, State> {
     return false
   }
 
-  isOptionsPageValid() {
-    const isValid = (field: Field): boolean => {
-      if (this.props.wizardData.options) {
-        let fieldValue = this.props.wizardData.options[field.name]
-        if (fieldValue === null) {
-          return false
-        }
-        if (fieldValue === undefined) {
-          return field.default != null
-        }
-        return Boolean(fieldValue)
-      }
-      return field.default != null
-    }
-
-    let schema = this.props.providerStore.optionsSchema
-    if (schema && schema.length > 0) {
-      let required = schema.filter(f => f.required && f.type !== 'object')
-      schema.forEach(f => {
-        if (f.type === 'object' && f.properties && f.properties.filter && f.properties.filter(p => isValid(p)).length > 0) {
-          required = required.concat(f.properties.filter(p => p.required))
-        }
-      })
-
-      let validFieldsCount = 0
-      required.forEach(f => {
-        if (isValid(f)) {
-          validFieldsCount += 1
-        }
-      })
-
-      if (validFieldsCount === required.length) {
-        return true
-      }
-    }
-
-    return false
-  }
-
   isNextButtonDisabled() {
     if (this.props.nextButtonDisabled) {
       return true
@@ -247,7 +245,7 @@ class WizardPageContent extends React.Component<Props, State> {
       case 'vms':
         return !this.props.wizardData.selectedInstances || !this.props.wizardData.selectedInstances.length
       case 'options':
-        return !this.isOptionsPageValid()
+        return !isOptionsPageValid(this.props.wizardData.options, this.props.providerStore.destinationSchema)
       case 'networks':
         return !this.isNetworksPageValid()
       default:
@@ -334,9 +332,9 @@ class WizardPageContent extends React.Component<Props, State> {
       case 'options':
         body = (
           <WizardOptions
-            loading={this.props.providerStore.optionsSchemaLoading || this.props.providerStore.destinationOptionsLoading}
+            loading={this.props.providerStore.destinationSchemaLoading || this.props.providerStore.destinationOptionsLoading}
             selectedInstances={this.props.wizardData.selectedInstances}
-            fields={this.props.providerStore.optionsSchema}
+            fields={this.props.providerStore.destinationSchema}
             onChange={this.props.onOptionsChange}
             data={this.props.wizardData.options}
             useAdvancedOptions={this.state.useAdvancedOptions}

+ 3 - 4
src/components/organisms/WizardStorage/WizardStorage.jsx

@@ -75,8 +75,7 @@ const StorageItem = styled.div`
   }
 `
 const StorageImage = styled.div`
-  width: 48px;
-  height: 48px;
+  ${StyleProps.exactSize('48px')}
   background: url('${props => props.backend ? backendImage : diskImage}') center no-repeat;
   margin-right: 16px;
 `
@@ -92,8 +91,8 @@ const StorageSubtitle = styled.div`
   margin-top: 1px;
 `
 const ArrowImage = styled.div`
-  width: 32px;
-  height: 16px;
+  ${StyleProps.exactWidth('32px')}
+  ${StyleProps.exactHeight('16px')}
   background: url('${arrowImage}') center no-repeat;
   flex-grow: 1;
   margin-right: 16px;

+ 4 - 5
src/components/pages/AssessmentDetailsPage/AssessmentDetailsPage.jsx

@@ -249,11 +249,11 @@ class AssessmentDetailsPage extends React.Component<Props, State> {
 
   handleMigrateClick() {
     let endpointType = this.getLocalData().endpoint.type
-    providerStore.loadOptionsSchema(endpointType, 'replica').then(() => {
-      this.setState({ replicaSchema: providerStore.optionsSchema })
-      return providerStore.loadOptionsSchema(endpointType, 'migration')
+    providerStore.loadDestinationSchema(endpointType, 'replica').then(() => {
+      this.setState({ replicaSchema: providerStore.destinationSchema })
+      return providerStore.loadDestinationSchema(endpointType, 'migration')
     }).then(() => {
-      this.setState({ migrationSchema: providerStore.optionsSchema })
+      this.setState({ migrationSchema: providerStore.destinationSchema })
     })
     this.setState({ showMigrationOptions: true })
   }
@@ -450,7 +450,6 @@ class AssessmentDetailsPage extends React.Component<Props, State> {
           />}
           contentHeaderComponent={<DetailsContentHeader
             item={
-              // $FlowIgnore
               {
                 ...details,
                 type: 'Azure Migrate',

+ 0 - 1
src/components/pages/EndpointsPage/EndpointsPage.jsx

@@ -303,7 +303,6 @@ class EndpointsPage extends React.Component<{}, State> {
               }}
               itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
               renderItemComponent={options =>
-                // $FlowIssue
                 (<EndpointListItem
                   {...options}
                   getUsage={endpoint => this.getEndpointUsage(endpoint)}

+ 47 - 0
src/components/pages/ReplicaDetailsPage/ReplicaDetailsPage.jsx

@@ -25,6 +25,8 @@ import ReplicaDetailsContent from '../../organisms/ReplicaDetailsContent'
 import Modal from '../../molecules/Modal'
 import ReplicaExecutionOptions from '../../organisms/ReplicaExecutionOptions'
 import AlertModal from '../../organisms/AlertModal'
+import EditReplica from '../../organisms/EditReplica'
+
 import ReplicaMigrationOptions from '../../organisms/ReplicaMigrationOptions'
 import type { MainItem } from '../../../types/MainItem'
 import type { Execution } from '../../../types/Execution'
@@ -51,6 +53,7 @@ type Props = {
 type State = {
   showOptionsModal: boolean,
   showMigrationModal: boolean,
+  showEditModal: boolean,
   showDeleteExecutionConfirmation: boolean,
   showDeleteReplicaConfirmation: boolean,
   showDeleteReplicaDisksConfirmation: boolean,
@@ -62,6 +65,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
   state = {
     showOptionsModal: false,
     showMigrationModal: false,
+    showEditModal: false,
     showDeleteExecutionConfirmation: false,
     showDeleteReplicaConfirmation: false,
     showDeleteReplicaDisksConfirmation: false,
@@ -214,6 +218,10 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     this.setState({ showMigrationModal: true })
   }
 
+  handleReplicaEditClick() {
+    this.setState({ showEditModal: true })
+  }
+
   handleAddScheduleClick(schedule: Schedule) {
     scheduleStore.addSchedule(this.props.match.params.id, schedule)
   }
@@ -271,11 +279,46 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
   }
 
   pollData(showLoading: boolean) {
+    if (this.state.showEditModal) {
+      return
+    }
+
+    if (!this.props.match.params.page) {
+      replicaStore.getReplica(this.props.match.params.id, showLoading)
+    }
+
     replicaStore.getReplicaExecutions(this.props.match.params.id, showLoading).then(() => {
       this.pollTimeout = setTimeout(() => { this.pollData(false) }, requestPollTimeout)
     })
   }
 
+  closeEditModal() {
+    this.setState({ showEditModal: false }, () => {
+      this.pollData(false)
+    })
+  }
+
+  renderEditReplica() {
+    let destinationEndpoint = endpointStore.endpoints
+      .find(e => replicaStore.replicaDetails && e.id === replicaStore.replicaDetails.destination_endpoint_id)
+
+    if (!this.state.showEditModal || !replicaStore.replicaDetails || !destinationEndpoint) {
+      return null
+    }
+
+    return (
+      <EditReplica
+        isOpen
+        onRequestClose={() => { this.closeEditModal() }}
+        replica={replicaStore.replicaDetails}
+        destinationEndpoint={destinationEndpoint}
+        instancesDetails={instanceStore.instancesDetails}
+        instancesDetailsLoading={instanceStore.loadingInstancesDetails}
+        networks={networkStore.networks}
+      />
+    )
+  }
+
   render() {
     let dropdownActions = [{
       label: 'Execute',
@@ -289,6 +332,9 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
       label: 'Create Migration',
       color: Palette.primary,
       action: () => { this.handleCreateMigrationClick() },
+    }, {
+      label: 'Edit',
+      action: () => { this.handleReplicaEditClick() },
     }, {
       label: 'Delete Disks',
       action: () => { this.handleDeleteReplicaDisksClick() },
@@ -385,6 +431,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
           onConfirmation={() => { this.handleCancelConfirmation() }}
           onRequestClose={() => { this.handleCloseCancelConfirmation() }}
         />
+        {this.renderEditReplica()}
       </Wrapper>
     )
   }

+ 15 - 40
src/components/pages/WizardPage/WizardPage.jsx

@@ -26,7 +26,7 @@ import Modal from '../../molecules/Modal'
 import Endpoint from '../../organisms/Endpoint'
 
 import userStore from '../../../stores/UserStore'
-import providerStore from '../../../stores/ProviderStore'
+import providerStore, { getFieldChangeDestOptions } from '../../../stores/ProviderStore'
 import endpointStore from '../../../stores/EndpointStore'
 import wizardStore from '../../../stores/WizardStore'
 import instanceStore from '../../../stores/InstanceStore'
@@ -35,7 +35,8 @@ import notificationStore from '../../../stores/NotificationStore'
 import scheduleStore from '../../../stores/ScheduleStore'
 import replicaStore from '../../../stores/ReplicaStore'
 import KeyboardManager from '../../../utils/KeyboardManager'
-import { wizardConfig, executionOptions, providersWithExtraOptions } from '../../../config'
+import { wizardConfig, executionOptions } from '../../../config'
+
 import type { MainItem } from '../../../types/MainItem'
 import type { Endpoint as EndpointType, StorageBackend } from '../../../types/Endpoint'
 import type { Instance, Nic, Disk } from '../../../types/Instance'
@@ -216,7 +217,7 @@ class WizardPage extends React.Component<Props, State> {
     wizardStore.clearStorageMap()
     wizardStore.setPermalink(wizardStore.data)
     // Preload destination options schema
-    providerStore.loadOptionsSchema(target.type, this.state.type).then(() => {
+    providerStore.loadDestinationSchema(target.type, this.state.type).then(() => {
       // Preload destination options values
       return providerStore.getDestinationOptions(target.id, target.type)
     })
@@ -316,41 +317,15 @@ class WizardPage extends React.Component<Props, State> {
 
   loadEnvDestinationOptions(field?: Field) {
     let provider = wizardStore.data.target && wizardStore.data.target.type
-    let providerWithExtraOptions = providersWithExtraOptions.find(p => typeof p !== 'string' && p.name === provider)
-    if (provider && providerWithExtraOptions && typeof providerWithExtraOptions !== 'string' && providerWithExtraOptions.envRequiredFields) {
-      let findFieldInSchema = (name: string) => providerStore.optionsSchema.find(f => f.name === name)
-      let validFields = providerWithExtraOptions.envRequiredFields.filter(fn => {
-        let schemaField = findFieldInSchema(fn)
-        if (wizardStore.data.options) {
-          if (wizardStore.data.options[fn] === null) {
-            return false
-          }
-          if (wizardStore.data.options[fn] === undefined && schemaField && schemaField.default) {
-            return true
-          }
-          return wizardStore.data.options[fn]
-        }
-        return false
-      })
-      let currentFieldValied = field ? validFields.find(fn => field ? fn === field.name : false) : true
-      if (
-        validFields.length === providerWithExtraOptions.envRequiredFields.length &&
-        currentFieldValied
-      ) {
-        let envData = {}
-        validFields.forEach(fn => {
-          envData[fn] = wizardStore.data.options ? wizardStore.data.options[fn] : null
-          if (envData[fn] == null) {
-            let schemaField = findFieldInSchema(fn)
-            if (schemaField && schemaField.default) {
-              envData[fn] = schemaField.default
-            }
-          }
-        })
-        if (wizardStore.data.target) {
-          providerStore.getDestinationOptions(wizardStore.data.target.id, provider, envData)
-        }
-      }
+    let envData = getFieldChangeDestOptions({
+      provider: wizardStore.data.target && wizardStore.data.target.type,
+      destSchema: providerStore.destinationSchema,
+      data: wizardStore.data.options,
+      field,
+    })
+
+    if (provider && envData && wizardStore.data.target) {
+      providerStore.getDestinationOptions(wizardStore.data.target.id, provider, envData)
     }
   }
 
@@ -379,8 +354,8 @@ class WizardPage extends React.Component<Props, State> {
           endpointStore.loadStorage(target.id, {})
         }
         // Preload destination options schema
-        if (providerStore.optionsSchema.length === 0 && target) {
-          providerStore.loadOptionsSchema(target.type, this.state.type).then(() => {
+        if (providerStore.destinationSchema.length === 0 && target) {
+          providerStore.loadDestinationSchema(target.type, this.state.type).then(() => {
             // Preload destination options if data is set from 'Permalink'
             if (providerStore.destinationOptions.length === 0 && target) {
               providerStore.getDestinationOptions(target.id, target.type).then(() => {

+ 2 - 2
src/config.js

@@ -81,14 +81,14 @@ export const executionOptions = [
   },
 ]
 
-export const storageProviders = ['openstack']
+export const storageProviders = ['openstack', 'azure']
 
 export const wizardConfig = {
   pages: [
     { id: 'type', title: 'New', breadcrumb: 'Type' },
     { id: 'source', title: 'Select your source cloud', breadcrumb: 'Source Cloud' },
-    { id: 'target', title: 'Select your target cloud', breadcrumb: 'Target Cloud' },
     { id: 'vms', title: 'Select instances', breadcrumb: 'Select VMs' },
+    { id: 'target', title: 'Select your target cloud', breadcrumb: 'Target Cloud' },
     { id: 'options', title: 'Options', breadcrumb: 'Options' },
     { id: 'networks', title: 'Networks', breadcrumb: 'Networks' },
     {

+ 28 - 26
src/plugins/endpoint/default/OptionsSchemaPlugin.js

@@ -62,42 +62,44 @@ export const defaultFillMigrationImageMapValues = (field: Field, option: Destina
   return false
 }
 
-export const defaultGetDestinationEnv = (data: WizardData): any => {
+export const defaultGetDestinationEnv = (options: ?{ [string]: mixed }, oldOptions?: ?{ [string]: mixed }): any => {
   let env = {}
   let specialOptions = ['execute_now', 'separate_vm', 'skip_os_morphing', 'default_storage', 'description']
     .concat(executionOptions.map(o => o.name))
     .concat(migrationImageOsTypes.map(o => `${o}_os_image`))
 
 
-  if (data.options) {
-    Object.keys(data.options).forEach(optionName => {
-      if (specialOptions.find(o => o === optionName) || !data.options || data.options[optionName] == null) {
-        return
-      }
-      if (optionName.indexOf('/') > 0) {
-        let parentName = optionName.substr(0, optionName.lastIndexOf('/'))
-        if (!env[parentName]) {
-          env[parentName] = {}
-        }
-        env[parentName][optionName.substr(optionName.lastIndexOf('/') + 1)] = data.options ? data.options[optionName] : null
-      } else {
-        env[optionName] = data.options ? data.options[optionName] : null
-      }
-    })
+  if (!options) {
+    return env
   }
+  Object.keys(options).forEach(optionName => {
+    if (specialOptions.find(o => o === optionName) || !options || options[optionName] == null) {
+      return
+    }
+
+    if (optionName.indexOf('/') > 0) {
+      let parentName = optionName.substr(0, optionName.lastIndexOf('/'))
+      if (!env[parentName]) {
+        env[parentName] = oldOptions ? oldOptions[parentName] || {} : {}
+      }
+      env[parentName][optionName.substr(optionName.lastIndexOf('/') + 1)] = options ? options[optionName] : null
+    } else {
+      env[optionName] = options ? options[optionName] : null
+    }
+  })
   return env
 }
 
-export const defaultGetMigrationImageMap = (data: WizardData) => {
+export const defaultGetMigrationImageMap = (options: ?{ [string]: mixed }) => {
   let env = {}
-  if (data.options) {
+  if (options) {
     migrationImageOsTypes.forEach(os => {
-      if (data.options && data.options[`${os}_os_image`]) {
+      if (options && options[`${os}_os_image`]) {
         if (!env.migr_image_map) {
           env.migr_image_map = {}
         }
 
-        env.migr_image_map[os] = data.options[`${os}_os_image`]
+        env.migr_image_map[os] = options[`${os}_os_image`]
       }
     })
   }
@@ -116,10 +118,10 @@ export default class OptionsSchemaParser {
     }
   }
 
-  static getDestinationEnv(data: WizardData) {
+  static getDestinationEnv(options: ?{ [string]: mixed }, oldOptions?: any) {
     let env = {
-      ...defaultGetDestinationEnv(data),
-      ...defaultGetMigrationImageMap(data),
+      ...defaultGetDestinationEnv(options, oldOptions),
+      ...defaultGetMigrationImageMap(options),
     }
     return env
   }
@@ -134,10 +136,10 @@ export default class OptionsSchemaParser {
     return payload
   }
 
-  static getStorageMap(data: WizardData, storageMap: StorageMap[]) {
+  static getStorageMap(data: any, storageMap: StorageMap[]) {
     let payload = {}
-    if (data.options && data.options.default_storage) {
-      payload.default = data.options.default_storage
+    if (data && data.default_storage) {
+      payload.default = data.default_storage
     }
 
     storageMap.forEach(mapping => {

+ 1 - 1
src/sources/ProviderSource.js

@@ -35,7 +35,7 @@ class ProviderSource {
       .then(response => response.data.providers)
   }
 
-  static loadOptionsSchema(providerName: string, schemaType: string): Promise<Field[]> {
+  static loadDestinationSchema(providerName: string, schemaType: string): Promise<Field[]> {
     let schemaTypeInt = schemaType === 'migration' ? providerTypes.TARGET_MIGRATION : providerTypes.TARGET_REPLICA
 
     return Api.get(`${servicesUrl.coriolis}/${Api.projectId}/providers/${providerName}/schemas/${schemaTypeInt}`).then(response => {

+ 30 - 1
src/sources/ReplicaSource.js

@@ -17,10 +17,12 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import moment from 'moment'
 
 import Api from '../utils/ApiCaller'
+import { OptionsSchemaPlugin } from '../plugins/endpoint'
 
 import { servicesUrl } from '../config'
-import type { MainItem } from '../types/MainItem'
+import type { MainItem, UpdateData } from '../types/MainItem'
 import type { Execution } from '../types/Execution'
+import type { Endpoint } from '../types/Endpoint'
 import type { Field } from '../types/Field'
 
 class ReplicaSourceUtils {
@@ -157,6 +159,33 @@ class ReplicaSource {
       data: { 'delete-disks': null },
     }).then(response => response.data.execution)
   }
+
+  static update(replica: MainItem, destinationEndpoint: Endpoint, updateData: UpdateData): Promise<Execution> {
+    const parser = OptionsSchemaPlugin[destinationEndpoint.type] || OptionsSchemaPlugin.default
+    let payload = { replica: {} }
+
+    if (updateData.network.length > 0) {
+      let networkMap = {}
+      updateData.network.forEach(mapping => {
+        networkMap[mapping.sourceNic.network_name] = mapping.targetNetwork.name
+      })
+      payload.replica.network_map = networkMap
+    }
+
+    if (Object.keys(updateData.destination).length > 0) {
+      payload.replica.destination_environment = parser.getDestinationEnv(updateData.destination, replica.destination_environment)
+    }
+
+    if (updateData.storage.length > 0) {
+      payload.replica.storage_mappings = parser.getStorageMap(updateData.destination, updateData.storage)
+    }
+
+    return Api.send({
+      url: `${servicesUrl.coriolis}/${Api.projectId}/replicas/${replica.id}`,
+      method: 'PUT',
+      data: payload,
+    }).then(response => response.data)
+  }
 }
 
 export default ReplicaSource

+ 2 - 2
src/sources/WizardSource.js

@@ -30,10 +30,10 @@ class WizardSource {
     payload[type] = {
       origin_endpoint_id: data.source ? data.source.id : 'null',
       destination_endpoint_id: data.target ? data.target.id : 'null',
-      destination_environment: parser.getDestinationEnv(data),
+      destination_environment: parser.getDestinationEnv(data.options),
       network_map: parser.getNetworkMap(data),
       instances: data.selectedInstances ? data.selectedInstances.map(i => i.instance_name) : 'null',
-      storage_mappings: parser.getStorageMap(data, storageMap),
+      storage_mappings: parser.getStorageMap(data.options, storageMap),
       notes: data.options ? data.options.description || '' : '',
     }
 

+ 6 - 0
src/stores/EndpointStore.js

@@ -39,6 +39,7 @@ class EndpointStore {
   @observable connectionInfoLoading = false
   @observable connectionsInfoLoading = false
   @observable storageBackends: StorageBackend[] = []
+  @observable storageLoading: boolean = false
 
   @action getEndpoints(options?: { showLoading: boolean }) {
     if (options && options.showLoading) {
@@ -136,8 +137,13 @@ class EndpointStore {
 
   @action loadStorage(endpointId: string, data: any): Promise<void> {
     this.storageBackends = []
+    this.storageLoading = true
     return EndpointSource.loadStorage(endpointId, data).then(storage => {
       this.storageBackends = storage.storage_backends
+      this.storageLoading = false
+    }).catch(ex => {
+      this.storageLoading = false
+      throw ex
     })
   }
 }

+ 60 - 13
src/stores/ProviderStore.js

@@ -23,17 +23,64 @@ import type { DestinationOption } from '../types/Endpoint'
 import type { Field } from '../types/Field'
 import type { Providers } from '../types/Providers'
 
+export const getFieldChangeDestOptions = (options: {
+  provider: ?string,
+  destSchema: Field[],
+  data: any,
+  field: ?Field,
+}) => {
+  let { provider, destSchema, data, field } = options
+  let providerWithExtraOptions = providersWithExtraOptions.find(p => typeof p !== 'string' && p.name === provider)
+  if (!provider || !providerWithExtraOptions || typeof providerWithExtraOptions === 'string' || !providerWithExtraOptions.envRequiredFields) {
+    return null
+  }
+
+  let findFieldInSchema = (name: string) => destSchema.find(f => f.name === name)
+
+  let validFields = providerWithExtraOptions.envRequiredFields.filter(fn => {
+    let schemaField = findFieldInSchema(fn)
+    if (data) {
+      if (data[fn] === null) {
+        return false
+      }
+      if (data[fn] === undefined && schemaField && schemaField.default) {
+        return true
+      }
+      return data[fn]
+    }
+    return false
+  })
+
+  let isCurrentFieldValid = field ? validFields.find(fn => field ? fn === field.name : false) : true
+  if (validFields.length !== providerWithExtraOptions.envRequiredFields.length || !isCurrentFieldValid) {
+    return null
+  }
+
+  let envData = {}
+  validFields.forEach(fn => {
+    envData[fn] = data ? data[fn] : null
+    if (envData[fn] == null) {
+      let schemaField = findFieldInSchema(fn)
+      if (schemaField && schemaField.default) {
+        envData[fn] = schemaField.default
+      }
+    }
+  })
+
+  return envData
+}
+
 class ProviderStore {
   @observable connectionInfoSchema: Field[] = []
   @observable connectionSchemaLoading: boolean = false
   @observable providers: ?Providers
   @observable providersLoading: boolean = false
-  @observable optionsSchema: Field[] = []
-  @observable optionsSchemaLoading: boolean = false
+  @observable destinationSchema: Field[] = []
+  @observable destinationSchemaLoading: boolean = false
   @observable destinationOptions: DestinationOption[] = []
   @observable destinationOptionsLoading: boolean = false
 
-  lastOptionsSchemaType: string = ''
+  lastDestinationSchemaType: string = ''
 
   @action getConnectionInfoSchema(providerName: string): Promise<void> {
     this.connectionSchemaLoading = true
@@ -62,15 +109,15 @@ class ProviderStore {
     })
   }
 
-  @action loadOptionsSchema(providerName: string, schemaType: string): Promise<void> {
-    this.optionsSchemaLoading = true
-    this.lastOptionsSchemaType = schemaType
+  @action loadDestinationSchema(providerName: string, schemaType: string): Promise<void> {
+    this.destinationSchemaLoading = true
+    this.lastDestinationSchemaType = schemaType
 
-    return ProviderSource.loadOptionsSchema(providerName, schemaType).then((fields: Field[]) => {
-      this.optionsSchemaLoading = false
-      this.optionsSchema = fields
+    return ProviderSource.loadDestinationSchema(providerName, schemaType).then((fields: Field[]) => {
+      this.destinationSchemaLoading = false
+      this.destinationSchema = fields
     }).catch(() => {
-      this.optionsSchemaLoading = false
+      this.destinationSchemaLoading = false
     })
   }
 
@@ -85,7 +132,7 @@ class ProviderStore {
     let destOptions = []
 
     return ProviderSource.getDestinationOptions(endpointId, envData).then(options => {
-      this.optionsSchema.forEach(field => {
+      this.destinationSchema.forEach(field => {
         const parser = OptionsSchemaPlugin[provider] || OptionsSchemaPlugin.default
         parser.fillFieldValues(field, options)
       })
@@ -95,11 +142,11 @@ class ProviderStore {
     }).catch(err => {
       console.error(err)
       if (envData) {
-        return this.loadOptionsSchema(provider, this.lastOptionsSchemaType).then(() => {
+        return this.loadDestinationSchema(provider, this.lastDestinationSchemaType).then(() => {
           return this.getDestinationOptions(endpointId, provider)
         })
       }
-      return this.loadOptionsSchema(provider, this.lastOptionsSchemaType)
+      return this.loadDestinationSchema(provider, this.lastDestinationSchemaType)
     }).then(() => {
       this.destinationOptionsLoading = false
       return destOptions

+ 13 - 8
src/stores/ReplicaStore.js

@@ -18,11 +18,12 @@ import { observable, action } from 'mobx'
 
 import notificationStore from '../stores/NotificationStore'
 import ReplicaSource from '../sources/ReplicaSource'
-import type { MainItem } from '../types/MainItem'
+import type { MainItem, UpdateData } from '../types/MainItem'
 import type { Execution } from '../types/Execution'
+import type { Endpoint } from '../types/Endpoint'
 import type { Field } from '../types/Field'
 
-class replicaStoreUtils {
+class ReplicaStoreUtils {
   static getNewReplica(replicaDetails: MainItem, execution: Execution): MainItem {
     if (replicaDetails.executions) {
       return {
@@ -87,8 +88,8 @@ class ReplicaStore {
     }).catch(() => { this.executionsLoading = false })
   }
 
-  @action getReplica(replicaId: string): Promise<void> {
-    this.detailsLoading = true
+  @action getReplica(replicaId: string, showLoading: boolean = true): Promise<void> {
+    this.detailsLoading = showLoading
 
     return ReplicaSource.getReplica(replicaId).then(replica => {
       this.detailsLoading = false
@@ -101,13 +102,13 @@ class ReplicaStore {
   @action execute(replicaId: string, fields?: Field[]): Promise<void> {
     return ReplicaSource.execute(replicaId, fields).then(execution => {
       if (this.replicaDetails && this.replicaDetails.id === replicaId) {
-        this.replicaDetails = replicaStoreUtils.getNewReplica(this.replicaDetails, execution)
+        this.replicaDetails = ReplicaStoreUtils.getNewReplica(this.replicaDetails, execution)
       }
 
       let replicasItemIndex = this.replicas ? this.replicas.findIndex(r => r.id === replicaId) : -1
 
       if (replicasItemIndex > -1) {
-        const updatedReplica = replicaStoreUtils.getNewReplica(this.replicas[replicasItemIndex], execution)
+        const updatedReplica = ReplicaStoreUtils.getNewReplica(this.replicas[replicasItemIndex], execution)
         this.replicas[replicasItemIndex] = updatedReplica
       }
     })
@@ -145,13 +146,13 @@ class ReplicaStore {
   @action deleteDisks(replicaId: string) {
     return ReplicaSource.deleteDisks(replicaId).then(execution => {
       if (this.replicaDetails && this.replicaDetails.id === replicaId) {
-        this.replicaDetails = replicaStoreUtils.getNewReplica(this.replicaDetails, execution)
+        this.replicaDetails = ReplicaStoreUtils.getNewReplica(this.replicaDetails, execution)
       }
 
       let replicasItemIndex = this.replicas ? this.replicas.findIndex(r => r.id === replicaId) : -1
 
       if (replicasItemIndex > -1) {
-        const updatedReplica = replicaStoreUtils.getNewReplica(this.replicas[replicasItemIndex], execution)
+        const updatedReplica = ReplicaStoreUtils.getNewReplica(this.replicas[replicasItemIndex], execution)
         this.replicas[replicasItemIndex] = updatedReplica
       }
     })
@@ -161,6 +162,10 @@ class ReplicaStore {
     this.detailsLoading = true
     this.replicaDetails = null
   }
+
+  @action update(replica: MainItem, destinationEndpoint: Endpoint, updateData: UpdateData) {
+    return ReplicaSource.update(replica, destinationEndpoint, updateData)
+  }
 }
 
 export default new ReplicaStore()

+ 1 - 0
src/types/Field.js

@@ -30,4 +30,5 @@ export type Field = {
   properties?: Field[],
   required?: boolean,
   useTextArea?: boolean,
+  readOnly?: boolean,
 }

+ 14 - 10
src/types/MainItem.js

@@ -17,6 +17,8 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import type { Execution } from './Execution'
 import type { Task } from './Task'
 import type { Instance } from './Instance'
+import type { NetworkMap } from './Network'
+import type { StorageMap } from './Endpoint'
 
 export type MainItemInfo = {
   export_info: {
@@ -28,14 +30,10 @@ export type MainItemInfo = {
   },
 }
 
-export type DestinationEnvInfo = {
-  network_map: {
-    [string]: {
-      source_network: string,
-      destination_network: string,
-    } | 'string'
-  },
-  [string]: mixed,
+export type UpdateData = {
+  destination: any,
+  network: NetworkMap[],
+  storage: StorageMap[],
 }
 
 export type MainItem = {
@@ -52,9 +50,9 @@ export type MainItem = {
   instances: string[],
   type: string,
   info: { [string]: MainItemInfo },
-  destination_environment: DestinationEnvInfo,
+  destination_environment: { [string]: mixed },
   transfer_result: ?{ [string]: Instance },
-  storage_mappings: ?{
+  storage_mappings?: ?{
     backend_mappings: ?{
       destination: string,
       source: string,
@@ -65,4 +63,10 @@ export type MainItem = {
       disk_id: string,
     }[],
   },
+  network_map?: {
+    [string]: {
+      source_network: string,
+      destination_network: string,
+    } | 'string'
+  }
 }

+ 1 - 1
src/utils/LabelDictionary.js

@@ -16,7 +16,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 class LabelDictionary {
   // The word will be uppercased
-  static acronyms = ['id', 'api', 'url', 'vm', 'os', 'dhcp', 'sql', 'oci']
+  static acronyms = ['id', 'api', 'url', 'vm', 'os', 'dhcp', 'sql', 'oci', 'sku']
 
   // The word will be replaced
   static abbreviations = {