فهرست منبع

Merge pull request #308 from smiclea/storage-mapping

Add Storage Mapping screen to the Migration Wizard
Dorin Paslaru 7 سال پیش
والد
کامیت
c3f0d871ed
32فایلهای تغییر یافته به همراه1087 افزوده شده و 110 حذف شده
  1. 376 0
      src/components/molecules/MainDetailsTable/MainDetailsTable.jsx
  2. 14 0
      src/components/molecules/MainDetailsTable/images/arrow.svg
  3. 15 0
      src/components/molecules/MainDetailsTable/images/instance.svg
  4. 15 0
      src/components/molecules/MainDetailsTable/images/network.svg
  5. 16 0
      src/components/molecules/MainDetailsTable/images/storage.svg
  6. 6 0
      src/components/molecules/MainDetailsTable/package.json
  7. 1 1
      src/components/molecules/TaskItem/TaskItem.jsx
  8. 6 2
      src/components/molecules/WizardBreadcrumbs/WizardBreadcrumbs.jsx
  9. 9 3
      src/components/molecules/WizardBreadcrumbs/test.jsx
  10. 20 45
      src/components/organisms/MainDetails/MainDetails.jsx
  11. 0 21
      src/components/organisms/MainDetails/test.jsx
  12. 7 0
      src/components/organisms/WizardOptions/WizardOptions.jsx
  13. 38 7
      src/components/organisms/WizardPageContent/WizardPageContent.jsx
  14. 246 0
      src/components/organisms/WizardStorage/WizardStorage.jsx
  15. 15 0
      src/components/organisms/WizardStorage/images/arrow.svg
  16. 41 0
      src/components/organisms/WizardStorage/images/backend.svg
  17. 21 0
      src/components/organisms/WizardStorage/images/disk.svg
  18. 18 0
      src/components/organisms/WizardStorage/images/storage-big.svg
  19. 6 0
      src/components/organisms/WizardStorage/package.json
  20. 56 1
      src/components/organisms/WizardSummary/WizardSummary.jsx
  21. 1 1
      src/components/organisms/WizardSummary/test.jsx
  22. 36 16
      src/components/pages/WizardPage/WizardPage.jsx
  23. 8 0
      src/config.js
  24. 34 2
      src/plugins/endpoint/default/OptionsSchemaPlugin.js
  25. 1 1
      src/sources/AssessmentSource.js
  26. 8 1
      src/sources/EndpointSource.js
  27. 5 3
      src/sources/WizardSource.js
  28. 9 1
      src/stores/EndpointStore.js
  29. 17 4
      src/stores/WizardStore.js
  30. 14 0
      src/types/Endpoint.js
  31. 15 1
      src/types/Instance.js
  32. 13 0
      src/types/MainItem.js

+ 376 - 0
src/components/molecules/MainDetailsTable/MainDetailsTable.jsx

@@ -0,0 +1,376 @@
+/*
+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 styled from 'styled-components'
+import { Collapse } from 'react-collapse'
+
+import Arrow from '../../atoms/Arrow'
+
+import Palette from '../../styleUtils/Palette'
+import StyleProps from '../../styleUtils/StyleProps'
+
+import type { MainItem } from '../../../types/MainItem'
+import type { Instance, Nic, Disk } from '../../../types/Instance'
+
+import instanceIcon from './images/instance.svg'
+import networkIcon from './images/network.svg'
+import storageIcon from './images/storage.svg'
+import arrowIcon from './images/arrow.svg'
+
+const Wrapper = styled.div`
+  margin-top: 24px;
+  margin-bottom: 48px;
+`
+const ArrowStyled = styled(Arrow)`
+  position: absolute;
+  left: -24px;
+`
+const Header = styled.div`
+  display: flex;
+`
+const HeaderLabel = styled.div`
+  font-size: 10px;
+  color: ${Palette.grayscale[3]};
+  font-weight: ${StyleProps.fontWeights.medium};
+  text-transform: uppercase;
+  width: 50%;
+  margin-bottom: 8px;
+  &:last-child { margin-left: 36px; }
+`
+const InstanceInfo = styled.div`
+  background: ${Palette.grayscale[1]};
+  border-radius: ${StyleProps.borderRadius};
+  margin-bottom: 32px;
+  &:last-child { margin-bottom: 0; }
+`
+const InstanceName = styled.div`
+  padding: 16px;
+  border-bottom: 1px solid ${Palette.grayscale[5]};
+  font-size: 16px;
+`
+const InstanceBody = styled.div`
+  font-size: 14px;
+`
+const Row = styled.div`
+  position: relative;
+  padding: 8px 0;
+  border-bottom: 1px solid white;
+  transition: all ${StyleProps.animations.swift};
+  &:last-child {
+    border-bottom: 0;
+    border-bottom-left-radius: ${StyleProps.borderRadius};
+    border-bottom-right-radius: ${StyleProps.borderRadius};
+  }
+  &:hover {
+    background: ${Palette.grayscale[0]};
+    ${ArrowStyled} {
+      opacity: 1;
+    }
+  }
+  cursor: pointer;
+`
+const RowHeader = styled.div`
+  display: flex;
+  align-items: center;
+  padding: 0 16px;
+`
+const RowHeaderColumn = styled.div`
+  display: flex;
+  align-items: center;
+  ${StyleProps.exactWidth('50%')}
+  &:last-child { margin-left: 19px; }
+`
+const HeaderName = styled.div`
+  overflow: hidden;
+  text-overflow: ellipsis;
+  ${props => StyleProps.exactWidth(`calc(100% - ${props.source ? 120 : 8}px)`)}
+`
+const RowBody = styled.div`
+  display: flex;
+  color: ${Palette.grayscale[5]};
+  padding: 0 16px;
+  margin-top: 4px;
+`
+const RowBodyColumn = styled.div`
+  width: 50%;
+  &:first-child { margin-left: 32px; }
+  &:last-child { margin-left: 6px; }
+`
+const getHeaderIcon = (icon: 'instance' | 'network' | 'storage'): string => {
+  switch (icon) {
+    case 'instance':
+      return instanceIcon
+    case 'network':
+      return networkIcon
+    default:
+      return storageIcon
+  }
+}
+const HeaderIcon = styled.div`
+  width: 16px;
+  height: 16px;
+  background: url('${props => getHeaderIcon(props.icon)}') center no-repeat;
+  margin-right: 16px;
+`
+const ArrowIcon = styled.div`
+  width: 32px;
+  height: 16px;
+  background: url('${arrowIcon}') center no-repeat;
+  margin-left: 16px;
+`
+
+type Props = {
+  item: ?MainItem,
+  instancesDetails: Instance[],
+}
+type State = {
+  openedRows: string[],
+}
+
+class MainDetailsTable extends React.Component<Props, State> {
+  state = {
+    openedRows: [],
+  }
+
+  getTransferResult(instance: Instance): ?Instance {
+    if (this.props.item && this.props.item.transfer_result) {
+      let transferInstanceKey = Object.keys(this.props.item.transfer_result).find(i => i.indexOf(instance.name))
+      if (transferInstanceKey && this.props.item && this.props.item.transfer_result) {
+        let result = this.props.item.transfer_result[transferInstanceKey]
+        result.instance_name = transferInstanceKey
+        return result
+      }
+    }
+    return null
+  }
+
+  handleRowClick(id: string) {
+    if (this.state.openedRows.find(i => i === id)) {
+      this.setState({
+        openedRows: this.state.openedRows.filter(i => i !== id),
+      })
+    } else {
+      this.setState({
+        openedRows: [...this.state.openedRows, id],
+      })
+    }
+  }
+
+  renderRow(
+    id: string,
+    icon: 'instance' | 'network' | 'storage',
+    sourceName: string,
+    destinationName: string,
+    sourceBody: string[],
+    destinationBody: string[]
+  ) {
+    let isOpened: boolean = Boolean(this.state.openedRows.find(i => i === id))
+
+    return (
+      <Row key={id} onClick={() => { this.handleRowClick(id) }}>
+        <ArrowStyled
+          primary
+          orientation={isOpened ? 'up' : 'down'}
+          opacity={isOpened ? 1 : 0}
+          thick
+        />
+        <RowHeader>
+          <RowHeaderColumn>
+            <HeaderIcon icon={icon} />
+            <HeaderName source>{sourceName}</HeaderName>
+            {destinationName ? <ArrowIcon /> : null}
+          </RowHeaderColumn>
+          <RowHeaderColumn>
+            <HeaderName>{destinationName}</HeaderName>
+          </RowHeaderColumn>
+        </RowHeader>
+        <Collapse isOpened={isOpened} springConfig={{ stiffness: 100, damping: 20 }}>
+          <RowBody>
+            <RowBodyColumn>{sourceBody.map(l => <div key={l}>{l}</div>)}</RowBodyColumn>
+            <RowBodyColumn>{destinationBody.map(l => <div key={l}>{l}</div>)}</RowBodyColumn>
+          </RowBody>
+        </Collapse>
+      </Row>
+    )
+  }
+
+  renderStorage(instance: Instance) {
+    let storageMapping = this.props.item && this.props.item.storage_mappings
+    let transferResult = this.getTransferResult(instance)
+    let rows = []
+    instance.devices.disks.forEach(disk => {
+      let sourceName = disk.id
+      let mappedDisk = storageMapping && storageMapping.disk_mappings &&
+        storageMapping.disk_mappings.find(m => String(m.disk_id) === String(disk.id))
+      let destinationName: string = (
+        this.props.item && this.props.item.storage_mappings
+        && this.props.item.storage_mappings.default
+      ) || 'Default'
+      if (mappedDisk) {
+        destinationName = mappedDisk.destination
+      }
+      let getBody = (d: Disk): string[] => {
+        let body: string[] = []
+        if (d.size_bytes) {
+          body.push(`Size: ${(d.size_bytes / 1024 / 1024).toFixed(0)} MB`)
+        }
+        if (d.storage_backend_identifier) {
+          body.push(`Backend Identifier: ${d.storage_backend_identifier}`)
+        }
+        if (d.format) {
+          body.push(`Format: ${d.format}`)
+        }
+        if (d.guest_device) {
+          body.push(`Guest Device: ${d.guest_device}`)
+        }
+        return body
+      }
+      let sourceBody = getBody(disk)
+      let destinationBody = []
+      if (transferResult) {
+        let transferDisk = transferResult.devices.disks.find(d => d.storage_backend_identifier === destinationName)
+        if (transferDisk) {
+          destinationName = transferDisk.name || transferDisk.id
+          destinationBody = getBody(transferDisk)
+        }
+      } else if (this.props.item && this.props.item.status === 'RUNNING' && this.props.item.type === 'migration') {
+        destinationBody = ['Waiting for migration to finish']
+      }
+
+      rows.push(this.renderRow(
+        `${instance.instance_name}-${sourceName}-${destinationName}`,
+        'storage',
+        sourceName,
+        destinationName,
+        sourceBody,
+        destinationBody
+      ))
+    })
+
+    return rows
+  }
+
+  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 (destinationNetworkMap == null) {
+      return null
+    }
+    let transferResult = this.getTransferResult(instance)
+    let rows = []
+    instance.devices.nics.forEach(nic => {
+      if (destinationNetworkMap && destinationNetworkMap[nic.network_name]) {
+        let getBody = (n: Nic): string[] => {
+          let body: string[] = []
+          let ipv4 = n.ip_addresses ? n.ip_addresses.find(ip => /(?:\d+?\.){3}\d+/g.exec(ip)) : null
+          let ipv6 = n.ip_addresses ? n.ip_addresses.find(ip => /\w*:\w*/g.exec(ip)) : null
+          if (ipv4) {
+            body.push(`IP Address (IPv4): ${ipv4}`)
+          }
+          if (ipv6) {
+            body.push(`IP Address (IPv6): ${ipv6}`)
+          }
+          body.push(`MAC Address: ${n.mac_address}`)
+          return body
+        }
+        let sourceBody = getBody(nic)
+        let destinationBody = []
+
+        let destinationNetworkName = String(destinationNetworkMap[nic.network_name])
+        if (transferResult) {
+          let destinationNic = transferResult.devices.nics
+            .find(n => n.network_id === destinationNetworkName || n.network_name === destinationNetworkName)
+          if (destinationNic) {
+            destinationNetworkName = destinationNic.network_name
+            destinationBody = getBody(destinationNic)
+          }
+        } else if (this.props.item && this.props.item.status === 'RUNNING' && this.props.item.type === 'migration') {
+          destinationBody = ['Waiting for migration to finish']
+        }
+
+        rows.push(this.renderRow(
+          `${instance.instance_name}-${nic.network_name}`,
+          'network',
+          nic.network_name,
+          destinationNetworkName,
+          sourceBody,
+          destinationBody
+        ))
+      }
+    })
+
+    return rows
+  }
+
+  renderInstanceDetails(instance: Instance) {
+    let getBody = (i: Instance): string[] => [
+      `Cores: ${i.num_cpu}`,
+      `Memory: ${i.memory_mb} MB`,
+      `Flavor Name: ${i.flavor_name || 'N/A'}`,
+      `OS Type: ${i.os_type}`,
+    ]
+
+    let sourceBody: string[] = getBody(instance)
+    let destinationBody: string[] = []
+    let destinationName: string = ''
+    let transferResult = this.getTransferResult(instance)
+    if (transferResult) {
+      destinationName = transferResult.instance_name
+      destinationBody = getBody(transferResult)
+    } else if (this.props.item && this.props.item.status === 'RUNNING' && this.props.item.type === 'migration') {
+      destinationName = 'Waiting for migration to finish'
+    }
+
+    return this.renderRow(
+      instance.instance_name,
+      'instance',
+      `${instance.instance_name}`,
+      `${destinationName}`,
+      sourceBody,
+      destinationBody
+    )
+  }
+
+  render() {
+    if (this.props.instancesDetails.length === 0 || !this.props.item) {
+      return null
+    }
+
+    return (
+      <Wrapper>
+        <Header>
+          <HeaderLabel>Source</HeaderLabel>
+          <HeaderLabel>Destination</HeaderLabel>
+        </Header>
+        {this.props.instancesDetails.map(instance => (
+          <InstanceInfo key={instance.name}>
+            <InstanceName>{instance.name}</InstanceName>
+            <InstanceBody>
+              {this.renderInstanceDetails(instance)}
+              {this.renderNetworks(instance)}
+              {this.renderStorage(instance)}
+            </InstanceBody>
+          </InstanceInfo>
+        ))}
+      </Wrapper>
+    )
+  }
+}
+
+export default MainDetailsTable

+ 14 - 0
src/components/molecules/MainDetailsTable/images/arrow.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="21px" height="14px" viewBox="0 0 21 14" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 52.4 (67378) - http://www.bohemiancoding.com/sketch -->
+    <title>Arrow</title>
+    <desc>Created with Sketch.</desc>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round">
+        <g id="Migration/Details/Overview-Open" transform="translate(-710.000000, -745.000000)" stroke="#A4AAB5" stroke-width="1.5">
+            <g id="Icon/Arrow/Small" transform="translate(704.000000, 744.000000)">
+                <polyline id="Path-181-Copy-4" stroke-linejoin="round" points="21 2.5 26 8 21 13.5"></polyline>
+                <path d="M7,8 L26,8" id="Line" transform="translate(16.500000, 8.000000) rotate(-180.000000) translate(-16.500000, -8.000000) "></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 15 - 0
src/components/molecules/MainDetailsTable/images/instance.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="14px" height="16px" viewBox="0 0 14 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 52.4 (67378) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon/Storage/16-2 Copy 2</title>
+    <desc>Created with Sketch.</desc>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Migration/Details/Overview-Closed" transform="translate(-337.000000, -649.000000)" stroke="#0044CA" stroke-width="1.5">
+            <g id="Icon/Network/16-Copy-3" transform="translate(336.000000, 649.000000)">
+                <polygon id="Page-1" stroke-linecap="round" stroke-linejoin="round" points="8 1 2 4.5 2 11.4750313 8 15 14 11.5 14 4.5"></polygon>
+                <polyline id="Path-3" stroke-linejoin="round" points="2 5 7.87559762 7.9545219 13.8755976 5"></polyline>
+                <path d="M7.87559762,7.70746946 L7.87559762,15.5342087" id="Path-4"></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 15 - 0
src/components/molecules/MainDetailsTable/images/network.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="14px" height="16px" viewBox="0 0 14 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 52.4 (67378) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon/Storage/16-2 Copy</title>
+    <desc>Created with Sketch.</desc>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Migration/Details/Overview-Closed" transform="translate(-336.000000, -681.000000)" stroke="#0044CA" stroke-width="1.5">
+            <g id="Icon/Network/16-Copy-2" transform="translate(336.000000, 681.000000)">
+                <path d="M0.5,8 L14,8" id="Path-5"></path>
+                <path d="M3,1 L3,2 C3,4.209139 4.790861,6 7,6 L9.96429232,6 L14,6" id="Path-6-Copy" stroke-linejoin="round" transform="translate(8.500000, 3.500000) scale(1, -1) translate(-8.500000, -3.500000) "></path>
+                <path d="M3,10 L3,11 C3,13.209139 4.790861,15 7,15 L9.96429232,15 L14,15" id="Path-6-Copy-2" stroke-linejoin="round"></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 16 - 0
src/components/molecules/MainDetailsTable/images/storage.svg

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 52.4 (67378) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon/Storage/16</title>
+    <desc>Created with Sketch.</desc>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Migration/Details/Overview-Closed" transform="translate(-336.000000, -745.000000)">
+            <g id="Icon/Storage/16-Copy-2" transform="translate(336.000000, 745.000000)">
+                <path d="M7,11 C6.44964029,11 6,11.4493709 6,11.9994008 C6,12.5494308 6.44964029,13 7,13 C7.55035971,13 8,12.5494308 8,11.9994008 C8,11.4493709 7.55035971,11 7,11" id="Fill-7-Copy-4" fill="#0044CA" fill-rule="evenodd"></path>
+                <path d="M4,11 C3.44964029,11 3,11.4493709 3,11.9994008 C3,12.5494308 3.44964029,13 4,13 C4.55035971,13 5,12.5494308 5,11.9994008 C5,11.4493709 4.55035971,11 4,11" id="Fill-7-Copy-5" fill="#0044CA" fill-rule="evenodd"></path>
+                <rect id="Rectangle-9-Copy-2" stroke="#0044CA" stroke-width="1.5" x="1" y="9" width="14" height="6" rx="1.5"></rect>
+                <path d="M1,10.3866757 C1,9.88540581 1.13769484,9.10377173 1.30967924,8.63504411 L3.80143187,1.84400221 C3.97246299,1.37787266 4.50214254,1 4.98820212,1 L11.0117979,1 C11.4962019,1 12.0265837,1.37527459 12.1985681,1.84400221 L14.6903208,8.63504411 C14.8613519,9.10117366 15,9.88591999 15,10.3866757" id="Path" stroke="#0044CA" stroke-width="1.5"></path>
+            </g>
+        </g>
+    </g>
+</svg>

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

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

+ 1 - 1
src/components/molecules/TaskItem/TaskItem.jsx

@@ -186,7 +186,7 @@ class TaskItem extends React.Component<Props> {
         <HeaderData width={this.props.columnWidths[3]}>
           {date ? DateUtils.getLocalTime(date).format('YYYY-MM-DD HH:mm:ss') : '-'}
         </HeaderData>
-        <ArrowStyled primary orientation={this.props.open ? 'up' : 'down'} opacity={this.props.open ? 1 : 0} />
+        <ArrowStyled primary orientation={this.props.open ? 'up' : 'down'} opacity={this.props.open ? 1 : 0} thick />
       </Header>
     )
   }

+ 6 - 2
src/components/molecules/WizardBreadcrumbs/WizardBreadcrumbs.jsx

@@ -27,7 +27,7 @@ const Wrapper = styled.div`
   display: flex;
   justify-content: center;
 `
-const ArrowStyled = styled(Arrow) ``
+const ArrowStyled = styled(Arrow)``
 const Breadcrumb = styled.div`
   display: flex;
   align-items: center;
@@ -44,11 +44,15 @@ const Name = styled.div`
 type Props = {
   selected: { id: string },
   wizardType: 'migration' | 'replica',
+  destinationProvider: ?string,
 }
 @observer
 class WizardBreadcrumbs extends React.Component<Props> {
   render() {
-    let pages = wizardConfig.pages.filter(p => !p.excludeFrom || p.excludeFrom !== this.props.wizardType)
+    let pages = wizardConfig.pages
+      .filter(p => !p.excludeFrom || p.excludeFrom !== this.props.wizardType)
+      .filter(p => !p.filter || (this.props.destinationProvider && p.filter(this.props.destinationProvider)))
+
     return (
       <Wrapper>
         {pages.map(page => {

+ 9 - 3
src/components/molecules/WizardBreadcrumbs/test.jsx

@@ -20,19 +20,19 @@ import WizardBreadcrumbs from '.'
 import TW from '../../../utils/TestWrapper'
 import { wizardConfig } from '../../../config'
 
-const wrap = props => new TW(shallow(<WizardBreadcrumbs {...props} />), 'wBreadCrumbs')
+const wrap = props => new TW(shallow(<WizardBreadcrumbs destinationProvider="oci" {...props} />), 'wBreadCrumbs')
 
 describe('WizardBreadcrumbs Component', () => {
   it('renders correct number of crumbs for replica', () => {
     let wrapper = wrap({ selected: wizardConfig.pages[2], wizardType: 'replica' })
     let pages = wizardConfig.pages.filter(p => !p.excludeFrom || p.excludeFrom !== 'replica')
-    expect(wrapper.find('name-', true).length).toBe(pages.length)
+    expect(wrapper.find('name-', true).length).toBe(pages.length - 1)
   })
 
   it('renders correct number of crumbs for migration', () => {
     let wrapper = wrap({ selected: wizardConfig.pages[2], wizardType: 'migration' })
     let pages = wizardConfig.pages.filter(p => !p.excludeFrom || p.excludeFrom !== 'migration')
-    expect(wrapper.find('name-', true).length).toBe(pages.length)
+    expect(wrapper.find('name-', true).length).toBe(pages.length - 1)
   })
 
   it('has correct page selected', () => {
@@ -40,4 +40,10 @@ describe('WizardBreadcrumbs Component', () => {
     let wrapper = wrap({ selected: pages[2], wizardType: 'migration' })
     expect(wrapper.findText(`name-${pages[2].id}`)).toBe(pages[2].breadcrumb)
   })
+
+  it('renders correct number of crumbs for Openstack', () => {
+    let wrapper = wrap({ selected: wizardConfig.pages[2], wizardType: 'migration', destinationProvider: 'openstack' })
+    let pages = wizardConfig.pages.filter(p => !p.excludeFrom || p.excludeFrom !== 'migration')
+    expect(wrapper.find('name-', true).length).toBe(pages.length)
+  })
 })

+ 20 - 45
src/components/organisms/MainDetails/MainDetails.jsx

@@ -22,7 +22,7 @@ import EndpointLogos from '../../atoms/EndpointLogos'
 import CopyValue from '../../atoms/CopyValue'
 import StatusIcon from '../../atoms/StatusIcon'
 import StatusImage from '../../atoms/StatusImage'
-import Table from '../../molecules/Table'
+import MainDetailsTable from '../../molecules/MainDetailsTable'
 import CopyMultilineValue from '../../atoms/CopyMultilineValue'
 
 import type { Instance } from '../../../types/Instance'
@@ -80,10 +80,6 @@ const ValueLink = styled.a`
   text-decoration: none;
   cursor: pointer;
 `
-const TableStyled = styled(Table)`
-  margin-top: 89px;
-  margin-bottom: 48px;
-`
 const Loading = styled.div`
   display: flex;
   justify-content: center;
@@ -205,27 +201,6 @@ class MainDetails extends React.Component<Props> {
     return <CopyValue value={value} maxWidth="90%" data-test-id={dateTestId ? `mainDetails-${dateTestId}` : undefined} />
   }
 
-  renderNetworksTable() {
-    if (this.props.loading) {
-      return null
-    }
-
-    let items = this.getNetworks()
-
-    if (!items || !items.length) {
-      return null
-    }
-
-    return (
-      <TableStyled
-        header={['Source Network', 'Connected VMs', 'Destination Network', 'Destination Type']}
-        items={items}
-        columnsStyle={[css`color: ${Palette.black};`]}
-        data-test-id="mainDetails-networksTable"
-      />
-    )
-  }
-
   renderEndpointLink(type: string): React.Node {
     let endpointIsMissing = (
       <Value flex data-test-id={`mainDetails-missing-${type}`}>
@@ -250,6 +225,9 @@ class MainDetails extends React.Component<Props> {
       if (value === false) {
         return 'No'
       }
+      if (value.join && value.length && value[0].destination && value[0].source) {
+        return value.map(v => `${v.source}=${v.destination}`).join(', ')
+      }
       return value.toString()
     }
 
@@ -266,6 +244,9 @@ class MainDetails extends React.Component<Props> {
         })
       } else if (value && typeof value === 'object') {
         properties = properties.concat(Object.keys(value).map(p => {
+          if (p === 'disk_mappings') {
+            return null
+          }
           return {
             label: `${label} - ${LabelDictionary.get(p)}`,
             value: getValue(value[p]),
@@ -279,6 +260,9 @@ class MainDetails extends React.Component<Props> {
     return (
       <PropertiesTable>
         {properties.map(prop => {
+          if (prop == null) {
+            return null
+          }
           return (
             <PropertyRow key={prop.label}>
               <PropertyName>{prop.label}</PropertyName>
@@ -301,7 +285,7 @@ class MainDetails extends React.Component<Props> {
 
     return (
       <ColumnsLayout>
-        <Column width="34.5%">
+        <Column width="42.5%">
           <Row>
             <Field>
               <Label>Source</Label>
@@ -335,12 +319,6 @@ class MainDetails extends React.Component<Props> {
                 : <Value>-</Value>}
             </Field>
           </Row>
-          <Row>
-            <Field>
-              <Label>Type</Label>
-              <Value capitalize data-test-id="mainDetails-type">Coriolis {this.props.item && this.props.item.type}</Value>
-            </Field>
-          </Row>
           <Row>
             <Field>
               <Label>Last Updated</Label>
@@ -348,7 +326,7 @@ class MainDetails extends React.Component<Props> {
             </Field>
           </Row>
         </Column>
-        <Column width="17.5%">
+        <Column width="9.5%">
           <Arrow />
         </Column>
         <Column width="48%" style={{ flexGrow: 1 }}>
@@ -372,14 +350,6 @@ class MainDetails extends React.Component<Props> {
               </Field>
             </Row>
           ) : null}
-          {this.props.item && this.props.item.instances ? (
-            <Row>
-              <Field>
-                <Label>Instances</Label>
-                <CopyMultilineValue value={this.props.item.instances.join('<br />')} useDangerousHtml />
-              </Field>
-            </Row>
-          ) : null}
         </Column>
       </ColumnsLayout>
     )
@@ -394,7 +364,7 @@ class MainDetails extends React.Component<Props> {
   }
 
   renderLoading() {
-    if (!this.props.loading) {
+    if (!this.props.loading && !this.props.instancesDetailsLoading) {
       return null
     }
 
@@ -409,9 +379,14 @@ class MainDetails extends React.Component<Props> {
     return (
       <Wrapper>
         {this.renderTable()}
-        {this.renderNetworksTable()}
-        {this.renderBottomControls()}
+        {this.props.instancesDetailsLoading || this.props.loading ? null : (
+          <MainDetailsTable
+            item={this.props.item}
+            instancesDetails={this.props.instancesDetails}
+          />
+        )}
         {this.renderLoading()}
+        {this.renderBottomControls()}
       </Wrapper>
     )
   }

+ 0 - 21
src/components/organisms/MainDetails/test.jsx

@@ -37,9 +37,6 @@ let item = {
   instances: ['instance_1'],
   destination_environment: {
     description: 'A description',
-    network_map: {
-      network_1: 'Mapping 1',
-    },
   },
   type: 'Replica',
 }
@@ -73,24 +70,6 @@ describe('MainDetails Component', () => {
     expect(wrapper.find('targetLogo').prop('endpoint')).toBe('azure')
   })
 
-  it('renders network_map', () => {
-    let wrapper = wrap({ item, endpoints, instancesDetails })
-    let tableItems = wrapper.find('networksTable').prop('items')
-    expect(tableItems.length).toBe(1)
-    expect(tableItems[0].length).toBe(4)
-    expect(tableItems[0][0]).toBe('network_1')
-    expect(new TW(shallow(tableItems[0][1][0])).find('vm-', true).text()).toBe('instance_1')
-    expect(tableItems[0][2]).toBe('Mapping 1')
-    expect(tableItems[0][3]).toBe('Existing network')
-    expect(wrapper.find('loading').length).toBe(0)
-  })
-
-  it('renders network map with missing source instance', () => {
-    let wrapper = wrap({ item, endpoints, instancesDetails: [] })
-    let tableItems = wrapper.find('networksTable').prop('items')
-    expect(tableItems[0][1]).toBe('Failed to read network configuration for the original instance')
-  })
-
   it('renders loading', () => {
     let wrapper = wrap({ item: {}, endpoints: [], loading: true })
     expect(wrapper.find('loading').length).toBe(1)

+ 7 - 0
src/components/organisms/WizardOptions/WizardOptions.jsx

@@ -26,6 +26,7 @@ import WizardOptionsField from '../../molecules/WizardOptionsField'
 import StatusImage from '../../atoms/StatusImage'
 import type { Field } from '../../../types/Field'
 import type { Instance } from '../../../types/Instance'
+import type { Storage } from '../../../types/Endpoint'
 
 import { executionOptions } from '../../../config'
 
@@ -61,6 +62,8 @@ type Props = {
   data: ?{ [string]: mixed },
   onChange: (field: Field, value: any) => void,
   useAdvancedOptions: boolean,
+  hasStorageMap: boolean,
+  storage: Storage[],
   onAdvancedOptionsToggle: (showAdvanced: boolean) => void,
   wizardType: string,
   loading: boolean,
@@ -115,6 +118,10 @@ class WizardOptions extends React.Component<Props> {
       }
     }
 
+    if (this.props.hasStorageMap && this.props.useAdvancedOptions && this.props.storage.length > 0) {
+      fieldsSchema.push({ name: 'default_storage', type: 'string', enum: this.props.storage.map(s => s.name) })
+    }
+
     return fieldsSchema
   }
 

+ 38 - 7
src/components/organisms/WizardPageContent/WizardPageContent.jsx

@@ -25,6 +25,7 @@ import WizardBreadcrumbs from '../../molecules/WizardBreadcrumbs'
 import WizardEndpointList from '../WizardEndpointList'
 import WizardInstances from '../WizardInstances'
 import WizardNetworks from '../WizardNetworks'
+import WizardStorage from '../WizardStorage'
 import WizardOptions from '../WizardOptions'
 import Schedule from '../Schedule'
 import WizardSummary from '../WizardSummary'
@@ -33,16 +34,17 @@ import StyleProps from '../../styleUtils/StyleProps'
 import Palette from '../../styleUtils/Palette'
 import { providerTypes, wizardConfig } from '../../../config'
 import type { WizardData } from '../../../types/WizardData'
-import type { Endpoint } from '../../../types/Endpoint'
-import type { Instance, Nic } from '../../../types/Instance'
+import type { Endpoint, Storage, StorageMap } from '../../../types/Endpoint'
+import type { Instance, Nic, Disk } from '../../../types/Instance'
 import type { Field } from '../../../types/Field'
 import type { Network } from '../../../types/Network'
 import type { Schedule as ScheduleType } from '../../../types/Schedule'
 import instanceStore from '../../../stores/InstanceStore'
 import providerStore from '../../../stores/ProviderStore'
+import endpointStore from '../../../stores/EndpointStore'
+import networkStore from '../../../stores/NetworkStore'
 
 import migrationArrowImage from './images/migration.js'
-import networkStore from '../../../stores/NetworkStore'
 
 const Wrapper = styled.div`
   ${StyleProps.exactWidth(`${parseInt(StyleProps.contentWidth, 10) + 64}px`)}
@@ -97,9 +99,11 @@ type Props = {
   providerStore: typeof providerStore,
   instanceStore: typeof instanceStore,
   networkStore: typeof networkStore,
+  endpointStore: typeof endpointStore,
   wizardData: WizardData,
-  endpoints: Endpoint[],
   schedules: ScheduleType[],
+  storageMap: StorageMap[],
+  hasStorageMap: boolean,
   onTypeChange: (isReplicaChecked: ?boolean) => void,
   onBackClick: () => void,
   onNextClick: () => void,
@@ -112,6 +116,7 @@ type Props = {
   onInstancePageClick: (page: number) => void,
   onOptionsChange: (field: Field, value: any) => void,
   onNetworkChange: (nic: Nic, network: Network) => void,
+  onStorageChange: (sourceStorage: Disk, targetStorage: Storage, type: 'backend' | 'disk') => void,
   onAddScheduleClick: (schedule: ScheduleType) => void,
   onScheduleChange: (scheduleId: string, schedule: ScheduleType) => void,
   onScheduleRemove: (scheudleId: string) => void,
@@ -287,7 +292,7 @@ class WizardPageContent extends React.Component<Props, State> {
             loading={this.props.providerStore.providersLoading}
             otherEndpoint={this.props.wizardData.target}
             selectedEndpoint={this.props.wizardData.source}
-            endpoints={this.props.endpoints}
+            endpoints={this.props.endpointStore.endpoints}
             onChange={this.props.onSourceEndpointChange}
             onAddEndpoint={type => { this.props.onAddEndpoint(type, true) }}
           />
@@ -300,7 +305,7 @@ class WizardPageContent extends React.Component<Props, State> {
             loading={this.props.providerStore.providersLoading}
             otherEndpoint={this.props.wizardData.source}
             selectedEndpoint={this.props.wizardData.target}
-            endpoints={this.props.endpoints}
+            endpoints={this.props.endpointStore.endpoints}
             onChange={this.props.onTargetEndpointChange}
             onAddEndpoint={type => { this.props.onAddEndpoint(type, false) }}
           />
@@ -335,6 +340,8 @@ class WizardPageContent extends React.Component<Props, State> {
             onChange={this.props.onOptionsChange}
             data={this.props.wizardData.options}
             useAdvancedOptions={this.state.useAdvancedOptions}
+            hasStorageMap={this.props.hasStorageMap}
+            storage={this.props.endpointStore.storage}
             wizardType={this.props.type}
             onAdvancedOptionsToggle={useAdvancedOptions => { this.handleAdvancedOptionsToggle(useAdvancedOptions) }}
           />
@@ -352,6 +359,17 @@ class WizardPageContent extends React.Component<Props, State> {
           />
         )
         break
+      case 'storage':
+        body = (
+          <WizardStorage
+            storage={this.props.endpointStore.storage}
+            instancesDetails={this.props.instanceStore.instancesDetails}
+            storageMap={this.props.storageMap}
+            defaultStorage={String(this.props.wizardData.options ? this.props.wizardData.options.default_storage : '')}
+            onChange={this.props.onStorageChange}
+          />
+        )
+        break
       case 'schedule':
         body = (
           <Schedule
@@ -370,7 +388,16 @@ class WizardPageContent extends React.Component<Props, State> {
           <WizardSummary
             data={this.props.wizardData}
             schedules={this.props.schedules}
+            storageMap={this.props.storageMap}
             wizardType={this.props.type}
+            instancesDetails={this.props.instanceStore.instancesDetails}
+            defaultStorage={
+              this.props.endpointStore.storage.find(
+                s => this.props.wizardData.options ?
+                  s.name === this.props.wizardData.options.default_storage :
+                  false
+              )
+            }
           />
         )
         break
@@ -418,7 +445,11 @@ class WizardPageContent extends React.Component<Props, State> {
         {this.renderBody()}
         <Footer>
           {this.renderNavigationActions()}
-          <WizardBreadcrumbs selected={this.props.page} wizardType={this.props.type} />
+          <WizardBreadcrumbs
+            selected={this.props.page}
+            wizardType={this.props.type}
+            destinationProvider={this.props.wizardData.target ? this.props.wizardData.target.type : null}
+          />
         </Footer>
       </Wrapper>
     )

+ 246 - 0
src/components/organisms/WizardStorage/WizardStorage.jsx

@@ -0,0 +1,246 @@
+/*
+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 { observer } from 'mobx-react'
+import styled from 'styled-components'
+
+import Dropdown from '../../molecules/Dropdown'
+
+import Palette from '../../styleUtils/Palette'
+import StyleProps from '../../styleUtils/StyleProps'
+import type { Instance, Disk } from '../../../types/Instance'
+import type { Storage, StorageMap } from '../../../types/Endpoint'
+
+import backendImage from './images/backend.svg'
+import diskImage from './images/disk.svg'
+import bigStorageImage from './images/storage-big.svg'
+import arrowImage from './images/arrow.svg'
+
+const Wrapper = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: center;
+`
+const Mapping = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+`
+const StorageWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  margin-bottom: 32px;
+  max-height: 100%;
+  min-height: 100px;
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+`
+const StorageSection = styled.div`
+  margin-bottom: 16px;
+  font-size: 24px;
+  font-weight: ${StyleProps.fontWeights.light};
+`
+const StorageItems = styled.div`
+  display: flex;
+  flex-direction: column;
+  overflow: auto;
+`
+const StorageItem = styled.div`
+  display: flex;
+  align-items: center;
+  flex-shrink: 0;
+  border-top: 1px solid ${Palette.grayscale[1]};
+  padding: 8px 0;
+
+  &:last-child {
+    border-bottom: 1px solid ${Palette.grayscale[1]};
+  }
+`
+const StorageImage = styled.div`
+  width: 48px;
+  height: 48px;
+  background: url('${props => props.backend ? backendImage : diskImage}') center no-repeat;
+  margin-right: 16px;
+`
+const StorageTitle = styled.div`
+  width: 320px;
+`
+const StorageName = styled.div`
+  font-size: 16px;
+`
+const StorageSubtitle = styled.div`
+  font-size: 12px;
+  color: ${Palette.grayscale[5]};
+  margin-top: 1px;
+`
+const ArrowImage = styled.div`
+  width: 32px;
+  height: 16px;
+  background: url('${arrowImage}') center no-repeat;
+  flex-grow: 1;
+  margin-right: 16px;
+`
+const NoStorageMessage = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-top: 64px;
+  width: 440px;
+`
+const BigStorageImage = styled.div`
+  margin-bottom: 46px;
+  ${StyleProps.exactSize('96px')}
+  background: url('${bigStorageImage}') center no-repeat;
+`
+const NoStorageTitle = styled.div`
+  margin-bottom: 10px;
+  font-size: 18px;
+`
+const NoStorageSubtitle = styled.div`
+  color: ${Palette.grayscale[4]};
+  text-align: center;
+`
+
+export const getDisks = (instancesDetails: Instance[], type: 'backend' | 'disk'): Disk[] => {
+  let fieldName = type === 'backend' ? 'storage_backend_identifier' : 'id'
+
+  let disks = []
+  instancesDetails.forEach(instance => {
+    if (!instance.devices || !instance.devices.disks) {
+      return
+    }
+    instance.devices.disks.forEach(disk => {
+      if (disks.find(d => d[fieldName] === disk[fieldName])) {
+        return
+      }
+      disks.push(disk)
+    })
+  })
+  return disks
+}
+
+type Props = {
+  storage: Storage[],
+  instancesDetails: Instance[],
+  storageMap: ?StorageMap[],
+  defaultStorage: ?string,
+  onChange: (sourceStorage: Disk, targetStorage: Storage, type: 'backend' | 'disk') => void,
+}
+@observer
+class WizardStorage extends React.Component<Props> {
+  renderNoStorage() {
+    return (
+      <NoStorageMessage>
+        <BigStorageImage />
+        <NoStorageTitle>No storage backends were found</NoStorageTitle>
+        <NoStorageSubtitle>We could not find any storage backends. Coriolis will skip this step.</NoStorageSubtitle>
+      </NoStorageMessage>
+    )
+  }
+
+  renderStorageWrapper(disks: Disk[], type: 'backend' | 'disk') {
+    let title = type === 'backend' ? 'Storage Backend Mapping' : 'Disk Mapping'
+    let diskFieldName = type === 'backend' ? 'storage_backend_identifier' : 'id'
+    let storageMap = this.props.storageMap
+    let storageItems = [
+      { name: 'Default', id: null },
+      ...this.props.storage,
+    ]
+
+    disks = disks.filter(d => d[diskFieldName])
+    disks.sort((d1, d2) => String(d1[diskFieldName]).localeCompare(String(d2[diskFieldName])))
+
+    return (
+      <StorageWrapper>
+        <StorageSection>{title}</StorageSection>
+        <StorageItems>
+          {disks.map(disk => {
+            let connectedTo = this.props.instancesDetails.filter(i => {
+              if (!i.devices || !i.devices.disks) {
+                return false
+              }
+              if (i.devices.disks.find(d => d[diskFieldName] === disk[diskFieldName])) {
+                return true
+              }
+              return false
+            }).map(i => i.instance_name)
+            let selectedItem = storageMap && storageMap.find(s => s.type === type && s.source[diskFieldName] === disk[diskFieldName])
+            selectedItem = selectedItem ? selectedItem.target : storageItems.find(i => i.name === this.props.defaultStorage)
+            return (
+              <StorageItem key={disk[diskFieldName]}>
+                <StorageImage backend={type === 'backend'} />
+                <StorageTitle>
+                  <StorageName>{disk[diskFieldName]}</StorageName>
+                  <StorageSubtitle>{`Connected to ${connectedTo.join(', ')}`}</StorageSubtitle>
+                </StorageTitle>
+                <ArrowImage />
+                <Dropdown
+                  large
+                  centered
+                  noSelectionMessage="Default"
+                  noItemsMessage="No storage found"
+                  selectedItem={selectedItem}
+                  items={storageItems}
+                  labelField="name"
+                  valueField="id"
+                  onChange={(item: Storage) => { this.props.onChange(disk, item, type) }}
+                />
+              </StorageItem>
+            )
+          })}
+        </StorageItems>
+      </StorageWrapper>
+    )
+  }
+
+  renderBackendMapping() {
+    let disks = getDisks(this.props.instancesDetails, 'backend')
+
+    if (disks.length === 0 || this.props.storage.length === 0) {
+      return null
+    }
+
+    return this.renderStorageWrapper(disks, 'backend')
+  }
+
+  renderDiskMapping() {
+    let disks = getDisks(this.props.instancesDetails, 'disk')
+
+    if (disks.length === 0 || this.props.storage.length === 0) {
+      return this.renderNoStorage()
+    }
+
+    return this.renderStorageWrapper(disks, 'disk')
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <Mapping>
+          {this.renderBackendMapping()}
+          {this.renderDiskMapping()}
+        </Mapping>
+      </Wrapper>
+    )
+  }
+}
+
+export default WizardStorage

+ 15 - 0
src/components/organisms/WizardStorage/images/arrow.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="20px" height="12px" viewBox="0 0 20 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 47.1 (45422) - http://www.bohemiancoding.com/sketch -->
+    <title>Arrow</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Wizard/04-Networks---List-Done" transform="translate(-711.000000, -250.000000)" stroke-width="1.5" stroke="#A4AAB5">
+            <g id="Icon/Arrow/Small" transform="translate(704.000000, 248.000000)">
+                <polyline id="Path-181-Copy-4" points="21 2.5 26 8 21 13.5"></polyline>
+                <path d="M7,8 L26,8" id="Line" transform="translate(16.500000, 8.000000) rotate(-180.000000) translate(-16.500000, -8.000000) "></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 41 - 0
src/components/organisms/WizardStorage/images/backend.svg

@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 52.4 (67378) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon-CentOS</title>
+    <desc>Created with Sketch.</desc>
+    <defs>
+        <path d="M0,0 L0,13.0499552 C0,15.7837895 5.82029825,18 13,18 C20.1797017,18 26,15.7837895 26,13.0499552 L26,0" id="path-1"></path>
+        <path d="M0,8 L0,20.0132882 C0,22.7673731 5.82029825,25 13,25 C20.1797017,25 26,22.7673731 26,20.0132882 L26,8" id="path-2"></path>
+        <path d="M5.68434189e-14,5 L5.68434189e-14,12.1999566 C5.68434189e-14,14.8509474 5.82029825,17 13,17 C20.1797017,17 26,14.8509474 26,12.1999566 L26,5" id="path-3"></path>
+    </defs>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Wizard/06-Storage" transform="translate(-256.000000, -280.000000)">
+            <g id="Elements/Item/Wizard/Network-Copy-2" transform="translate(256.000000, 272.000000)">
+                <g id="Icon/Storage/Volume" transform="translate(0.000000, 8.000000)">
+                    <path d="M10.2555408,-4.14336123e-16 L37.7444592,4.14336123e-16 C41.3105342,-2.4074122e-16 42.6036791,0.371302445 43.9073828,1.06853082 C45.2110865,1.76575919 46.2342408,2.78891348 46.9314692,4.09261719 C47.6286976,5.39632089 48,6.68946584 48,10.2555408 L48,37.7444592 C48,41.3105342 47.6286976,42.6036791 46.9314692,43.9073828 C46.2342408,45.2110865 45.2110865,46.2342408 43.9073828,46.9314692 C42.6036791,47.6286976 41.3105342,48 37.7444592,48 L10.2555408,48 C6.68946584,48 5.39632089,47.6286976 4.09261719,46.9314692 C2.78891348,46.2342408 1.76575919,45.2110865 1.06853082,43.9073828 C0.371302445,42.6036791 1.60494146e-16,41.3105342 -2.76224082e-16,37.7444592 L2.76224082e-16,10.2555408 C-1.60494146e-16,6.68946584 0.371302445,5.39632089 1.06853082,4.09261719 C1.76575919,2.78891348 2.78891348,1.76575919 4.09261719,1.06853082 C5.39632089,0.371302445 6.68946584,2.4074122e-16 10.2555408,-4.14336123e-16 Z" id="Rectangle-6" fill="#D8DBE2"></path>
+                    <g id="Group" stroke-width="1" transform="translate(11.000000, 8.000000)">
+                        <rect id="Rectangle" fill="#D8DBE2" x="0" y="4" width="26" height="7"></rect>
+                        <g id="Group-Copy-4" transform="translate(0.000000, 14.000000)" stroke-linecap="round" stroke-linejoin="round">
+                            <g id="Shape">
+                                <use fill="#D8DBE2" fill-rule="evenodd" xlink:href="#path-1"></use>
+                                <use stroke="#979797" stroke-width="1" xlink:href="#path-1"></use>
+                                <use stroke="#0044CA" stroke-width="1.5" xlink:href="#path-1"></use>
+                            </g>
+                        </g>
+                        <g id="Shape" stroke-linecap="round" stroke-linejoin="round">
+                            <use fill="#D8DBE2" fill-rule="evenodd" xlink:href="#path-2"></use>
+                            <use stroke="#979797" stroke-width="1" xlink:href="#path-2"></use>
+                            <use stroke="#0044CA" stroke-width="1.5" xlink:href="#path-2"></use>
+                        </g>
+                        <g id="Shape-Copy" stroke-linecap="round" stroke-linejoin="round">
+                            <use fill="#D8DBE2" fill-rule="evenodd" xlink:href="#path-3"></use>
+                            <use stroke="#979797" stroke-width="1" xlink:href="#path-3"></use>
+                            <use stroke="#0044CA" stroke-width="1.5" xlink:href="#path-3"></use>
+                        </g>
+                        <ellipse id="Oval" stroke="#0044CA" stroke-width="1.5" fill="#D8DBE2" stroke-linecap="round" cx="13" cy="5" rx="13" ry="5"></ellipse>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 21 - 0
src/components/organisms/WizardStorage/images/disk.svg

@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 52.4 (67378) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon-CentOS</title>
+    <desc>Created with Sketch.</desc>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Wizard/06-Storage" transform="translate(-256.000000, -568.000000)">
+            <g id="Elements/Item/Wizard/Network-Copy-2" transform="translate(256.000000, 560.000000)">
+                <g id="Icon/Storage/Disk" transform="translate(0.000000, 8.000000)">
+                    <path d="M10.2555408,-4.14336123e-16 L37.7444592,4.14336123e-16 C41.3105342,-2.4074122e-16 42.6036791,0.371302445 43.9073828,1.06853082 C45.2110865,1.76575919 46.2342408,2.78891348 46.9314692,4.09261719 C47.6286976,5.39632089 48,6.68946584 48,10.2555408 L48,37.7444592 C48,41.3105342 47.6286976,42.6036791 46.9314692,43.9073828 C46.2342408,45.2110865 45.2110865,46.2342408 43.9073828,46.9314692 C42.6036791,47.6286976 41.3105342,48 37.7444592,48 L10.2555408,48 C6.68946584,48 5.39632089,47.6286976 4.09261719,46.9314692 C2.78891348,46.2342408 1.76575919,45.2110865 1.06853082,43.9073828 C0.371302445,42.6036791 1.60494146e-16,41.3105342 -2.76224082e-16,37.7444592 L2.76224082e-16,10.2555408 C-1.60494146e-16,6.68946584 0.371302445,5.39632089 1.06853082,4.09261719 C1.76575919,2.78891348 2.78891348,1.76575919 4.09261719,1.06853082 C5.39632089,0.371302445 6.68946584,2.4074122e-16 10.2555408,-4.14336123e-16 Z" id="Rectangle-6" fill="#D8DBE2"></path>
+                    <g id="Group-7" stroke-width="1" transform="translate(8.000000, 9.000000)">
+                        <rect id="Rectangle-9-Copy-2" stroke="#0044CA" stroke-width="1.5" x="0" y="18" width="32" height="12" rx="3"></rect>
+                        <path d="M10.0889749,22.7272727 C9.33954886,22.7272727 8.72727273,23.339182 8.72727273,24.088159 C8.72727273,24.8371359 9.33954886,25.450677 10.0889749,25.450677 C10.8384008,25.450677 11.450677,24.8371359 11.450677,24.088159 C11.450677,23.339182 10.8384008,22.7272727 10.0889749,22.7272727" id="Fill-7-Copy-4" fill="#0044CA"></path>
+                        <path d="M5.72533849,22.7272727 C4.9759125,22.7272727 4.36363636,23.339182 4.36363636,24.088159 C4.36363636,24.8371359 4.9759125,25.450677 5.72533849,25.450677 C6.47476448,25.450677 7.08704062,24.8371359 7.08704062,24.088159 C7.08704062,23.339182 6.47476448,22.7272727 5.72533849,22.7272727" id="Fill-7-Copy-5" fill="#0044CA"></path>
+                        <path d="M0,20.7733513 C0,19.6640071 0.314731068,17.9341977 0.70783827,16.8968716 L6.40327284,1.86783426 C6.79420112,0.836257883 8.00489723,0 9.11589056,0 L22.8841094,0 C23.9913186,0 25.20362,0.830508177 25.5967272,1.86783426 L31.2921617,16.8968716 C31.68309,17.928448 32,19.665145 32,20.7733513" id="Path" stroke="#0044CA" stroke-width="1.5"></path>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 18 - 0
src/components/organisms/WizardStorage/images/storage-big.svg

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="96px" height="93px" viewBox="0 0 96 93" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 52.4 (67378) - http://www.bohemiancoding.com/sketch -->
+    <title>splash-1</title>
+    <desc>Created with Sketch.</desc>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Wizard/06-Storage---Empty" transform="translate(-672.000000, -289.000000)" stroke="#0044CA" stroke-width="1.5">
+            <g id="Splash-Copy" transform="translate(501.000000, 287.000000)">
+                <g id="Icon/Storage/96" transform="translate(171.000000, 0.000000)">
+                    <rect id="Rectangle-9-Copy-2" x="1" y="62" width="94" height="32" rx="9"></rect>
+                    <path d="M12,74 C9.79856115,74 8,75.7974835 8,77.9976034 C8,80.1977232 9.79856115,82 12,82 C14.2014388,82 16,80.1977232 16,77.9976034 C16,75.7974835 14.2014388,74 12,74" id="Fill-7-Copy-4" stroke-linecap="round"></path>
+                    <path d="M24,74 C21.7985612,74 20,75.7974835 20,77.9976034 C20,80.1977232 21.7985612,82 24,82 C26.2014388,82 28,80.1977232 28,77.9976034 C28,75.7974835 26.2014388,74 24,74" id="Fill-7-Copy-5" stroke-linecap="round"></path>
+                    <path d="M1,70.5502738 C1,66.9429354 1.92452251,61.3179838 3.07927492,57.9448323 L19.809614,9.07377759 C20.9579658,5.71932285 24.5143856,3 27.7779285,3 L68.2220715,3 C71.4744983,3 75.0356336,5.7006261 76.190386,9.07377759 L92.9207251,57.9448323 C94.0690769,61.299287 95,66.9466356 95,70.5502738" id="Path"></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

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

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

+ 56 - 1
src/components/organisms/WizardSummary/WizardSummary.jsx

@@ -27,6 +27,10 @@ import LabelDictionary from '../../../utils/LabelDictionary'
 import DateUtils from '../../../utils/DateUtils'
 import type { Schedule } from '../../../types/Schedule'
 import type { WizardData } from '../../../types/WizardData'
+import type { StorageMap, Storage } from '../../../types/Endpoint'
+import type { Instance, Disk } from '../../../types/Instance'
+
+import { getDisks } from '../WizardStorage'
 
 import networkArrowImage from './images/network-arrow.svg'
 
@@ -138,6 +142,9 @@ type Props = {
   data: WizardData,
   wizardType: 'replica' | 'migration',
   schedules: Schedule[],
+  storageMap: StorageMap[],
+  instancesDetails: Instance[],
+  defaultStorage: ?Storage,
 }
 @observer
 class WizardSummary extends React.Component<Props> {
@@ -255,7 +262,12 @@ class WizardSummary extends React.Component<Props> {
           {this.props.wizardType === 'replica' ? executeNowOption : null}
           {this.props.data.selectedInstances && this.props.data.selectedInstances.length > 1 ? separateVmOption : null}
           {data.options ? Object.keys(data.options).map(optionName => {
-            if (optionName === 'execute_now' || optionName === 'separate_vm' || !data.options || data.options[optionName] == null) {
+            if (
+              optionName === 'execute_now' ||
+              optionName === 'separate_vm' ||
+              optionName === 'default_stoage' ||
+              !data.options || data.options[optionName] == null
+            ) {
               return null
             }
 
@@ -276,6 +288,47 @@ class WizardSummary extends React.Component<Props> {
     )
   }
 
+  renderStorageSection(type: 'backend' | 'disk') {
+    let storageMap = this.props.storageMap.filter(mapping => mapping.type === type)
+    let disks = getDisks(this.props.instancesDetails, type)
+
+    if (disks.length === 0 || (storageMap.length === 0 && !this.props.defaultStorage)) {
+      return null
+    }
+    let fieldName = type === 'backend' ? 'storage_backend_identifier' : 'id'
+
+    let fullStorageMap: { source: Disk, target: ?Storage }[] = disks.filter(d => d[fieldName]).map(disk => {
+      let diskMapped = storageMap.find(s => s.source[fieldName] === disk[fieldName])
+      if (diskMapped) {
+        return { source: diskMapped.source, target: diskMapped.target }
+      }
+      return { source: disk, target: this.props.defaultStorage }
+    })
+
+    fullStorageMap.sort((m1, m2) => String(m1.source[fieldName]).localeCompare(String(m2.source[fieldName])))
+    let title = type === 'backend' ? 'Storage Backend Mapping' : 'Disk Mapping'
+
+    return (
+      <Section>
+        <SectionTitle>{title}</SectionTitle>
+        <Table>
+          {fullStorageMap.filter(m => m.target).map(mapping => {
+            return (
+              <Row
+                key={`${type}-${mapping.source[fieldName] || ''}-${mapping.target ? mapping.target.name : ''}`}
+                direction="row"
+              >
+                <SourceNetwork>{mapping.source[fieldName]}</SourceNetwork>
+                <NetworkArrow />
+                <TargetNetwork>{mapping.target ? mapping.target.name : 'Default'}</TargetNetwork>
+              </Row>
+            )
+          })}
+        </Table>
+      </Section>
+    )
+  }
+
   renderNetworksSection() {
     let data = this.props.data
 
@@ -380,6 +433,8 @@ class WizardSummary extends React.Component<Props> {
         </Column>
         <Column>
           {this.renderOptionsSection()}
+          {this.renderStorageSection('backend')}
+          {this.renderStorageSection('disk')}
           {this.renderScheduleSection()}
         </Column>
       </Wrapper>

+ 1 - 1
src/components/organisms/WizardSummary/test.jsx

@@ -21,7 +21,7 @@ import WizardSummary from '.'
 
 const wrap = props => new TW(shallow(
   // $FlowIgnore
-  <WizardSummary {...props} />
+  <WizardSummary storageMap={[]} instancesDetails={[]} {...props} />
 ), 'wSummary')
 
 let schedules = [

+ 36 - 16
src/components/pages/WizardPage/WizardPage.jsx

@@ -37,8 +37,8 @@ import replicaStore from '../../../stores/ReplicaStore'
 import KeyboardManager from '../../../utils/KeyboardManager'
 import { wizardConfig, executionOptions, providersWithExtraOptions } from '../../../config'
 import type { MainItem } from '../../../types/MainItem'
-import type { Endpoint as EndpointType } from '../../../types/Endpoint'
-import type { Instance, Nic } from '../../../types/Instance'
+import type { Endpoint as EndpointType, Storage } from '../../../types/Endpoint'
+import type { Instance, Nic, Disk } from '../../../types/Instance'
 import type { Field } from '../../../types/Field'
 import type { Network } from '../../../types/Network'
 import type { Schedule } from '../../../types/Schedule'
@@ -76,6 +76,12 @@ class WizardPage extends React.Component<Props, State> {
     return Math.min(max, Math.max(min, Math.floor((window.innerHeight - instancesTableDiff) / instancesItemHeight)))
   }
 
+  get pages() {
+    return wizardConfig.pages
+      .filter(p => !p.excludeFrom || p.excludeFrom !== this.state.type)
+      .filter(p => !p.filter || (wizardStore.data.target && p.filter(wizardStore.data.target.type)))
+  }
+
   componentWillMount() {
     this.initializeState()
     this.handleResize()
@@ -162,35 +168,34 @@ class WizardPage extends React.Component<Props, State> {
   }
 
   handleBackClick() {
-    let pages = wizardConfig.pages.filter(p => !p.excludeFrom || p.excludeFrom !== this.state.type)
-    let currentPageIndex = pages.findIndex(p => p.id === wizardStore.currentPage.id)
+    let currentPageIndex = this.pages.findIndex(p => p.id === wizardStore.currentPage.id)
 
     if (currentPageIndex === 0) {
       window.history.back()
       return
     }
 
-    let page = pages[currentPageIndex - 1]
+    let page = this.pages[currentPageIndex - 1]
     this.loadDataForPage(page)
     wizardStore.setCurrentPage(page)
   }
 
   handleNextClick() {
-    let pages = wizardConfig.pages.filter(p => !p.excludeFrom || p.excludeFrom !== this.state.type)
-    let currentPageIndex = pages.findIndex(p => p.id === wizardStore.currentPage.id)
+    let currentPageIndex = this.pages.findIndex(p => p.id === wizardStore.currentPage.id)
 
-    if (currentPageIndex === pages.length - 1) {
+    if (currentPageIndex === this.pages.length - 1) {
       this.create()
       return
     }
 
-    let page = pages[currentPageIndex + 1]
+    let page = this.pages[currentPageIndex + 1]
     this.loadDataForPage(page)
     wizardStore.setCurrentPage(page)
   }
 
   handleSourceEndpointChange(source: ?EndpointType) {
     wizardStore.updateData({ source, selectedInstances: null, networks: null })
+    wizardStore.clearStorageMap()
     wizardStore.setPermalink(wizardStore.data)
 
     if (source) {
@@ -208,12 +213,16 @@ class WizardPage extends React.Component<Props, State> {
 
   handleTargetEndpointChange(target: EndpointType) {
     wizardStore.updateData({ target, networks: null, options: null })
+    wizardStore.clearStorageMap()
     wizardStore.setPermalink(wizardStore.data)
     // Preload destination options schema
     providerStore.loadOptionsSchema(target.type, this.state.type).then(() => {
       // Preload destination options values
       return providerStore.getDestinationOptions(target.id, target.type)
     })
+    if (this.pages.find(p => p.id === 'storage')) {
+      endpointStore.loadStorage(target.id, {})
+    }
   }
 
   handleAddEndpoint(newEndpointType: string, newEndpointFromSource: boolean) {
@@ -250,6 +259,7 @@ class WizardPage extends React.Component<Props, State> {
 
   handleInstanceClick(instance: Instance) {
     wizardStore.updateData({ networks: null })
+    wizardStore.clearStorageMap()
     wizardStore.toggleInstanceSelection(instance)
     wizardStore.setPermalink(wizardStore.data)
   }
@@ -264,6 +274,7 @@ class WizardPage extends React.Component<Props, State> {
 
   handleOptionsChange(field: Field, value: any) {
     wizardStore.updateData({ networks: null })
+    wizardStore.clearStorageMap()
     wizardStore.updateOptions({ field, value })
     // If the field is a string and doesn't have an enum property,
     // we can't call destination options on "change" since too many calls will be made,
@@ -279,19 +290,20 @@ class WizardPage extends React.Component<Props, State> {
     wizardStore.setPermalink(wizardStore.data)
   }
 
+  handleStorageChange(source: Disk, target: Storage, type: 'backend' | 'disk') {
+    wizardStore.updateStorage({ source, target, type })
+  }
+
   handleAddScheduleClick(schedule: Schedule) {
     wizardStore.addSchedule(schedule)
-    wizardStore.setPermalink(wizardStore.data)
   }
 
   handleScheduleChange(scheduleId: string, data: Schedule) {
     wizardStore.updateSchedule(scheduleId, data)
-    wizardStore.setPermalink(wizardStore.data)
   }
 
   handleScheduleRemove(scheduleId: string) {
     wizardStore.removeSchedule(scheduleId)
-    wizardStore.setPermalink(wizardStore.data)
   }
 
   initializeState() {
@@ -362,6 +374,10 @@ class WizardPage extends React.Component<Props, State> {
       }
       case 'target': {
         let target = wizardStore.data.target
+        // Preload Storage Mapping
+        if (this.pages.find(p => p.id === 'storage') && target) {
+          endpointStore.loadStorage(target.id, {})
+        }
         // Preload destination options schema
         if (providerStore.optionsSchema.length === 0 && target) {
           providerStore.loadOptionsSchema(target.type, this.state.type).then(() => {
@@ -380,7 +396,8 @@ class WizardPage extends React.Component<Props, State> {
           instanceStore.loadInstancesDetails(wizardStore.data.source.id, wizardStore.data.selectedInstances)
         }
         if (wizardStore.data.target) {
-          networkStore.loadNetworks(wizardStore.data.target.id, wizardStore.data.options)
+          let id = wizardStore.data.target.id
+          networkStore.loadNetworks(id, wizardStore.data.options)
         }
         break
       default:
@@ -390,7 +407,7 @@ class WizardPage extends React.Component<Props, State> {
   createMultiple() {
     let typeLabel = this.state.type.charAt(0).toUpperCase() + this.state.type.substr(1)
     notificationStore.alert(`Creating ${typeLabel}s ...`)
-    wizardStore.createMultiple(this.state.type, wizardStore.data).then(() => {
+    wizardStore.createMultiple(this.state.type, wizardStore.data, wizardStore.storageMap).then(() => {
       let items = wizardStore.createdItems
       if (!items) {
         notificationStore.alert(`${typeLabel}s couldn't be created`, 'error')
@@ -404,7 +421,7 @@ class WizardPage extends React.Component<Props, State> {
   createSingle() {
     let typeLabel = this.state.type.charAt(0).toUpperCase() + this.state.type.substr(1)
     notificationStore.alert(`Creating ${typeLabel} ...`)
-    wizardStore.create(this.state.type, wizardStore.data).then(() => {
+    wizardStore.create(this.state.type, wizardStore.data, wizardStore.storageMap).then(() => {
       let item = wizardStore.createdItem
       if (!item) {
         notificationStore.alert(`${typeLabel} couldn't be created`, 'error')
@@ -482,8 +499,10 @@ class WizardPage extends React.Component<Props, State> {
             providerStore={providerStore}
             instanceStore={instanceStore}
             networkStore={networkStore}
-            endpoints={endpointStore.endpoints}
+            endpointStore={endpointStore}
             wizardData={wizardStore.data}
+            hasStorageMap={Boolean(this.pages.find(p => p.id === 'storage'))}
+            storageMap={wizardStore.storageMap}
             schedules={wizardStore.schedules}
             nextButtonDisabled={this.state.nextButtonDisabled}
             type={this.state.type}
@@ -500,6 +519,7 @@ class WizardPage extends React.Component<Props, State> {
             onInstanceChunkSizeUpdate={chunkSize => { this.handleInstanceChunkSizeUpdate(chunkSize) }}
             onOptionsChange={(field, value) => { this.handleOptionsChange(field, value) }}
             onNetworkChange={(sourceNic, targetNetwork) => { this.handleNetworkChange(sourceNic, targetNetwork) }}
+            onStorageChange={(source, target, type) => { this.handleStorageChange(source, target, type) }}
             onAddScheduleClick={schedule => { this.handleAddScheduleClick(schedule) }}
             onScheduleChange={(scheduleId, data) => { this.handleScheduleChange(scheduleId, data) }}
             onScheduleRemove={scheduleId => { this.handleScheduleRemove(scheduleId) }}

+ 8 - 0
src/config.js

@@ -74,6 +74,8 @@ export const executionOptions = [
   },
 ]
 
+export const storageProviders = ['openstack']
+
 export const wizardConfig = {
   pages: [
     { id: 'type', title: 'New', breadcrumb: 'Type' },
@@ -82,6 +84,12 @@ export const wizardConfig = {
     { id: 'vms', title: 'Select instances', breadcrumb: 'Select VMs' },
     { id: 'options', title: 'Options', breadcrumb: 'Options' },
     { id: 'networks', title: 'Networks', breadcrumb: 'Networks' },
+    {
+      id: 'storage',
+      title: 'Storage Mapping',
+      breadcrumb: 'Storage',
+      filter: (p: string) => storageProviders.find(s => s === p),
+    },
     { id: 'schedule', title: 'Schedule', breadcrumb: 'Schedule', excludeFrom: 'migration' },
     { id: 'summary', title: 'Summary', breadcrumb: 'Summary' },
   ],

+ 34 - 2
src/plugins/endpoint/default/OptionsSchemaPlugin.js

@@ -15,7 +15,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 // @flow
 
 import type { Field } from '../../../types/Field'
-import type { DestinationOption } from '../../../types/Endpoint'
+import type { DestinationOption, StorageMap } from '../../../types/Endpoint'
 import type { WizardData } from '../../../types/WizardData'
 import { executionOptions } from '../../../config'
 
@@ -64,7 +64,7 @@ export const defaultFillMigrationImageMapValues = (field: Field, option: Destina
 
 export const defaultGetDestinationEnv = (data: WizardData): any => {
   let env = {}
-  let specialOptions = ['execute_now', 'separate_vm', 'skip_os_morphing']
+  let specialOptions = ['execute_now', 'separate_vm', 'skip_os_morphing', 'default_storage']
     .concat(executionOptions.map(o => o.name))
     .concat(migrationImageOsTypes.map(o => `${o}_os_image`))
 
@@ -133,5 +133,37 @@ export default class OptionsSchemaParser {
     }
     return payload
   }
+
+  static getStorageMap(data: WizardData, storageMap: StorageMap[]) {
+    let payload = {}
+    if (data.options && data.options.default_storage) {
+      payload.default = data.options.default_storage
+    }
+
+    storageMap.forEach(mapping => {
+      if (mapping.target.id === null) {
+        return
+      }
+
+      if (mapping.type === 'backend') {
+        if (!payload.backend_mappings) {
+          payload.backend_mappings = []
+        }
+        payload.backend_mappings.push({
+          source: mapping.source.storage_backend_identifier,
+          destination: mapping.target.name,
+        })
+      } else {
+        if (!payload.disk_mappings) {
+          payload.disk_mappings = []
+        }
+        payload.disk_mappings.push({
+          disk_id: mapping.source.id.toString(),
+          destination: mapping.target.name,
+        })
+      }
+    })
+    return payload
+  }
 }
 

+ 1 - 1
src/sources/AssessmentSource.js

@@ -33,7 +33,7 @@ class AssessmentSourceUtils {
     if (vmSize) {
       env.vm_size = vmSize
     }
-    let skipFields = ['use_replica', 'separate_vm', 'shutdown_instances', 'skip_os_morphing']
+    let skipFields = ['use_replica', 'separate_vm', 'shutdown_instances', 'skip_os_morphing', 'default_storage']
     Object.keys(data.fieldValues).filter(f => !skipFields.find(sf => sf === f)).forEach(fieldName => {
       if (data.fieldValues[fieldName] != null) {
         env[fieldName] = data.fieldValues[fieldName]

+ 8 - 1
src/sources/EndpointSource.js

@@ -19,7 +19,7 @@ import moment from 'moment'
 import Api from '../utils/ApiCaller'
 import { SchemaParser } from './Schemas'
 import ObjectUtils from '../utils/ObjectUtils'
-import type { Endpoint, Validation } from '../types/Endpoint'
+import type { Endpoint, Validation, Storage } from '../types/Endpoint'
 
 import { servicesUrl, useSecret } from '../config'
 
@@ -229,6 +229,13 @@ class EdnpointSource {
       return response.data.endpoint
     })
   }
+
+  static loadStorage(endpointId: string, data: any): Promise<Storage[]> {
+    let env = btoa(JSON.stringify(data))
+    return Api.get(`${servicesUrl.coriolis}/${Api.projectId}/endpoints/${endpointId}/storage?env=${env}`).then(response => {
+      return response.data.storage
+    })
+  }
 }
 
 export default EdnpointSource

+ 5 - 3
src/sources/WizardSource.js

@@ -20,10 +20,11 @@ import { OptionsSchemaPlugin } from '../plugins/endpoint'
 
 import { servicesUrl } from '../config'
 import type { WizardData } from '../types/WizardData'
+import type { StorageMap } from '../types/Endpoint'
 import type { MainItem } from '../types/MainItem'
 
 class WizardSource {
-  static create(type: string, data: WizardData): Promise<MainItem> {
+  static create(type: string, data: WizardData, storageMap: StorageMap[]): Promise<MainItem> {
     const parser = data.target ? OptionsSchemaPlugin[data.target.type] || OptionsSchemaPlugin.default : OptionsSchemaPlugin.default
     let payload = {}
     payload[type] = {
@@ -32,6 +33,7 @@ class WizardSource {
       destination_environment: parser.getDestinationEnv(data),
       network_map: parser.getNetworkMap(data),
       instances: data.selectedInstances ? data.selectedInstances.map(i => i.instance_name) : 'null',
+      storage_mappings: parser.getStorageMap(data, storageMap),
       notes: '',
     }
 
@@ -46,7 +48,7 @@ class WizardSource {
     }).then(response => response.data[type])
   }
 
-  static createMultiple(type: string, data: WizardData): Promise<MainItem[]> {
+  static createMultiple(type: string, data: WizardData, storageMap: StorageMap[]): Promise<MainItem[]> {
     if (!data.selectedInstances) {
       return Promise.reject('No selected instances')
     }
@@ -54,7 +56,7 @@ class WizardSource {
     return Promise.all(data.selectedInstances.map(instance => {
       let newData = { ...data }
       newData.selectedInstances = [instance]
-      return WizardSource.create(type, newData).catch(() => {
+      return WizardSource.create(type, newData, storageMap).catch(() => {
         notificationStore.alert(`Error while creating ${type} for instance ${instance.name}`, 'error')
         return null
       })

+ 9 - 1
src/stores/EndpointStore.js

@@ -14,7 +14,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 // @flow
 import { observable, action } from 'mobx'
-import type { Endpoint, Validation } from '../types/Endpoint'
+import type { Endpoint, Validation, Storage } from '../types/Endpoint'
 import EndpointSource from '../sources/EndpointSource'
 
 export const passwordFields = ['password', 'private_key_passphrase']
@@ -38,6 +38,7 @@ class EndpointStore {
   @observable adding = false
   @observable connectionInfoLoading = false
   @observable connectionsInfoLoading = false
+  @observable storage: Storage[] = []
 
   @action getEndpoints(options?: { showLoading: boolean }) {
     if (options && options.showLoading) {
@@ -132,6 +133,13 @@ class EndpointStore {
       this.adding = false
     })
   }
+
+  @action loadStorage(endpointId: string, data: any): Promise<void> {
+    this.storage = []
+    return EndpointSource.loadStorage(endpointId, data).then(storage => {
+      this.storage = storage
+    })
+  }
 }
 
 export default new EndpointStore()

+ 17 - 4
src/stores/WizardStore.js

@@ -21,6 +21,7 @@ import type { MainItem } from '../types/MainItem'
 import type { Instance } from '../types/Instance'
 import type { Field } from '../types/Field'
 import type { NetworkMap } from '../types/Network'
+import type { StorageMap } from '../types/Endpoint'
 import type { Schedule } from '../types/Schedule'
 import { wizardConfig } from '../config'
 import Source from '../sources/WizardSource'
@@ -28,6 +29,7 @@ import Source from '../sources/WizardSource'
 class WizardStore {
   @observable data: WizardData = {}
   @observable schedules: Schedule[] = []
+  @observable storageMap: StorageMap[] = []
   @observable currentPage: WizardPage = wizardConfig.pages[0]
   @observable createdItem: ?MainItem = null
   @observable creatingItem: boolean = false
@@ -89,6 +91,17 @@ class WizardStore {
     this.data.networks.push(network)
   }
 
+  @action updateStorage(storage: StorageMap) {
+    let diskFieldName = storage.type === 'backend' ? 'storage_backend_identifier' : 'id'
+    this.storageMap = this.storageMap
+      .filter(n => n.type !== storage.type || n.source[diskFieldName] !== storage.source[diskFieldName])
+    this.storageMap.push(storage)
+  }
+
+  @action clearStorageMap() {
+    this.storageMap = []
+  }
+
   @action addSchedule(schedule: Schedule) {
     this.schedules.push({ id: new Date().getTime().toString(), schedule: schedule.schedule })
   }
@@ -117,10 +130,10 @@ class WizardStore {
     this.schedules = this.schedules.filter(s => s.id !== scheduleId)
   }
 
-  @action create(type: string, data: WizardData): Promise<void> {
+  @action create(type: string, data: WizardData, storageMap: StorageMap[]): Promise<void> {
     this.creatingItem = true
 
-    return Source.create(type, data).then((item: MainItem) => {
+    return Source.create(type, data, storageMap).then((item: MainItem) => {
       this.createdItem = item
       this.creatingItem = false
     }).catch(() => {
@@ -129,10 +142,10 @@ class WizardStore {
     })
   }
 
-  @action createMultiple(type: string, data: WizardData): Promise<void> {
+  @action createMultiple(type: string, data: WizardData, storageMap: StorageMap[]): Promise<void> {
     this.creatingItems = true
 
-    return Source.createMultiple(type, data).then((items: MainItem[]) => {
+    return Source.createMultiple(type, data, storageMap).then((items: MainItem[]) => {
       this.createdItems = items
       this.creatingItems = false
     }).catch(() => {

+ 14 - 0
src/types/Endpoint.js

@@ -14,6 +14,8 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 // @flow
 
+import type { Disk } from './Instance'
+
 export type Validation = {
   valid: boolean,
   message: string,
@@ -38,3 +40,15 @@ export type DestinationOption = {
   values: string[] | { name: string, id: string, [string]: mixed }[],
   config_default: string | { name: string, id: string },
 }
+
+export type Storage = {
+  id: string,
+  name: string,
+  config_default?: string,
+}
+
+export type StorageMap = {
+  type: 'backend' | 'disk',
+  source: Disk,
+  target: Storage,
+}

+ 15 - 1
src/types/Instance.js

@@ -16,7 +16,19 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 export type Nic = {
   id: string,
-  network_name: string
+  network_name: string,
+  ip_addresses?: string[],
+  mac_address: string,
+  network_id: string,
+}
+
+export type Disk = {
+  id: string,
+  name?: string,
+  storage_backend_identifier?: string,
+  format?: string,
+  guest_device?: string,
+  size_bytes?: number,
 }
 
 export type Instance = {
@@ -26,7 +38,9 @@ export type Instance = {
   instance_name: string,
   num_cpu: number,
   memory_mb: number,
+  os_type: string,
   devices: {
     nics: Nic[],
+    disks: Disk[],
   },
 }

+ 13 - 0
src/types/MainItem.js

@@ -16,6 +16,7 @@ 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'
 
 export type MainItemInfo = {
   export_info: {
@@ -52,4 +53,16 @@ export type MainItem = {
   type: string,
   info: { [string]: MainItemInfo },
   destination_environment: DestinationEnvInfo,
+  transfer_result: ?{ [string]: Instance },
+  storage_mappings: ?{
+    backend_mappings: ?{
+      destination: string,
+      source: string,
+    }[],
+    default: ?string,
+    disk_mappings: ?{
+      destination: string,
+      disk_id: string,
+    }[],
+  },
 }