Selaa lähdekoodia

Add Coriolis Minion Pools operations support

The users can view and manage the Coriolis Minion Pools similar to the
other Coriolis resources (Replicas, Migrations, Endpoints etc.). The
Minion Pools can be listed, created, updated, deleted and certain
actions can be applied. Their executions view is similar to that of the
replicas and migrations.

The Minion Pools are also integrated with Replicas and Migrations. When
creating a new one, the supported source and target endpoints have their
associated minion pools listed and a minion pool can be set.

Each instance of a Replica / Migration may be mapped to a supported
target endpoint's Minion Pool.

This Replica / Migration Minion Pool support is available throughout
the UI where their associated operations are available, like editing
replicas and migrations and creating a migration from replica.
Sergiu Miclea 5 vuotta sitten
vanhempi
sitoutus
84a91b8587
70 muutettua tiedostoa jossa 4408 lisäystä ja 236 poistoa
  1. 1 1
      src/@types/Field.ts
  2. 3 0
      src/@types/MainItem.ts
  3. 34 0
      src/@types/MinionPool.ts
  4. 5 0
      src/components/App.tsx
  5. 6 1
      src/components/atoms/CopyValue/CopyValue.tsx
  6. 1 1
      src/components/atoms/EndpointLogos/story.tsx
  7. 9 0
      src/components/atoms/StatusPill/StatusPill.tsx
  8. 10 0
      src/components/atoms/StatusPill/story.tsx
  9. 20 0
      src/components/atoms/Switch/Switch.tsx
  10. 17 0
      src/components/atoms/Switch/images/required.svg
  11. 3 1
      src/components/molecules/ActionDropdown/ActionDropdown.tsx
  12. 12 1
      src/components/molecules/FieldInput/FieldInput.tsx
  13. 10 0
      src/components/molecules/MainDetailsTable/MainDetailsTable.tsx
  14. 2 0
      src/components/molecules/MainListFilter/MainListFilter.tsx
  15. 2 2
      src/components/molecules/MainListItem/MainListItem.tsx
  16. 194 0
      src/components/molecules/MinionPoolListItem/MinionPoolListItem.tsx
  17. 110 0
      src/components/molecules/MinionPoolListItem/images/minion-pool-list-item.svg
  18. 6 0
      src/components/molecules/MinionPoolListItem/package.json
  19. 54 53
      src/components/molecules/NewItemDropdown/NewItemDropdown.tsx
  20. 70 0
      src/components/molecules/NewItemDropdown/images/minionPool.svg
  21. 16 2
      src/components/molecules/PropertiesTable/PropertiesTable.tsx
  22. 2 0
      src/components/organisms/DetailsContentHeader/DetailsContentHeader.tsx
  23. 59 39
      src/components/organisms/EditReplica/EditReplica.tsx
  24. 1 1
      src/components/organisms/EndpointDetailsContent/EndpointDetailsContent.tsx
  25. 7 5
      src/components/organisms/Executions/Executions.tsx
  26. 2 0
      src/components/organisms/FilterList/FilterList.tsx
  27. 35 3
      src/components/organisms/MainDetails/MainDetails.tsx
  28. 1 0
      src/components/organisms/MainList/MainList.tsx
  29. 3 0
      src/components/organisms/MigrationDetailsContent/MigrationDetailsContent.tsx
  30. 221 0
      src/components/organisms/MinionEndpointModal/MinionEndpointModal.tsx
  31. 6 0
      src/components/organisms/MinionEndpointModal/package.json
  32. 187 0
      src/components/organisms/MinionPoolDetailsContent/MinionPoolDetailsContent.tsx
  33. 360 0
      src/components/organisms/MinionPoolDetailsContent/MinionPoolMainDetails.tsx
  34. 6 0
      src/components/organisms/MinionPoolDetailsContent/package.json
  35. 357 0
      src/components/organisms/MinionPoolModal/MinionPoolModal.tsx
  36. 245 0
      src/components/organisms/MinionPoolModal/MinionPoolModalContent.tsx
  37. 87 0
      src/components/organisms/MinionPoolModal/images/minion-pool.svg
  38. 6 0
      src/components/organisms/MinionPoolModal/package.json
  39. 6 2
      src/components/organisms/Navigation/Navigation.tsx
  40. 15 0
      src/components/organisms/Navigation/images/minion-pools-menu.svg
  41. 73 0
      src/components/organisms/PageHeader/PageHeader.tsx
  42. 3 0
      src/components/organisms/ReplicaDetailsContent/ReplicaDetailsContent.tsx
  43. 84 20
      src/components/organisms/ReplicaMigrationOptions/ReplicaMigrationOptions.tsx
  44. 75 10
      src/components/organisms/WizardOptions/WizardOptions.tsx
  45. 19 6
      src/components/organisms/WizardPageContent/WizardPageContent.tsx
  46. 93 0
      src/components/organisms/WizardSummary/WizardSummary.tsx
  47. 29 6
      src/components/pages/MigrationDetailsPage/MigrationDetailsPage.tsx
  48. 6 1
      src/components/pages/MigrationsPage/MigrationsPage.tsx
  49. 420 0
      src/components/pages/MinionPoolDetailsPage/MinionPoolDetailsPage.tsx
  50. 128 0
      src/components/pages/MinionPoolDetailsPage/images/minion-pool.svg
  51. 6 0
      src/components/pages/MinionPoolDetailsPage/package.json
  52. 411 0
      src/components/pages/MinionPoolsPage/MinionPoolsPage.tsx
  53. 87 0
      src/components/pages/MinionPoolsPage/images/minion-pool-empty-list.svg
  54. 6 0
      src/components/pages/MinionPoolsPage/package.json
  55. 21 5
      src/components/pages/ReplicaDetailsPage/ReplicaDetailsPage.tsx
  56. 3 0
      src/components/pages/ReplicasPage/ReplicasPage.tsx
  57. 14 7
      src/components/pages/WizardPage/WizardPage.tsx
  58. 3 0
      src/constants.ts
  59. 3 4
      src/plugins/endpoint/default/ConnectionSchemaPlugin.ts
  60. 42 20
      src/plugins/endpoint/default/OptionsSchemaPlugin.ts
  61. 30 3
      src/plugins/endpoint/openstack/OptionsSchemaPlugin.ts
  62. 15 3
      src/plugins/endpoint/ovm/OptionsSchemaPlugin.ts
  63. 55 11
      src/sources/MigrationSource.ts
  64. 242 0
      src/sources/MinionPoolSource.ts
  65. 47 11
      src/sources/ReplicaSource.ts
  66. 36 2
      src/sources/WizardSource.ts
  67. 13 2
      src/stores/MigrationStore.ts
  68. 231 0
      src/stores/MinionPoolStore.ts
  69. 6 10
      src/stores/ReplicaStore.ts
  70. 16 3
      src/stores/WizardStore.ts

+ 1 - 1
src/@types/Field.ts

@@ -100,7 +100,7 @@ class FieldHelper {
       return value.map((v: any) => findInEnum(v)).join(', ')
       return value.map((v: any) => findInEnum(v)).join(', ')
     }
     }
 
 
-    const isImageMapField = migrationImageOsTypes.find(os => `${os}_os_image` === name)
+    const isImageMapField = migrationImageOsTypes.find(os => `${os}${plugin?.imageSuffix}` === name)
     if (isImageMapField) {
     if (isImageMapField) {
       const migrImageField = plugin && fields
       const migrImageField = plugin && fields
         .find(f => f.name === plugin.migrationImageMapFieldName)
         .find(f => f.name === plugin.migrationImageMapFieldName)

+ 3 - 0
src/@types/MainItem.ts

@@ -65,6 +65,8 @@ type BaseItem = {
   updated_at: string,
   updated_at: string,
   origin_endpoint_id: string,
   origin_endpoint_id: string,
   destination_endpoint_id: string,
   destination_endpoint_id: string,
+  origin_minion_pool_id: string | null,
+  destination_minion_pool_id: string | null,
   instances: string[],
   instances: string[],
   info: { [prop: string]: MainItemInfo },
   info: { [prop: string]: MainItemInfo },
   destination_environment: { [prop: string]: any },
   destination_environment: { [prop: string]: any },
@@ -75,6 +77,7 @@ type BaseItem = {
   network_map?: TransferNetworkMap,
   network_map?: TransferNetworkMap,
   last_execution_status: string
   last_execution_status: string
   user_id: string
   user_id: string
+  instance_osmorphing_minion_pool_mappings?: {[instanceName: string]: string}
 }
 }
 
 
 export type ReplicaItem = BaseItem & {
 export type ReplicaItem = BaseItem & {

+ 34 - 0
src/@types/MinionPool.ts

@@ -0,0 +1,34 @@
+/*
+Copyright (C) 2020  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/>.
+*/
+
+import { Execution } from './Execution'
+
+export type MinionPool = {
+  id: string
+  created_at: string
+  updated_at: string | null
+  pool_name: string
+  pool_os_type: string
+  pool_status: string
+  minimum_minions: number
+  environment_options: { [prop: string]: any }
+  endpoint_id: string
+  last_execution_status: string
+  notes?: string
+  pool_platform: 'source' | 'destination'
+}
+
+export type MinionPoolDetails = MinionPool & {
+  executions: Execution[]
+}

+ 5 - 0
src/components/App.tsx

@@ -46,6 +46,8 @@ import { navigationMenu } from '../constants'
 import Palette from './styleUtils/Palette'
 import Palette from './styleUtils/Palette'
 import StyleProps from './styleUtils/StyleProps'
 import StyleProps from './styleUtils/StyleProps'
 import configLoader from '../utils/Config'
 import configLoader from '../utils/Config'
+import MinionPoolsPage from './pages/MinionPoolsPage/MinionPoolsPage'
+import MinionPoolDetailsPage from './pages/MinionPoolDetailsPage/MinionPoolDetailsPage'
 
 
 const GlobalStyle = createGlobalStyle`
 const GlobalStyle = createGlobalStyle`
  ${Fonts}
  ${Fonts}
@@ -185,6 +187,9 @@ class App extends React.Component<{}, State> {
             {renderRoute('/migrations/:id/:page', MigrationDetailsPage)}
             {renderRoute('/migrations/:id/:page', MigrationDetailsPage)}
             {renderRoute('/endpoints', EndpointsPage, true)}
             {renderRoute('/endpoints', EndpointsPage, true)}
             {renderRoute('/endpoints/:id', EndpointDetailsPage)}
             {renderRoute('/endpoints/:id', EndpointDetailsPage)}
+            {renderRoute('/minion-pools', MinionPoolsPage, true)}
+            {renderRoute('/minion-pools/:id', MinionPoolDetailsPage, true)}
+            {renderRoute('/minion-pools/:id/:page', MinionPoolDetailsPage)}
             {renderRoute('/wizard/:type', WizardPage)}
             {renderRoute('/wizard/:type', WizardPage)}
             {renderOptionalRoute('planning', AssessmentsPage)}
             {renderOptionalRoute('planning', AssessmentsPage)}
             {renderOptionalRoute('planning', AssessmentDetailsPage, '/assessment/:info')}
             {renderOptionalRoute('planning', AssessmentDetailsPage, '/assessment/:info')}

+ 6 - 1
src/components/atoms/CopyValue/CopyValue.tsx

@@ -14,7 +14,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 
 import React from 'react'
 import React from 'react'
 import { observer } from 'mobx-react'
 import { observer } from 'mobx-react'
-import styled from 'styled-components'
+import styled, { css } from 'styled-components'
 
 
 import CopyButton from '../CopyButton'
 import CopyButton from '../CopyButton'
 import DomUtils from '../../../utils/DomUtils'
 import DomUtils from '../../../utils/DomUtils'
@@ -26,6 +26,9 @@ const Wrapper = styled.div<any>`
   &:hover > span:last-child {
   &:hover > span:last-child {
     opacity: 1;
     opacity: 1;
   }
   }
+  ${props => (props.capitalize ? css`
+    text-transform: capitalize;
+  ` : '')}
 `
 `
 const Value = styled.span<any>`
 const Value = styled.span<any>`
   width: ${(props: any) => `${props.width || 'auto'}`};
   width: ${(props: any) => `${props.width || 'auto'}`};
@@ -41,6 +44,7 @@ type Props = {
   value: string,
   value: string,
   width?: string,
   width?: string,
   maxWidth?: string,
   maxWidth?: string,
+  capitalize?: boolean,
   'data-test-id'?: string,
   'data-test-id'?: string,
   onCopy?: (value: string) => void,
   onCopy?: (value: string) => void,
 }
 }
@@ -66,6 +70,7 @@ class CopyValue extends React.Component<Props> {
         onMouseDown={(e: { stopPropagation: () => void }) => { e.stopPropagation() }}
         onMouseDown={(e: { stopPropagation: () => void }) => { e.stopPropagation() }}
         onMouseUp={(e: { stopPropagation: () => void }) => { e.stopPropagation() }}
         onMouseUp={(e: { stopPropagation: () => void }) => { e.stopPropagation() }}
         data-test-id={this.props['data-test-id'] || 'copyValue'}
         data-test-id={this.props['data-test-id'] || 'copyValue'}
+        capitalize={this.props.capitalize}
       >
       >
         <Value
         <Value
           data-test-id="copyValue-value"
           data-test-id="copyValue-value"

+ 1 - 1
src/components/atoms/EndpointLogos/story.tsx

@@ -35,7 +35,7 @@ const wrap = (
   disabled = false, white = false,
   disabled = false, white = false,
 ) => (
 ) => (
   <EndpointLogos
   <EndpointLogos
-    endpoint={endpoint}
+    endpoint={endpoint as any}
     height={height}
     height={height}
     disabled={disabled}
     disabled={disabled}
     white={white}
     white={white}

+ 9 - 0
src/components/atoms/StatusPill/StatusPill.tsx

@@ -33,6 +33,7 @@ const LABEL_MAP: { [status: string]: string } = {
 const statuses = (status: any) => {
 const statuses = (status: any) => {
   switch (status) {
   switch (status) {
     case 'COMPLETED':
     case 'COMPLETED':
+    case 'ALLOCATED':
       return css`
       return css`
         background: ${Palette.success};
         background: ${Palette.success};
         color: white;
         color: white;
@@ -63,6 +64,9 @@ const statuses = (status: any) => {
     case 'STARTING':
     case 'STARTING':
     case 'RUNNING':
     case 'RUNNING':
     case 'PENDING':
     case 'PENDING':
+    case 'INITIALIZING':
+    case 'ALLOCATING':
+    case 'RECONFIGURING':
       return css`
       return css`
         background: url('${runningImage}');
         background: url('${runningImage}');
         animation: bgMotion 1s infinite linear;
         animation: bgMotion 1s infinite linear;
@@ -74,6 +78,8 @@ const statuses = (status: any) => {
         }
         }
       `
       `
     case 'CANCELLING':
     case 'CANCELLING':
+    case 'UNINITIALIZING':
+    case 'DEALLOCATING':
     case 'CANCELLING_AFTER_COMPLETION':
     case 'CANCELLING_AFTER_COMPLETION':
       return css`
       return css`
         background: url('${cancellingImage}');
         background: url('${cancellingImage}');
@@ -93,6 +99,9 @@ const statuses = (status: any) => {
         border-color: transparent;
         border-color: transparent;
       `
       `
     case 'UNSCHEDULED':
     case 'UNSCHEDULED':
+    case 'UNINITIALIZED':
+    case 'DEALLOCATED':
+    case 'INITIALIZED':
       return css`
       return css`
         background: ${Palette.grayscale[2]};
         background: ${Palette.grayscale[2]};
         color: ${Palette.black};
         color: ${Palette.black};

+ 10 - 0
src/components/atoms/StatusPill/story.tsx

@@ -39,6 +39,16 @@ const STATUSES = [
   'FAILED_TO_SCHEDULE',
   'FAILED_TO_SCHEDULE',
   'DEADLOCKED',
   'DEADLOCKED',
   'STRANDED_AFTER_DEADLOCK',
   'STRANDED_AFTER_DEADLOCK',
+  // Minion Pool statuses
+  'INITIALIZED',
+  'UNINITIALIZED',
+  'UNINITIALIZING',
+  'INITIALIZING',
+  'DEALLOCATING',
+  'DEALLOCATED',
+  'ALLOCATING',
+  'ALLOCATED',
+  'RECONFIGURING',
 ]
 ]
 
 
 const renderAllStatuses = (small?: boolean) => (
 const renderAllStatuses = (small?: boolean) => (

+ 20 - 0
src/components/atoms/Switch/Switch.tsx

@@ -18,16 +18,31 @@ import styled, { css } from 'styled-components'
 
 
 import Palette from '../../styleUtils/Palette'
 import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
 import StyleProps from '../../styleUtils/StyleProps'
+import requiredImage from './images/required.svg'
 
 
 const Wrapper = styled.div<any>`
 const Wrapper = styled.div<any>`
+  position: relative;
   display: flex;
   display: flex;
   height: ${StyleProps.inputSizes.regular.height}px;
   height: ${StyleProps.inputSizes.regular.height}px;
   align-items: center;
   align-items: center;
+  ${(props: any) => (props.highlight ? css`
+    border: 1px solid ${Palette.alert};
+    height: ${StyleProps.inputSizes.regular.height - 2}px;
+    border-radius: ${StyleProps.borderRadius};
+  ` : '')}
   ${(props: any) => (props.disabled ? 'opacity: 0.5;' : '')}
   ${(props: any) => (props.disabled ? 'opacity: 0.5;' : '')}
   ${(props: any) => (props.justifyContent ? `justify-content: ${props.justifyContent};` : '')}
   ${(props: any) => (props.justifyContent ? `justify-content: ${props.justifyContent};` : '')}
   ${(props: any) => (props.width ? `width: ${props.width};` : '')}
   ${(props: any) => (props.width ? `width: ${props.width};` : '')}
   ${(props: any) => (props.disabledLoading ? StyleProps.animations.disabledLoading : '')}
   ${(props: any) => (props.disabledLoading ? StyleProps.animations.disabledLoading : '')}
 `
 `
+const Required = styled.div<any>`
+  position: absolute;
+  width: 8px;
+  height: 8px;
+  right: -16px;
+  top: 12px;
+  background: url('${requiredImage}') center no-repeat;
+`
 const InputWrapper = styled.div<any>`
 const InputWrapper = styled.div<any>`
   position: relative;
   position: relative;
   width: ${(props: any) => props.height * 2}px;
   width: ${(props: any) => props.height * 2}px;
@@ -131,6 +146,8 @@ type Props = {
   uncheckedLabel?: string,
   uncheckedLabel?: string,
   'data-test-id'?: string,
   'data-test-id'?: string,
   style?: React.CSSProperties,
   style?: React.CSSProperties,
+  required?: boolean,
+  highlight?: boolean,
 }
 }
 type State = {
 type State = {
   lastChecked: boolean | null | undefined,
   lastChecked: boolean | null | undefined,
@@ -241,11 +258,14 @@ class Switch extends React.Component<Props, State> {
         disabledLoading={this.props.disabledLoading}
         disabledLoading={this.props.disabledLoading}
         style={this.props.style}
         style={this.props.style}
         width={this.props.width}
         width={this.props.width}
+        height={this.props.height}
         justifyContent={this.props.justifyContent}
         justifyContent={this.props.justifyContent}
+        highlight={this.props.highlight}
       >
       >
         {this.renderLeftLabel()}
         {this.renderLeftLabel()}
         {this.renderInput()}
         {this.renderInput()}
         {this.renderRightLabel()}
         {this.renderRightLabel()}
+        {this.props.required ? <Required /> : null}
       </Wrapper>
       </Wrapper>
     )
     )
   }
   }

+ 17 - 0
src/components/atoms/Switch/images/required.svg

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="8px" height="10px" viewBox="0 0 8 10" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 55.2 (78181) - https://sketchapp.com -->
+    <title>Icon-Star</title>
+    <desc>Created with Sketch.</desc>
+    <g id="Coriolis-Migrations" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="square">
+        <g id="EP/New-Endpoint/OpenStack/01-Copy-7" transform="translate(-304.000000, -523.000000)" stroke="#0044CA" stroke-width="1.5">
+            <g id="Form/Input-with-label-Copy-4" transform="translate(304.000000, 495.000000)">
+                <g id="Icon/Asterisk/Blue" transform="translate(0.000000, 29.000000)">
+                    <path d="M4,0.666666667 L4,7.33333333" id="Line"></path>
+                    <path d="M1.11324865,2.33333333 L6.88675135,5.66666667" id="Line"></path>
+                    <path d="M1.11324865,2.33333333 L6.88675135,5.66666667" id="Line" transform="translate(4.000000, 4.000000) scale(-1, 1) translate(-4.000000, -4.000000) "></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 3 - 1
src/components/molecules/ActionDropdown/ActionDropdown.tsx

@@ -30,7 +30,7 @@ const Wrapper = styled.div<any>`
 
 
 const ListItem = styled.div<any>`
 const ListItem = styled.div<any>`
   color: ${(props: any) => (props.disabled ? Palette.grayscale[2] : props.color || Palette.black)};
   color: ${(props: any) => (props.disabled ? Palette.grayscale[2] : props.color || Palette.black)};
-  height: 32px;
+  height: ${(props: any) => (props.large ? 42 : 32)}px;
   padding: 0 16px;
   padding: 0 16px;
   cursor: ${(props: any) => (props.disabled ? 'default' : 'pointer')};
   cursor: ${(props: any) => (props.disabled ? 'default' : 'pointer')};
   display: flex;
   display: flex;
@@ -66,6 +66,7 @@ export type Props = {
   actions: Action[],
   actions: Action[],
   style?: any,
   style?: any,
   'data-test-id'?: string,
   'data-test-id'?: string,
+  largeItems?: boolean
 }
 }
 
 
 type State = {
 type State = {
@@ -172,6 +173,7 @@ class ActionDropdown extends React.Component<Props, State> {
             disabled={action.disabled}
             disabled={action.disabled}
             data-test-id={`${TEST_ID}-listItem-${action.label}`}
             data-test-id={`${TEST_ID}-listItem-${action.label}`}
             title={action.title}
             title={action.title}
+            large={this.props.largeItems}
           >
           >
             {action.label}
             {action.label}
           </ListItem>
           </ListItem>

+ 12 - 1
src/components/molecules/FieldInput/FieldInput.tsx

@@ -45,6 +45,7 @@ const Wrapper = styled.div<any>`
 `
 `
 
 
 const Label = styled.div<any>`
 const Label = styled.div<any>`
+  ${props => (props.width ? `width: ${props.width}px;` : '')}
   font-weight: ${StyleProps.fontWeights.medium};
   font-weight: ${StyleProps.fontWeights.medium};
   flex-grow: 1;
   flex-grow: 1;
   ${props => (props.layout === 'page' ? css`
   ${props => (props.layout === 'page' ? css`
@@ -96,6 +97,7 @@ type Props = {
   description?: string,
   description?: string,
   addNullValue?: boolean,
   addNullValue?: boolean,
   nullableBoolean?: boolean,
   nullableBoolean?: boolean,
+  labelRenderer?: ((prop: string) => string) | null,
   style?: React.CSSProperties,
   style?: React.CSSProperties,
 }
 }
 @observer
 @observer
@@ -113,6 +115,8 @@ class FieldInput extends React.Component<Props> {
         onChange={checked => { if (this.props.onChange) this.props.onChange(checked) }}
         onChange={checked => { if (this.props.onChange) this.props.onChange(checked) }}
         leftLabel={this.props.layout === 'page'}
         leftLabel={this.props.layout === 'page'}
         style={this.props.layout === 'page' ? { marginTop: '-8px' } : {}}
         style={this.props.layout === 'page' ? { marginTop: '-8px' } : {}}
+        required={this.props.required}
+        highlight={this.props.highlight}
       />
       />
     )
     )
   }
   }
@@ -178,6 +182,7 @@ class FieldInput extends React.Component<Props> {
 
 
     return (
     return (
       <PropertiesTable
       <PropertiesTable
+        width={this.props.width}
         properties={this.props.properties}
         properties={this.props.properties}
         valueCallback={field => this.props.valueCallback && this.props.valueCallback(field)}
         valueCallback={field => this.props.valueCallback && this.props.valueCallback(field)}
         onChange={(field, value) => {
         onChange={(field, value) => {
@@ -185,6 +190,7 @@ class FieldInput extends React.Component<Props> {
             this.props.onChange(value, field)
             this.props.onChange(value, field)
           }
           }
         }}
         }}
+        labelRenderer={this.props.labelRenderer}
         hideRequiredSymbol={this.props.layout === 'page'}
         hideRequiredSymbol={this.props.layout === 'page'}
         disabledLoading={this.props.disabledLoading}
         disabledLoading={this.props.disabledLoading}
       />
       />
@@ -235,6 +241,7 @@ class FieldInput extends React.Component<Props> {
       items,
       items,
       disabledLoading: this.props.disabledLoading,
       disabledLoading: this.props.disabledLoading,
       disabled: this.props.disabled,
       disabled: this.props.disabled,
+      highlight: this.props.highlight,
       onChange: (item: { value: any }) => this.props.onChange && this.props.onChange(item.value),
       onChange: (item: { value: any }) => this.props.onChange && this.props.onChange(item.value),
     }
     }
 
 
@@ -363,7 +370,11 @@ class FieldInput extends React.Component<Props> {
     const marginRight = this.props.layout === 'modal' || description || this.props.required ? '24px' : 0
     const marginRight = this.props.layout === 'modal' || description || this.props.required ? '24px' : 0
 
 
     return (
     return (
-      <Label layout={this.props.layout} disabledLoading={this.props.disabledLoading}>
+      <Label
+        layout={this.props.layout}
+        disabledLoading={this.props.disabledLoading}
+        width={this.props.width}
+      >
         <LabelText style={{ marginRight }}>
         <LabelText style={{ marginRight }}>
           {this.props.label}
           {this.props.label}
         </LabelText>
         </LabelText>

+ 10 - 0
src/components/molecules/MainDetailsTable/MainDetailsTable.tsx

@@ -32,6 +32,7 @@ import instanceIcon from './images/instance.svg'
 import networkIcon from './images/network.svg'
 import networkIcon from './images/network.svg'
 import storageIcon from './images/storage.svg'
 import storageIcon from './images/storage.svg'
 import arrowIcon from './images/arrow.svg'
 import arrowIcon from './images/arrow.svg'
+import { MinionPool } from '../../../@types/MinionPool'
 
 
 const GlobalStyle = createGlobalStyle`
 const GlobalStyle = createGlobalStyle`
   .ReactCollapse--collapse {
   .ReactCollapse--collapse {
@@ -151,6 +152,7 @@ export type Props = {
   item?: TransferItem | null,
   item?: TransferItem | null,
   instancesDetails: Instance[],
   instancesDetails: Instance[],
   networks?: Network[],
   networks?: Network[],
+  minionPools: MinionPool[]
 }
 }
 type State = {
 type State = {
   openedRows: string[],
   openedRows: string[],
@@ -382,6 +384,14 @@ class MainDetailsTable extends React.Component<Props, State> {
     ]
     ]
 
 
     const sourceBody: string[] = getBody(instance)
     const sourceBody: string[] = getBody(instance)
+
+    const minionPoolMappings = this.props.item?.instance_osmorphing_minion_pool_mappings
+    const minionPoolId = minionPoolMappings
+      && minionPoolMappings[instance.instance_name || instance.name]
+    if (minionPoolId) {
+      const minionPool = this.props.minionPools.find(m => m.id === minionPoolId)
+      sourceBody.push(`Minion Pool: ${minionPool?.pool_name || minionPoolId}`)
+    }
     let destinationBody: string[] = []
     let destinationBody: string[] = []
     let destinationName: string = ''
     let destinationName: string = ''
     const transferResult = this.getTransferResult(instance)
     const transferResult = this.getTransferResult(instance)

+ 2 - 0
src/components/molecules/MainListFilter/MainListFilter.tsx

@@ -88,6 +88,7 @@ type Props = {
   customFilterComponent?: React.ReactNode,
   customFilterComponent?: React.ReactNode,
   searchValue?: string,
   searchValue?: string,
   dropdownActions: DropdownAction[] | null,
   dropdownActions: DropdownAction[] | null,
+  largeDropdownActionItems?: boolean
 }
 }
 @observer
 @observer
 class MainListFilter extends React.Component<Props> {
 class MainListFilter extends React.Component<Props> {
@@ -131,6 +132,7 @@ class MainListFilter extends React.Component<Props> {
         {this.props.dropdownActions && this.props.dropdownActions.length ? (
         {this.props.dropdownActions && this.props.dropdownActions.length ? (
           <ActionDropdown
           <ActionDropdown
             actions={this.props.dropdownActions}
             actions={this.props.dropdownActions}
+            largeItems={this.props.largeDropdownActionItems}
             style={{ marginLeft: '8px' }}
             style={{ marginLeft: '8px' }}
             data-test-id="mainListFilter-actionButton"
             data-test-id="mainListFilter-actionButton"
           />
           />

+ 2 - 2
src/components/molecules/MainListItem/MainListItem.tsx

@@ -172,9 +172,9 @@ class MainListItem extends React.Component<Props> {
     const destinationType = this.props.endpointType(this.props.item.destination_endpoint_id)
     const destinationType = this.props.endpointType(this.props.item.destination_endpoint_id)
     const endpointImages = (
     const endpointImages = (
       <EndpointsImages>
       <EndpointsImages>
-        <EndpointLogos data-test-id="mainListItem-sourceLogo" height={32} endpoint={sourceType} />
+        <EndpointLogos data-test-id="mainListItem-sourceLogo" height={32} endpoint={sourceType as any} />
         <EndpointImageArrow />
         <EndpointImageArrow />
-        <EndpointLogos data-test-id="mainListItem-destLogo" height={32} endpoint={destinationType} />
+        <EndpointLogos data-test-id="mainListItem-destLogo" height={32} endpoint={destinationType as any} />
       </EndpointsImages>
       </EndpointsImages>
     )
     )
     const status = this.getStatus()
     const status = this.getStatus()

+ 194 - 0
src/components/molecules/MinionPoolListItem/MinionPoolListItem.tsx

@@ -0,0 +1,194 @@
+/*
+Copyright (C) 2020  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/>.
+*/
+
+import React from 'react'
+import styled from 'styled-components'
+import { observer } from 'mobx-react'
+
+import Checkbox from '../../atoms/Checkbox/Checkbox'
+import StatusPill from '../../atoms/StatusPill/StatusPill'
+import EndpointLogos from '../../atoms/EndpointLogos/EndpointLogos'
+import Palette from '../../styleUtils/Palette'
+import StyleProps from '../../styleUtils/StyleProps'
+import itemImage from './images/minion-pool-list-item.svg'
+
+import DateUtils from '../../../utils/DateUtils'
+import { MinionPool } from '../../../@types/MinionPool'
+import { ProviderTypes } from '../../../@types/Providers'
+
+const CheckboxStyled = styled(Checkbox)`
+  opacity: ${props => (props.checked ? 1 : 0)};
+  transition: all ${StyleProps.animations.swift};
+`
+const Content = styled.div<any>`
+  display: flex;
+  align-items: center;
+  margin-left: 16px;
+  border-top: 1px solid ${Palette.grayscale[1]};
+  padding: 8px 16px;
+  cursor: pointer;
+  flex-grow: 1;
+  transition: all ${StyleProps.animations.swift};
+  min-width: 785px;
+
+  &:hover {
+    background: ${Palette.grayscale[1]};
+  }
+`
+const Wrapper = styled.div<any>`
+  display: flex;
+  align-items: center;
+
+  &:hover ${CheckboxStyled} {
+    opacity: 1;
+  }
+
+  &:last-child ${Content} {
+    border-bottom: 1px solid ${Palette.grayscale[1]};
+  }
+`
+
+const Image = styled.div<any>`
+  min-width: 48px;
+  height: 48px;
+  background: url('${itemImage}') no-repeat center;
+  margin-right: 16px;
+`
+const Title = styled.div<any>`
+  flex-grow: 1;
+  overflow: hidden;
+  margin-right: 48px;
+  min-width: 100px;
+`
+const TitleLabel = styled.div<any>`
+  font-size: 16px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`
+const StatusWrapper = styled.div<any>`
+  display: flex;
+  margin-top: 8px;
+`
+const EndpointImage = styled.div<any>`
+  display: flex;
+  align-items: center;
+  margin-right: 48px;
+`
+const ItemLabel = styled.div<any>`
+  color: ${Palette.grayscale[4]};
+`
+const ItemValue = styled.div<any>`
+  color: ${Palette.primary};
+`
+const Column = styled.div`
+  align-self: start;
+`
+
+type Props = {
+  item: MinionPool,
+  onClick: () => void,
+  selected: boolean,
+  endpointType: (endpointId: string) => ProviderTypes | string,
+  onSelectedChange: (value: boolean) => void,
+}
+@observer
+class MinionPoolListItem extends React.Component<Props> {
+  getStatus() {
+    return this.props.item.pool_status
+  }
+
+  renderCreationDate() {
+    return (
+      <Column style={{ minWidth: '170px', maxWidth: '170px', marginRight: '25px' }}>
+        <ItemLabel>
+          Created
+        </ItemLabel>
+        <ItemValue>
+          {DateUtils.getLocalTime(this.props.item.created_at).format('DD MMMM YYYY, HH:mm')}
+        </ItemValue>
+      </Column>
+    )
+  }
+
+  renderUpdateDate() {
+    return (
+      <Column style={{ minWidth: '170px', maxWidth: '170px', marginRight: '25px' }}>
+        <ItemLabel>
+          Updated
+        </ItemLabel>
+        <ItemValue>
+          {this.props.item.updated_at ? DateUtils.getLocalTime(this.props.item.updated_at).format('DD MMMM YYYY, HH:mm') : '-'}
+        </ItemValue>
+      </Column>
+    )
+  }
+
+  renderUser() {
+    return (
+      <Column style={{ minWidth: '115px', maxWidth: '115px' }}>
+        <ItemLabel>
+          OS Type
+        </ItemLabel>
+        <ItemValue
+          style={{
+            textOverflow: 'ellipsis',
+            overflow: 'hidden',
+          }}
+        >
+          {this.props.item.pool_os_type}
+        </ItemValue>
+      </Column>
+    )
+  }
+
+  render() {
+    const endpointType = this.props.endpointType(this.props.item.endpoint_id)
+    const endpointImage = (
+      <EndpointImage>
+        <EndpointLogos height={42} endpoint={endpointType} />
+      </EndpointImage>
+    )
+    const status = this.getStatus()
+
+    return (
+      <Wrapper>
+        <CheckboxStyled
+          checked={this.props.selected}
+          onChange={this.props.onSelectedChange}
+        />
+        <Content onClick={this.props.onClick}>
+          <Image />
+          <Title>
+            <TitleLabel>{this.props.item.pool_name}</TitleLabel>
+            <StatusWrapper>
+              {status ? (
+                <StatusPill
+                  status={status}
+                  style={{ marginRight: '8px' }}
+                />
+              ) : null}
+            </StatusWrapper>
+          </Title>
+          {endpointImage}
+          {this.renderCreationDate()}
+          {this.renderUpdateDate()}
+          {this.renderUser()}
+        </Content>
+      </Wrapper>
+    )
+  }
+}
+
+export default MinionPoolListItem

+ 110 - 0
src/components/molecules/MinionPoolListItem/images/minion-pool-list-item.svg

@@ -0,0 +1,110 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   sodipodi:docname="minion-pool-list-item.svg"
+   inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
+   id="svg8"
+   version="1.1"
+   viewBox="0 0 12.7 12.7"
+   height="48"
+   width="48">
+  <defs
+     id="defs2">
+    <linearGradient
+       osb:paint="solid"
+       id="linearGradient892">
+      <stop
+         id="stop890"
+         offset="0"
+         style="stop-color:#0044ca;stop-opacity:1;" />
+    </linearGradient>
+    <path
+       d="M24,48 C37.254834,48 48,37.254834 48,24 C48,10.745166 37.254834,0 24,0 C10.745166,0 0,10.745166 0,24 C0,37.254834 10.745166,48 24,48 Z"
+       id="path-1" />
+  </defs>
+  <sodipodi:namedview
+     inkscape:window-maximized="0"
+     inkscape:window-y="0"
+     inkscape:window-x="98"
+     inkscape:window-height="1040"
+     inkscape:window-width="1274"
+     units="px"
+     showgrid="false"
+     inkscape:document-rotation="0"
+     inkscape:current-layer="layer1"
+     inkscape:document-units="mm"
+     inkscape:cy="12.413305"
+     inkscape:cx="32.047639"
+     inkscape:zoom="7.9195959"
+     inkscape:pageshadow="2"
+     inkscape:pageopacity="0.0"
+     borderopacity="1.0"
+     bordercolor="#666666"
+     pagecolor="#ffffff"
+     id="base" />
+  <metadata
+     id="metadata5">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     id="layer1"
+     inkscape:groupmode="layer"
+     inkscape:label="Layer 1">
+    <g
+       id="Icon/Project/ProjectListItem"
+       style="fill:none;fill-rule:evenodd;stroke:none;stroke-width:1"
+       transform="scale(0.26458333)">
+      <mask
+         id="mask-2"
+         fill="#ffffff">
+        <use
+           xlink:href="#path-1"
+           id="use15"
+           x="0"
+           y="0"
+           width="100%"
+           height="100%" />
+      </mask>
+      <use
+         id="Pat-Benetar"
+         fill="#c8ccd7"
+         fill-rule="evenodd"
+         xlink:href="#path-1"
+         x="0"
+         y="0"
+         width="100%"
+         height="100%" />
+      <path
+         sodipodi:nodetypes="cccccssssssssssssssccscsssscs"
+         d="M 28.176,16.752495 V 12.78 h -7.944 v 3.973 z M 11.77,19.831 v 14.996 c 0,0.775618 0.634385,1.41 1.41,1.41 h 22.047 c 0.775615,0 1.41,-0.634383 1.41,-1.41 V 19.831 c 0,-0.775615 -0.634385,-1.41 -1.41,-1.41 H 13.18 c -0.775615,0 -1.41,0.634386 -1.41,1.41 z m 23.71529,-3.078484 c 1.565332,0 2.820418,1.255085 2.820418,2.820417 v 15.5123 c 0,1.565331 -1.255086,2.820418 -2.820418,2.820418 H 12.921944 c -1.565332,0 -2.820418,-1.255087 -2.820418,-2.820418 l 0.0141,-15.5123 c 0,-1.565332 1.240984,-2.820417 2.806316,-2.820417 h 5.640837 v -2.820419 c 0,-1.565332 1.255085,-2.820419 2.820418,-2.820419 h 5.640835 c 1.565332,0 2.820418,1.255087 2.820418,2.820419 v 2.820419 z"
+         style="fill:#0044ca;fill-opacity:1;stroke:none;stroke-width:1.41;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         id="path80" />
+      <path
+         sodipodi:nodetypes="cccccssssssssssssssccscsssscs"
+         id="path917"
+         style="fill:none;fill-opacity:1;stroke:none;stroke-width:1.41021;stroke-opacity:1"
+         d="m 27.675789,16.752495 v -3.472062 h -6.944288 v 3.472083 z m -15.405554,3.578939 v 13.995287 c 0,0.775617 0.63465,1.408655 1.410265,1.408713 l 21.046288,0.0016 c 0.775615,5.7e-5 1.410265,-0.634648 1.410265,-1.410265 V 20.331434 c 0,-0.775615 -0.63465,-1.410266 -1.410265,-1.410266 H 13.6805 c -0.775615,0 -1.410265,0.634651 -1.410265,1.410266 z M 35.48529,16.752516 c 1.565332,0 2.820418,1.255085 2.820418,2.820417 v 15.5123 c 0,1.565331 -1.255086,2.820418 -2.820418,2.820418 H 12.921944 c -1.565332,0 -2.820418,-1.255087 -2.820418,-2.820418 l 0.0141,-15.5123 c 0,-1.565332 1.240984,-2.820417 2.806316,-2.820417 h 5.640837 v -2.820419 c 0,-1.565332 1.255085,-2.820419 2.820418,-2.820419 h 5.640835 c 1.565332,0 2.820418,1.255087 2.820418,2.820419 v 2.820419 z" />
+      <path
+         id="path896"
+         style="fill:#0044ca;fill-opacity:1;stroke:none;stroke-width:1.41;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         d="M 28.176,16.752495 V 12.78 h -7.944 v 3.973 z M 11.77,19.831 v 14.996 c 0,0.775618 0.634385,1.41 1.41,1.41 h 22.047 c 0.775615,0 1.41,-0.634383 1.41,-1.41 V 19.831 c 0,-0.775615 -0.634385,-1.41 -1.41,-1.41 H 13.18 c -0.775615,0 -1.41,0.634386 -1.41,1.41 z m 23.71529,-3.078484 c 1.565332,0 2.820418,1.255085 2.820418,2.820417 v 15.5123 c 0,1.565331 -1.255086,2.820418 -2.820418,2.820418 H 12.921944 c -1.565332,0 -2.820418,-1.255087 -2.820418,-2.820418 l 0.0141,-15.5123 c 0,-1.565332 1.240984,-2.820417 2.806316,-2.820417 h 5.640837 v -2.820419 c 0,-1.565332 1.255085,-2.820419 2.820418,-2.820419 h 5.640835 c 1.565332,0 2.820418,1.255087 2.820418,2.820419 v 2.820419 z"
+         sodipodi:nodetypes="cccccssssssssssssssccscsssscs" />
+    </g>
+  </g>
+</svg>

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

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

+ 54 - 53
src/components/molecules/NewItemDropdown/NewItemDropdown.tsx

@@ -30,9 +30,19 @@ import replicaImage from './images/replica.svg'
 import endpointImage from './images/endpoint.svg'
 import endpointImage from './images/endpoint.svg'
 import userImage from './images/user.svg'
 import userImage from './images/user.svg'
 import projectImage from './images/project.svg'
 import projectImage from './images/project.svg'
+import minionPoolImage from './images/minionPool.svg'
 
 
 import { navigationMenu } from '../../../constants'
 import { navigationMenu } from '../../../constants'
 
 
+const ICON_MAP = {
+  migration: migrationImage,
+  replica: replicaImage,
+  endpoint: endpointImage,
+  minionPool: minionPoolImage,
+  user: userImage,
+  project: projectImage,
+}
+
 const Wrapper = styled.div<any>`
 const Wrapper = styled.div<any>`
   position: relative;
   position: relative;
 `
 `
@@ -86,26 +96,10 @@ const ListItem = styled(Link)`
   }
   }
 `
 `
 
 
-const getIcon = (props: any) => {
-  if (props.migration) {
-    return migrationImage
-  }
-  if (props.replica) {
-    return replicaImage
-  }
-  if (props.user) {
-    return userImage
-  }
-  if (props.project) {
-    return projectImage
-  }
-  return endpointImage
-}
-
-const Icon = styled.div<any>`
+const Icon = styled.div<{iconName: keyof typeof ICON_MAP}>`
   min-width: 48px;
   min-width: 48px;
   height: 48px;
   height: 48px;
-  background: url('${props => getIcon(props)}') no-repeat center;
+  background: url('${props => ICON_MAP[props.iconName]}') no-repeat center;
   margin: 16px;
   margin: 16px;
 `
 `
 const Content = styled.div<any>`
 const Content = styled.div<any>`
@@ -122,10 +116,7 @@ const Description = styled.div<any>`
 
 
 export type ItemType = {
 export type ItemType = {
   href?: string,
   href?: string,
-  icon: {
-    migration?: boolean,
-    replica?: boolean, endpoint?: boolean, user?: boolean, project?: boolean
-  },
+  iconName: keyof typeof ICON_MAP,
   title: string,
   title: string,
   description: string,
   description: string,
   value?: string,
   value?: string,
@@ -180,36 +171,48 @@ class NewItemDropdown extends React.Component<Props, State> {
 
 
     const isAdmin = userStore.loggedUser ? userStore.loggedUser.isAdmin : false
     const isAdmin = userStore.loggedUser ? userStore.loggedUser.isAdmin : false
     const disabledPages = configLoader.config ? configLoader.config.disabledPages : []
     const disabledPages = configLoader.config ? configLoader.config.disabledPages : []
-    const items: ItemType[] = [{
-      title: 'Migration',
-      href: '/wizard/migration',
-      description: 'Migrate VMs between two clouds',
-      icon: { migration: true },
-    }, {
-      title: 'Replica',
-      href: '/wizard/replica',
-      description: 'Incrementally replicate VMs between two clouds',
-      icon: { replica: true },
-    }, {
-      title: 'Endpoint',
-      value: 'endpoint',
-      description: 'Add connection information for a cloud',
-      icon: { endpoint: true },
-    }, {
-      title: 'User',
-      value: 'user',
-      description: 'Create a new Coriolis user',
-      icon: { user: true },
-      disabled: Boolean(navigationMenu.find(i => i.value === 'users'
+    const items: ItemType[] = [
+      {
+        title: 'Migration',
+        href: '/wizard/migration',
+        description: 'Migrate VMs between two clouds',
+        iconName: 'migration',
+      },
+      {
+        title: 'Replica',
+        href: '/wizard/replica',
+        description: 'Incrementally replicate VMs between two clouds',
+        iconName: 'replica',
+      },
+      {
+        title: 'Endpoint',
+        value: 'endpoint',
+        description: 'Add connection information for a cloud',
+        iconName: 'endpoint',
+      },
+      {
+        title: 'Minion Pool',
+        value: 'minionPool',
+        description: 'Create a new Coriolis Minion Pool',
+        iconName: 'minionPool',
+      },
+      {
+        title: 'User',
+        value: 'user',
+        description: 'Create a new Coriolis user',
+        iconName: 'user',
+        disabled: Boolean(navigationMenu.find(i => i.value === 'users'
         && (disabledPages.find(p => p === 'users') || (i.requiresAdmin && !isAdmin)))),
         && (disabledPages.find(p => p === 'users') || (i.requiresAdmin && !isAdmin)))),
-    }, {
-      title: 'Project',
-      value: 'project',
-      description: 'Create a new Coriolis project',
-      icon: { project: true },
-      disabled: Boolean(navigationMenu.find(i => i.value === 'projects'
+      },
+      {
+        title: 'Project',
+        value: 'project',
+        description: 'Create a new Coriolis project',
+        iconName: 'project',
+        disabled: Boolean(navigationMenu.find(i => i.value === 'projects'
         && (disabledPages.find(p => p === 'users') || (i.requiresAdmin && !isAdmin)))),
         && (disabledPages.find(p => p === 'users') || (i.requiresAdmin && !isAdmin)))),
-    }]
+      },
+    ]
 
 
     const list = (
     const list = (
       <List>
       <List>
@@ -217,7 +220,6 @@ class NewItemDropdown extends React.Component<Props, State> {
           items.filter(i => (i.disabled ? !i.disabled : i.requiresAdmin ? isAdmin : true))
           items.filter(i => (i.disabled ? !i.disabled : i.requiresAdmin ? isAdmin : true))
             .map(item => (
             .map(item => (
               <ListItem
               <ListItem
-                data-test-id={`newItemDropdown-listItem-${item.title}`}
                 key={item.title}
                 key={item.title}
                 onMouseDown={() => { this.itemMouseDown = true }}
                 onMouseDown={() => { this.itemMouseDown = true }}
                 onMouseUp={() => { this.itemMouseDown = false }}
                 onMouseUp={() => { this.itemMouseDown = false }}
@@ -225,8 +227,7 @@ class NewItemDropdown extends React.Component<Props, State> {
                 onClick={() => { this.handleItemClick(item) }}
                 onClick={() => { this.handleItemClick(item) }}
               >
               >
                 <Icon
                 <Icon
-                  // eslint-disable-next-line react/jsx-props-no-spreading
-                  {...item.icon}
+                  iconName={item.iconName}
                 />
                 />
                 <Content>
                 <Content>
                   <Title>{item.title}</Title>
                   <Title>{item.title}</Title>

+ 70 - 0
src/components/molecules/NewItemDropdown/images/minionPool.svg

@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   sodipodi:docname="minionPool.svg"
+   inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
+   id="svg858"
+   version="1.1"
+   viewBox="0 0 12.170833 12.170834"
+   height="46"
+   width="46">
+  <defs
+     id="defs852">
+    <linearGradient
+       osb:paint="solid"
+       id="linearGradient1457">
+      <stop
+         id="stop1455"
+         offset="0"
+         style="stop-color:#0044ca;stop-opacity:1;" />
+    </linearGradient>
+  </defs>
+  <sodipodi:namedview
+     inkscape:window-maximized="0"
+     inkscape:window-y="0"
+     inkscape:window-x="403"
+     inkscape:window-height="1040"
+     inkscape:window-width="1274"
+     units="px"
+     showgrid="false"
+     inkscape:document-rotation="0"
+     inkscape:current-layer="layer1"
+     inkscape:document-units="mm"
+     inkscape:cy="6.7745742"
+     inkscape:cx="9.8898401"
+     inkscape:zoom="5.6"
+     inkscape:pageshadow="2"
+     inkscape:pageopacity="0.0"
+     borderopacity="1.0"
+     bordercolor="#666666"
+     pagecolor="#ffffff"
+     id="base" />
+  <metadata
+     id="metadata855">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     id="layer1"
+     inkscape:groupmode="layer"
+     inkscape:label="Layer 1">
+    <path
+       d="M 7.5316674,2.8742546 V 1.4229441 H 4.628978 V 2.8742627 Z M 1.0921959,4.3702397 v 5.8499833 c 0,0.324205 0.2652822,0.589487 0.5894863,0.589487 h 8.7972798 c 0.324206,0 0.589485,-0.265282 0.589485,-0.589487 V 4.3702397 c 0,-0.324205 -0.265279,-0.5894871 -0.589485,-0.5894871 H 1.6816822 c -0.3242041,0 -0.5894863,0.2652821 -0.5894863,0.5894871 z m 9.7038161,-1.495977 c 0.654305,0 1.178927,0.5246217 1.178927,1.1789256 V 10.53728 c 0,0.654302 -0.524622,1.178926 -1.178927,1.178926 H 1.3646085 c -0.65430385,0 -1.17892609,-0.524624 -1.17892609,-1.178926 l 0.005892,-6.4840917 c 0,-0.6543039 0.5187271,-1.1789256 1.17303099,-1.1789256 h 2.357851 V 1.695337 c 0,-0.6543033 0.5246217,-1.17892568 1.1789258,-1.17892568 h 2.357851 c 0.6543032,0 1.1789251,0.52462238 1.1789251,1.17892568 v 1.1789257 z"
+       style="fill:none;fill-opacity:1;stroke:#0044ca;stroke-width:0.371364;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       id="path10" />
+  </g>
+</svg>

+ 16 - 2
src/components/molecules/PropertiesTable/PropertiesTable.tsx

@@ -28,6 +28,7 @@ import { Field, EnumItem, isEnumSeparator } from '../../../@types/Field'
 
 
 const Wrapper = styled.div<any>`
 const Wrapper = styled.div<any>`
   display: flex;
   display: flex;
+  ${props => (props.width ? `width: ${props.width}px;` : '')}
   flex-direction: column;
   flex-direction: column;
   border: 1px solid ${Palette.grayscale[2]};
   border: 1px solid ${Palette.grayscale[2]};
   border-radius: ${StyleProps.borderRadius};
   border-radius: ${StyleProps.borderRadius};
@@ -39,6 +40,13 @@ const Column = styled.div<any>`
   padding: 0 8px 0 16px;
   padding: 0 8px 0 16px;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
+  > span {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    min-width: 0;
+  }
+
   ${props => (props.header ? css`
   ${props => (props.header ? css`
     color: ${Palette.grayscale[4]};
     color: ${Palette.grayscale[4]};
     background: ${Palette.grayscale[7]};
     background: ${Palette.grayscale[7]};
@@ -65,10 +73,16 @@ type Props = {
   valueCallback: (property: Field) => any,
   valueCallback: (property: Field) => any,
   hideRequiredSymbol?: boolean,
   hideRequiredSymbol?: boolean,
   disabledLoading?: boolean,
   disabledLoading?: boolean,
+  labelRenderer?: ((propName: string) => string) | null,
+  width?: number,
 }
 }
 @observer
 @observer
 class PropertiesTable extends React.Component<Props> {
 class PropertiesTable extends React.Component<Props> {
   getName(propName: string): string {
   getName(propName: string): string {
+    if (this.props.labelRenderer) {
+      return this.props.labelRenderer(propName)
+    }
+
     if (propName.indexOf('/') > -1) {
     if (propName.indexOf('/') > -1) {
       return LabelDictionary.get(propName.substr(propName.lastIndexOf('/') + 1))
       return LabelDictionary.get(propName.substr(propName.lastIndexOf('/') + 1))
     }
     }
@@ -181,10 +195,10 @@ class PropertiesTable extends React.Component<Props> {
 
 
   render() {
   render() {
     return (
     return (
-      <Wrapper disabledLoading={this.props.disabledLoading}>
+      <Wrapper disabledLoading={this.props.disabledLoading} width={this.props.width}>
         {this.props.properties.map(prop => (
         {this.props.properties.map(prop => (
           <Row key={prop.name} data-test-id={`${baseId}-row-${prop.name}`}>
           <Row key={prop.name} data-test-id={`${baseId}-row-${prop.name}`}>
-            <Column header data-test-id={`${baseId}-header`}>{this.getName(prop.name)}</Column>
+            <Column header data-test-id={`${baseId}-header`}><span title={this.getName(prop.name)}>{this.getName(prop.name)}</span></Column>
             <Column input>{this.renderInput(prop)}</Column>
             <Column input>{this.renderInput(prop)}</Column>
           </Row>
           </Row>
         ))}
         ))}

+ 2 - 0
src/components/organisms/DetailsContentHeader/DetailsContentHeader.tsx

@@ -89,6 +89,7 @@ type Props = {
   itemTitle?: string | null
   itemTitle?: string | null
   itemType?: string
   itemType?: string
   itemDescription?: string
   itemDescription?: string
+  largeDropdownActionItems?: boolean
 }
 }
 @observer
 @observer
 class DetailsContentHeader extends React.Component<Props> {
 class DetailsContentHeader extends React.Component<Props> {
@@ -124,6 +125,7 @@ class DetailsContentHeader extends React.Component<Props> {
     return (
     return (
       <ActionDropdown
       <ActionDropdown
         actions={this.props.dropdownActions}
         actions={this.props.dropdownActions}
+        largeItems={this.props.largeDropdownActionItems}
         style={{ marginLeft: '32px' }}
         style={{ marginLeft: '32px' }}
         data-test-id="dcHeader-actionButton"
         data-test-id="dcHeader-actionButton"
       />
       />

+ 59 - 39
src/components/organisms/EditReplica/EditReplica.tsx

@@ -28,7 +28,7 @@ import Modal from '../../molecules/Modal'
 import Panel from '../../molecules/Panel'
 import Panel from '../../molecules/Panel'
 import { isOptionsPageValid } from '../WizardPageContent'
 import { isOptionsPageValid } from '../WizardPageContent'
 import WizardNetworks from '../WizardNetworks'
 import WizardNetworks from '../WizardNetworks'
-import WizardOptions from '../WizardOptions'
+import WizardOptions, { INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS } from '../WizardOptions'
 import WizardStorage from '../WizardStorage/WizardStorage'
 import WizardStorage from '../WizardStorage/WizardStorage'
 
 
 import type {
 import type {
@@ -44,6 +44,7 @@ import { providerTypes, migrationFields } from '../../../constants'
 import configLoader from '../../../utils/Config'
 import configLoader from '../../../utils/Config'
 import StyleProps from '../../styleUtils/StyleProps'
 import StyleProps from '../../styleUtils/StyleProps'
 import LoadingButton from '../../molecules/LoadingButton/LoadingButton'
 import LoadingButton from '../../molecules/LoadingButton/LoadingButton'
+import minionPoolStore from '../../../stores/MinionPoolStore'
 
 
 const PanelContent = styled.div<any>`
 const PanelContent = styled.div<any>`
   display: flex;
   display: flex;
@@ -209,21 +210,49 @@ class EditReplica extends React.Component<Props, State> {
       ? this.state.defaultStorage : replicaDefaultStorage
       ? this.state.defaultStorage : replicaDefaultStorage
   }
   }
 
 
-  getFieldValue(type: 'source' | 'destination', fieldName: string, defaultValue: any) {
+  getFieldValue(type: 'source' | 'destination', fieldName: string, defaultValue: any, parentFieldName?: string) {
     const currentData = type === 'source' ? this.state.sourceData : this.state.destinationData
     const currentData = type === 'source' ? this.state.sourceData : this.state.destinationData
+
+    const replicaMinionMappings = this.props.replica[INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS]
+
+    if (parentFieldName) {
+      if (currentData[parentFieldName]
+        && currentData[parentFieldName][fieldName] !== undefined) {
+        return currentData[parentFieldName][fieldName]
+      }
+      if (parentFieldName === INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS
+        && replicaMinionMappings
+        && replicaMinionMappings[fieldName] !== undefined
+      ) {
+        return replicaMinionMappings[fieldName]
+      }
+    }
+
     if (currentData[fieldName] !== undefined) {
     if (currentData[fieldName] !== undefined) {
       return currentData[fieldName]
       return currentData[fieldName]
     }
     }
-    const replicaData: any = this.parseReplicaData(type === 'source' ? this.props.replica.source_environment
-      : this.props.replica.destination_environment)
+
+    if (fieldName === 'minion_pool_id') {
+      return type === 'source' ? this.props.replica.origin_minion_pool_id : this.props.replica.destination_minion_pool_id
+    }
+
+    const replicaData: any = type === 'source' ? this.props.replica.source_environment
+      : this.props.replica.destination_environment
+
+    if (parentFieldName) {
+      if (replicaData[parentFieldName]?.[fieldName] !== undefined) {
+        return replicaData[parentFieldName][fieldName]
+      }
+    }
     if (replicaData[fieldName] !== undefined) {
     if (replicaData[fieldName] !== undefined) {
       return replicaData[fieldName]
       return replicaData[fieldName]
     }
     }
-    const osMapping = /^(windows|linux)_os_image$/.exec(fieldName)
+    const endpoint = type === 'source' ? this.props.sourceEndpoint : this.props.destinationEndpoint
+    const plugin = OptionsSchemaPlugin.for(endpoint.type)
+
+    const osMapping = new RegExp(`^(windows|linux)${plugin.imageSuffix}`).exec(fieldName)
     if (osMapping) {
     if (osMapping) {
-      const endpoint = type === 'source' ? this.props.sourceEndpoint : this.props.destinationEndpoint
-      const plugin = OptionsSchemaPlugin.for(endpoint.type)
-      const osData = replicaData[`${plugin.migrationImageMapFieldName}/${osMapping[1]}`]
+      const osData = replicaData[`${plugin.migrationImageMapFieldName}/${osMapping[0]}`]
       return osData
       return osData
     }
     }
     const anyData = this.props.replica as any
     const anyData = this.props.replica as any
@@ -237,6 +266,7 @@ class EditReplica extends React.Component<Props, State> {
   }
   }
 
 
   async loadData(useCache: boolean) {
   async loadData(useCache: boolean) {
+    minionPoolStore.loadMinionPools()
     await providerStore.loadProviders()
     await providerStore.loadProviders()
 
 
     if (this.hasStorageMap()) {
     if (this.hasStorageMap()) {
@@ -298,7 +328,7 @@ class EditReplica extends React.Component<Props, State> {
       providerName: endpoint.type,
       providerName: endpoint.type,
       schema: type === 'source' ? providerStore.sourceSchema : providerStore.destinationSchema,
       schema: type === 'source' ? providerStore.sourceSchema : providerStore.destinationSchema,
       data: {
       data: {
-        ...this.parseReplicaData(env),
+        ...env,
         ...stateEnv,
         ...stateEnv,
       },
       },
       field,
       field,
@@ -353,33 +383,12 @@ class EditReplica extends React.Component<Props, State> {
       || this.isLoadingStorage()
       || this.isLoadingStorage()
   }
   }
 
 
-  parseReplicaData(environment: { [prop: string]: any } | null) {
-    const data: any = {}
-    const env = environment
-    if (!env) {
-      return data
-    }
-    Object.keys(env).forEach(key => {
-      if (env[key] && typeof env[key] === 'object' && !Array.isArray(JSON.parse(JSON.stringify(env[key])))) {
-        Object.keys(env[key]).forEach(subkey => {
-          const destParent: any = env[key]
-          if (destParent[subkey]) {
-            data[`${key}/${subkey}`] = destParent[subkey]
-          }
-        })
-      } else {
-        data[key] = env[key]
-      }
-    })
-    return data
-  }
-
   validateOptions(type: 'source' | 'destination') {
   validateOptions(type: 'source' | 'destination') {
     const env = type === 'source' ? this.props.replica.source_environment : this.props.replica.destination_environment
     const env = type === 'source' ? this.props.replica.source_environment : this.props.replica.destination_environment
     const data = type === 'source' ? this.state.sourceData : this.state.destinationData
     const data = type === 'source' ? this.state.sourceData : this.state.destinationData
     const schema = type === 'source' ? providerStore.sourceSchema : providerStore.destinationSchema
     const schema = type === 'source' ? providerStore.sourceSchema : providerStore.destinationSchema
     const isValid = isOptionsPageValid({
     const isValid = isOptionsPageValid({
-      ...this.parseReplicaData(env),
+      ...env,
       ...data,
       ...data,
     }, schema)
     }, schema)
 
 
@@ -395,8 +404,9 @@ class EditReplica extends React.Component<Props, State> {
     this.loadData(false)
     this.loadData(false)
   }
   }
 
 
-  handleFieldChange(type: 'source' | 'destination', field: Field, value: any) {
+  handleFieldChange(type: 'source' | 'destination', field: Field, value: any, parentFieldName?: string) {
     const data = type === 'source' ? { ...this.state.sourceData } : { ...this.state.destinationData }
     const data = type === 'source' ? { ...this.state.sourceData } : { ...this.state.destinationData }
+
     if (field.type === 'array') {
     if (field.type === 'array') {
       const oldValues: string[] = data[field.name] || []
       const oldValues: string[] = data[field.name] || []
       if (oldValues.find(v => v === value)) {
       if (oldValues.find(v => v === value)) {
@@ -404,6 +414,9 @@ class EditReplica extends React.Component<Props, State> {
       } else {
       } else {
         data[field.name] = [...oldValues, value]
         data[field.name] = [...oldValues, value]
       }
       }
+    } else if (parentFieldName) {
+      data[parentFieldName] = data[parentFieldName] || {}
+      data[parentFieldName][field.name] = value
     } else {
     } else {
       data[field.name] = value
       data[field.name] = value
     }
     }
@@ -441,11 +454,13 @@ class EditReplica extends React.Component<Props, State> {
     }
     }
     if (this.props.type === 'replica') {
     if (this.props.type === 'replica') {
       try {
       try {
-        await replicaStore.update(
-          this.props.replica as any,
-          this.props.destinationEndpoint,
-          updateData, this.getDefaultStorage(), endpointStore.storageConfigDefault,
-        )
+        await replicaStore.update({
+          replica: this.props.replica as any,
+          destinationEndpoint: this.props.destinationEndpoint,
+          updateData,
+          defaultStorage: this.getDefaultStorage(),
+          storageConfigDefault: endpointStore.storageConfigDefault,
+        })
         this.props.onRequestClose()
         this.props.onRequestClose()
         this.props.onUpdateComplete(`/replicas/${this.props.replica.id}/executions`)
         this.props.onUpdateComplete(`/replicas/${this.props.replica.id}/executions`)
       } catch (err) {
       } catch (err) {
@@ -531,15 +546,20 @@ class EditReplica extends React.Component<Props, State> {
     if (endpoint) {
     if (endpoint) {
       dictionaryKey = `${endpoint.type}-${type}`
       dictionaryKey = `${endpoint.type}-${type}`
     }
     }
+    const minionPools = type === 'source'
+      ? minionPoolStore.minionPools.filter(m => m.endpoint_id === this.props.sourceEndpoint.id)
+      : minionPoolStore.minionPools.filter(m => m.endpoint_id === this.props.destinationEndpoint.id)
     return (
     return (
       <WizardOptions
       <WizardOptions
+        minionPools={minionPools}
         wizardType={`${this.props.type || 'replica'}-${type}-options-edit`}
         wizardType={`${this.props.type || 'replica'}-${type}-options-edit`}
-        getFieldValue={(f, d) => this.getFieldValue(type, f, d)}
+        getFieldValue={(f, d, pf) => this.getFieldValue(type, f, d, pf)}
         fields={fields}
         fields={fields}
+        selectedInstances={type === 'destination' ? this.props.instancesDetails : null}
         hasStorageMap={type === 'source' ? false : this.hasStorageMap()}
         hasStorageMap={type === 'source' ? false : this.hasStorageMap()}
         storageBackends={endpointStore.storageBackends}
         storageBackends={endpointStore.storageBackends}
         storageConfigDefault={endpointStore.storageConfigDefault}
         storageConfigDefault={endpointStore.storageConfigDefault}
-        onChange={(f, v) => { this.handleFieldChange(type, f, v) }}
+        onChange={(f, v, fp) => { this.handleFieldChange(type, f, v, fp) }}
         oneColumnStyle={{
         oneColumnStyle={{
           marginTop: '-16px', display: 'flex', flexDirection: 'column', width: '100%', alignItems: 'center',
           marginTop: '-16px', display: 'flex', flexDirection: 'column', width: '100%', alignItems: 'center',
         }}
         }}

+ 1 - 1
src/components/organisms/EndpointDetailsContent/EndpointDetailsContent.tsx

@@ -179,7 +179,7 @@ class EndpointDetailsContent extends React.Component<Props> {
       <span>
       <span>
         <LinkStyled
         <LinkStyled
           key={item.id}
           key={item.id}
-          to={`/${item.type}/${item.id}`}
+          to={`/${item.type}s/${item.id}`}
         >
         >
           {item.instances[0]}
           {item.instances[0]}
         </LinkStyled>
         </LinkStyled>

+ 7 - 5
src/components/organisms/Executions/Executions.tsx

@@ -90,8 +90,8 @@ type Props = {
   tasksLoading: boolean,
   tasksLoading: boolean,
   onChange: (executionId: string) => void,
   onChange: (executionId: string) => void,
   onCancelExecutionClick: (execution: Execution | null, force?: boolean) => void,
   onCancelExecutionClick: (execution: Execution | null, force?: boolean) => void,
-  onDeleteExecutionClick: (execution: Execution | null) => void,
-  onExecuteClick: () => void,
+  onDeleteExecutionClick?: (execution: Execution | null) => void,
+  onExecuteClick?: () => void,
 }
 }
 type State = {
 type State = {
   selectedExecution: Execution | null,
   selectedExecution: Execution | null,
@@ -275,7 +275,6 @@ class Executions extends React.Component<Props, State> {
           secondary
           secondary
           hollow
           hollow
           onClick={() => { this.handleCancelExecutionClick() }}
           onClick={() => { this.handleCancelExecutionClick() }}
-          data-test-id="executions-cancelButton"
         >Cancel Execution
         >Cancel Execution
         </Button>
         </Button>
       )
       )
@@ -292,12 +291,15 @@ class Executions extends React.Component<Props, State> {
       )
       )
     }
     }
 
 
+    const onDeleteExecutionClick = this.props.onDeleteExecutionClick
+    if (!onDeleteExecutionClick) {
+      return null
+    }
     return (
     return (
       <Button
       <Button
         alert
         alert
         hollow
         hollow
-        onClick={() => { this.props.onDeleteExecutionClick(this.state.selectedExecution) }}
-        data-test-id="executions-deleteButton"
+        onClick={() => { onDeleteExecutionClick(this.state.selectedExecution) }}
       >Delete Execution
       >Delete Execution
       </Button>
       </Button>
     )
     )

+ 2 - 0
src/components/organisms/FilterList/FilterList.tsx

@@ -49,6 +49,7 @@ type Props = {
   emptyListButtonLabel?: string,
   emptyListButtonLabel?: string,
   onEmptyListButtonClick?: () => void,
   onEmptyListButtonClick?: () => void,
   customFilterComponent?: React.ReactNode,
   customFilterComponent?: React.ReactNode,
+  largeDropdownActionItems?: boolean
 }
 }
 type State = {
 type State = {
   items: any[],
   items: any[],
@@ -210,6 +211,7 @@ class FilterList extends React.Component<Props, State> {
           }}
           }}
           items={this.props.filterItems}
           items={this.props.filterItems}
           dropdownActions={this.props.dropdownActions || []}
           dropdownActions={this.props.dropdownActions || []}
+          largeDropdownActionItems={this.props.largeDropdownActionItems}
           data-test-id="filterList-filter"
           data-test-id="filterList-filter"
         />
         />
         <MainList
         <MainList

+ 35 - 3
src/components/organisms/MainDetails/MainDetails.tsx

@@ -39,6 +39,7 @@ import { OptionsSchemaPlugin } from '../../../plugins/endpoint'
 
 
 import arrowImage from './images/arrow.svg'
 import arrowImage from './images/arrow.svg'
 import { TransferItem } from '../../../@types/MainItem'
 import { TransferItem } from '../../../@types/MainItem'
+import { MinionPool } from '../../../@types/MinionPool'
 
 
 const Wrapper = styled.div<any>`
 const Wrapper = styled.div<any>`
   display: flex;
   display: flex;
@@ -121,6 +122,7 @@ const PropertyValue = styled.div<any>`
 
 
 type Props = {
 type Props = {
   item?: TransferItem | null,
   item?: TransferItem | null,
+  minionPools: MinionPool[]
   destinationSchema: FieldType[],
   destinationSchema: FieldType[],
   destinationSchemaLoading: boolean,
   destinationSchemaLoading: boolean,
   sourceSchema: FieldType[],
   sourceSchema: FieldType[],
@@ -235,7 +237,7 @@ class MainDetails extends React.Component<Props, State> {
           }
           }
           let fieldName = pn
           let fieldName = pn
           if (migrationImageMapFieldName && fieldName === migrationImageMapFieldName) {
           if (migrationImageMapFieldName && fieldName === migrationImageMapFieldName) {
-            fieldName = `${p}_os_image`
+            fieldName = `${p}${plugin?.imageSuffix}`
           }
           }
           return {
           return {
             label: `${label} - ${LabelDictionary.get(p)}`,
             label: `${label} - ${LabelDictionary.get(p)}`,
@@ -283,6 +285,11 @@ class MainDetails extends React.Component<Props, State> {
       )) : []
       )) : []
     }
     }
 
 
+    const sourceMinionPool = this.props.minionPools
+      .find(m => m.id === this.props.item?.origin_minion_pool_id)
+    const destMinionPool = this.props.minionPools
+      .find(m => m.id === this.props.item?.destination_minion_pool_id)
+
     return (
     return (
       <ColumnsLayout>
       <ColumnsLayout>
         <Column width="42.5%">
         <Column width="42.5%">
@@ -294,7 +301,7 @@ class MainDetails extends React.Component<Props, State> {
           </Row>
           </Row>
           <Row>
           <Row>
             <EndpointLogos
             <EndpointLogos
-              endpoint={sourceEndpoint ? sourceEndpoint.type : ''}
+              endpoint={(sourceEndpoint ? sourceEndpoint.type : '') as any}
               data-test-id="mainDetails-sourceLogo"
               data-test-id="mainDetails-sourceLogo"
             />
             />
           </Row>
           </Row>
@@ -339,6 +346,18 @@ class MainDetails extends React.Component<Props, State> {
               </Field>
               </Field>
             </Row>
             </Row>
           ) : null}
           ) : null}
+          {this.props.item?.origin_minion_pool_id ? (
+            <Row>
+              <Field>
+                <Label>Source Minion Pool</Label>
+                {sourceMinionPool ? (
+                  <ValueLink to={`/minion-pools/${sourceMinionPool.id}`}>{sourceMinionPool.pool_name}</ValueLink>
+                ) : (
+                  <Value>{this.props.item.origin_minion_pool_id}</Value>
+                )}
+              </Field>
+            </Row>
+          ) : null}
           {this.props.item?.type === 'migration' && this.props.item.replica_id ? (
           {this.props.item?.type === 'migration' && this.props.item.replica_id ? (
             <Row>
             <Row>
               <Field>
               <Field>
@@ -360,7 +379,7 @@ class MainDetails extends React.Component<Props, State> {
           </Row>
           </Row>
           <Row>
           <Row>
             <EndpointLogos
             <EndpointLogos
-              endpoint={destinationEndpoint ? destinationEndpoint.type : ''}
+              endpoint={(destinationEndpoint ? destinationEndpoint.type : '') as any}
               data-test-id="mainDetails-targetLogo"
               data-test-id="mainDetails-targetLogo"
             />
             />
           </Row>
           </Row>
@@ -375,6 +394,18 @@ class MainDetails extends React.Component<Props, State> {
               </Field>
               </Field>
             </Row>
             </Row>
           ) : null}
           ) : null}
+          {this.props.item?.destination_minion_pool_id ? (
+            <Row>
+              <Field>
+                <Label>Target Minion Pool</Label>
+                {destMinionPool ? (
+                  <ValueLink to={`/minion-pools/${destMinionPool.id}`}>{destMinionPool.pool_name}</ValueLink>
+                ) : (
+                  <Value>{this.props.item.destination_minion_pool_id}</Value>
+                )}
+              </Field>
+            </Row>
+          ) : null}
         </Column>
         </Column>
       </ColumnsLayout>
       </ColumnsLayout>
     )
     )
@@ -407,6 +438,7 @@ class MainDetails extends React.Component<Props, State> {
         {this.props.instancesDetailsLoading || this.props.loading ? null : (
         {this.props.instancesDetailsLoading || this.props.loading ? null : (
           <MainDetailsTable
           <MainDetailsTable
             item={this.props.item}
             item={this.props.item}
+            minionPools={this.props.minionPools}
             instancesDetails={this.props.instancesDetails}
             instancesDetails={this.props.instancesDetails}
             networks={this.props.networks}
             networks={this.props.networks}
           />
           />

+ 1 - 0
src/components/organisms/MainList/MainList.tsx

@@ -74,6 +74,7 @@ const EmptyListMessage = styled.div<any>`
   font-size: 18px;
   font-size: 18px;
 `
 `
 const EmptyListExtraMessage = styled.div<any>`
 const EmptyListExtraMessage = styled.div<any>`
+  max-width: 700px;
   text-align: center;
   text-align: center;
   margin: 10px 0 25px 0;
   margin: 10px 0 25px 0;
 `
 `

+ 3 - 0
src/components/organisms/MigrationDetailsContent/MigrationDetailsContent.tsx

@@ -26,6 +26,7 @@ import type { Instance } from '../../../@types/Instance'
 import type { Endpoint } from '../../../@types/Endpoint'
 import type { Endpoint } from '../../../@types/Endpoint'
 import type { Field } from '../../../@types/Field'
 import type { Field } from '../../../@types/Field'
 import { MigrationItemDetails } from '../../../@types/MainItem'
 import { MigrationItemDetails } from '../../../@types/MainItem'
+import { MinionPool } from '../../../@types/MinionPool'
 
 
 const Wrapper = styled.div<any>`
 const Wrapper = styled.div<any>`
   display: flex;
   display: flex;
@@ -54,6 +55,7 @@ const NavigationItems = [
 
 
 type Props = {
 type Props = {
   item: MigrationItemDetails | null,
   item: MigrationItemDetails | null,
+  minionPools: MinionPool[]
   detailsLoading: boolean,
   detailsLoading: boolean,
   instancesDetails: Instance[],
   instancesDetails: Instance[],
   instancesDetailsLoading: boolean,
   instancesDetailsLoading: boolean,
@@ -88,6 +90,7 @@ class MigrationDetailsContent extends React.Component<Props> {
     return (
     return (
       <MainDetails
       <MainDetails
         item={this.props.item}
         item={this.props.item}
+        minionPools={this.props.minionPools}
         instancesDetails={this.props.instancesDetails}
         instancesDetails={this.props.instancesDetails}
         instancesDetailsLoading={this.props.instancesDetailsLoading}
         instancesDetailsLoading={this.props.instancesDetailsLoading}
         sourceSchema={this.props.sourceSchema}
         sourceSchema={this.props.sourceSchema}

+ 221 - 0
src/components/organisms/MinionEndpointModal/MinionEndpointModal.tsx

@@ -0,0 +1,221 @@
+/*
+Copyright (C) 2020  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/>.
+*/
+
+import React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+
+import Modal from '../../molecules/Modal'
+import { Providers, ProviderTypes } from '../../../@types/Providers'
+import { Endpoint } from '../../../@types/Endpoint'
+import StatusImage from '../../atoms/StatusImage/StatusImage'
+import Switch from '../../atoms/Switch'
+import { providerTypes } from '../../../constants'
+import EndpointLogos from '../../atoms/EndpointLogos/EndpointLogos'
+import Dropdown from '../../molecules/Dropdown/Dropdown'
+import Button from '../../atoms/Button/Button'
+
+const Wrapper = styled.div``
+const LoadingWrapper = styled.div`
+  margin: 96px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+`
+const ContentWrapper = styled.div`
+  padding: 48px;
+`
+const NoEndpoints = styled.div`
+  padding: 64px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+`
+const NoEndpointsMessage = styled.div`
+  text-align: center;
+  margin-top: 48px;
+`
+const ProviderWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+`
+const ButtonWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin: 16px 32px 32px 32px;
+`
+const PoolPlatformWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin: 32px 0 64px 0;
+`
+const PoolPlatformOptions = styled.div`
+  display: flex;
+  align-items: center;
+`
+const PoolPlatformOption = styled.div``
+const SwitchWrapper = styled.div`
+  margin: 0 16px;
+`
+
+type Props = {
+  providers: Providers | null
+  endpoints: Endpoint[]
+  loading: boolean
+  onRequestClose: () => void
+  onSelectEndpoint: (endpoint: Endpoint, platform: 'source' | 'destination') => void
+}
+
+type State = {
+  selectedEndpoint: Endpoint | null
+  platform: 'source' | 'destination'
+}
+
+@observer
+class MinionEndpointModal extends React.Component<Props, State> {
+  state: State = {
+    selectedEndpoint: null,
+    platform: 'source',
+  }
+
+  handleNextClick() {
+    this.props.onSelectEndpoint(this.state.selectedEndpoint!, this.state.platform)
+  }
+
+  renderNoEndpoints() {
+    return (
+      <NoEndpoints>
+        <StatusImage status="ERROR" />
+        <NoEndpointsMessage>
+          Please create a Coriolis Endpoint with Minion Pool support
+          before creating a Coriolis Minion Pool.
+        </NoEndpointsMessage>
+      </NoEndpoints>
+    )
+  }
+
+  renderProvider(providerName: ProviderTypes, providerEndpoints: Endpoint[]) {
+    return (
+      <ProviderWrapper key={providerName}>
+        <EndpointLogos
+          height={128}
+          endpoint={providerName}
+          style={{ marginBottom: '16px' }}
+        />
+        <Dropdown
+          items={providerEndpoints}
+          valueField="id"
+          labelField="name"
+          noSelectionMessage="Choose an endpoint"
+          centered
+          selectedItem={this.state.selectedEndpoint}
+          onChange={endpoint => { this.setState({ selectedEndpoint: endpoint }) }}
+        />
+      </ProviderWrapper>
+    )
+  }
+
+  renderPoolPlatform() {
+    return (
+      <PoolPlatformWrapper>
+        <PoolPlatformOptions>
+          <PoolPlatformOption>Source Platform</PoolPlatformOption>
+          <SwitchWrapper>
+            <Switch
+              big
+              checked={this.state.platform === 'destination'}
+              onChange={value => {
+                this.setState({
+                  platform: value ? 'destination' : 'source',
+                })
+              }}
+            />
+          </SwitchWrapper>
+          <PoolPlatformOption>Destination Platform</PoolPlatformOption>
+        </PoolPlatformOptions>
+      </PoolPlatformWrapper>
+    )
+  }
+
+  renderContent() {
+    if (!this.props.providers) {
+      return this.renderNoEndpoints()
+    }
+
+    const availableProviders = Object.keys(this.props.providers).filter((name: any) => {
+      const providerName = name as ProviderTypes
+      const providerType = this.state.platform === 'source' ? providerTypes.SOURCE_MINION_POOL : providerTypes.DESTINATION_MINION_POOL
+      const types = this.props.providers?.[providerName].types.indexOf(providerType)
+      return types && types > -1
+    })
+
+    const availableEndpoints = this.props.endpoints
+      .filter(e => availableProviders.indexOf(e.type) > -1)
+
+    if (availableProviders.length === 0 || availableEndpoints.length === 0) {
+      return this.renderNoEndpoints()
+    }
+
+    return (
+      <ContentWrapper>
+        {availableProviders.map(providerName => this.renderProvider(
+          providerName as any,
+          this.props.endpoints.filter(e => e.type === providerName),
+        ))}
+      </ContentWrapper>
+    )
+  }
+
+  renderLoading() {
+    return (
+      <LoadingWrapper>
+        <StatusImage loading />
+      </LoadingWrapper>
+    )
+  }
+
+  render() {
+    return (
+      <Modal
+        isOpen
+        title="Choose Minion Pool Endpoint"
+        onRequestClose={this.props.onRequestClose}
+      >
+        <Wrapper>
+          {!this.props.loading ? this.renderContent() : null}
+          {this.props.loading ? this.renderLoading() : null}
+          {!this.props.loading ? this.renderPoolPlatform() : null}
+          <ButtonWrapper>
+            <Button secondary onClick={this.props.onRequestClose}>Cancel</Button>
+            <Button
+              primary
+              disabled={!this.state.selectedEndpoint}
+              onClick={() => { this.handleNextClick() }}
+            >
+              Next
+            </Button>
+          </ButtonWrapper>
+        </Wrapper>
+      </Modal>
+    )
+  }
+}
+
+export default MinionEndpointModal

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

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

+ 187 - 0
src/components/organisms/MinionPoolDetailsContent/MinionPoolDetailsContent.tsx

@@ -0,0 +1,187 @@
+/*
+Copyright (C) 2020  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/>.
+*/
+
+import React from 'react'
+import styled from 'styled-components'
+import { observer } from 'mobx-react'
+
+import Button from '../../atoms/Button/Button'
+import DetailsNavigation from '../../molecules/DetailsNavigation/DetailsNavigation'
+import Executions from '../Executions/Executions'
+import type { Endpoint } from '../../../@types/Endpoint'
+import type { Execution, ExecutionTasks } from '../../../@types/Execution'
+import type { Field } from '../../../@types/Field'
+import StyleProps from '../../styleUtils/StyleProps'
+import { MinionPoolDetails } from '../../../@types/MinionPool'
+import { MinionPoolAction } from '../../../stores/MinionPoolStore'
+import MinionPoolMainDetails from './MinionPoolMainDetails'
+import { ReplicaItem, MigrationItem } from '../../../@types/MainItem'
+
+const Wrapper = styled.div<any>`
+  display: flex;
+  justify-content: center;
+`
+
+const Buttons = styled.div<any>`
+  display: flex;
+  justify-content: space-between;
+  margin-top: 64px;
+`
+const ButtonColumn = styled.div<any>`
+  display: flex;
+  flex-direction: column;
+  button {
+    margin-top: 16px;
+    &:first-child {
+      margin-top: 0;
+    }
+  }
+`
+const DetailsBody = styled.div<any>`
+  ${StyleProps.exactWidth(StyleProps.contentWidth)}
+`
+
+const NavigationItems = [
+  {
+    label: 'Minion Pool',
+    value: '',
+  },
+  {
+    label: 'Executions',
+    value: 'executions',
+  },
+]
+
+type Props = {
+  item?: MinionPoolDetails | null,
+  replicas: ReplicaItem[],
+  migrations: MigrationItem[]
+  endpoints: Endpoint[],
+  schema: Field[],
+  schemaLoading: boolean,
+  page: string,
+  detailsLoading: boolean,
+  executions: Execution[],
+  executionsLoading: boolean,
+  executionsTasksLoading: boolean,
+  executionsTasks: ExecutionTasks[],
+  onRunAction: (action: MinionPoolAction) => void,
+  onExecutionChange: (executionId: string) => void,
+  onCancelExecutionClick: (execution: Execution | null, force?: boolean) => void,
+  onDeleteMinionPoolClick: () => void,
+}
+@observer
+class MinionPoolDetailsContent extends React.Component<Props> {
+  getStatus() {
+    return this.props.item?.pool_status
+  }
+
+  isEndpointMissing() {
+    const endpoint = this.props.endpoints
+      .find(e => e.id === this.props.item?.endpoint_id)
+
+    return Boolean(!endpoint)
+  }
+
+  renderBottomControls() {
+    const uninitialized = this.props.item?.pool_status === 'UNINITIALIZED'
+    const deallocated = this.props.item?.pool_status === 'DEALLOCATED'
+
+    return (
+      <Buttons>
+        <ButtonColumn>
+          <Button
+            primary
+            hollow
+            disabled={this.isEndpointMissing() || !uninitialized}
+            onClick={() => { this.props.onRunAction('set-up-shared-resources') }}
+          >Setup Shared Resources
+          </Button>
+          <Button
+            primary
+            hollow
+            disabled={this.isEndpointMissing() || !deallocated}
+            onClick={() => { this.props.onRunAction('allocate-machines') }}
+          >Allocate Machines
+          </Button>
+        </ButtonColumn>
+        <ButtonColumn>
+          <Button
+            alert
+            hollow
+            disabled={!uninitialized}
+            onClick={this.props.onDeleteMinionPoolClick}
+            data-test-id="rdContent-deleteButton"
+          >Delete Minion Pool
+          </Button>
+        </ButtonColumn>
+      </Buttons>
+    )
+  }
+
+  renderMainDetails() {
+    if (this.props.page !== '') {
+      return null
+    }
+
+    return (
+      <MinionPoolMainDetails
+        item={this.props.item}
+        replicas={this.props.replicas}
+        migrations={this.props.migrations}
+        schema={this.props.schema}
+        schemaLoading={this.props.schemaLoading}
+        loading={this.props.detailsLoading}
+        endpoints={this.props.endpoints}
+        bottomControls={this.renderBottomControls()}
+      />
+    )
+  }
+
+  renderExecutions() {
+    if (this.props.page !== 'executions') {
+      return null
+    }
+
+    return (
+      <Executions
+        executions={this.props.executions}
+        executionsTasks={this.props.executionsTasks}
+        onCancelExecutionClick={this.props.onCancelExecutionClick}
+        loading={this.props.executionsLoading}
+        onChange={this.props.onExecutionChange}
+        tasksLoading={this.props.executionsTasksLoading}
+      />
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <DetailsNavigation
+          items={NavigationItems}
+          selectedValue={this.props.page}
+          itemId={this.props.item ? this.props.item.id : ''}
+          itemType="minion-pool"
+        />
+        <DetailsBody>
+          {this.renderMainDetails()}
+          {this.renderExecutions()}
+        </DetailsBody>
+      </Wrapper>
+    )
+  }
+}
+
+export default MinionPoolDetailsContent

+ 360 - 0
src/components/organisms/MinionPoolDetailsContent/MinionPoolMainDetails.tsx

@@ -0,0 +1,360 @@
+/*
+Copyright (C) 2020  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/>.
+*/
+
+import * as React from 'react'
+import { Link } from 'react-router-dom'
+import { observer } from 'mobx-react'
+import styled, { css } from 'styled-components'
+
+import EndpointLogos from '../../atoms/EndpointLogos'
+import CopyValue from '../../atoms/CopyValue'
+import StatusIcon from '../../atoms/StatusIcon'
+import StatusImage from '../../atoms/StatusImage'
+import CopyMultilineValue from '../../atoms/CopyMultilineValue'
+
+import type { Endpoint } from '../../../@types/Endpoint'
+import type { Field as FieldType } from '../../../@types/Field'
+import fieldHelper from '../../../@types/Field'
+
+import StyleProps from '../../styleUtils/StyleProps'
+import Palette from '../../styleUtils/Palette'
+import DateUtils from '../../../utils/DateUtils'
+import LabelDictionary from '../../../utils/LabelDictionary'
+import { OptionsSchemaPlugin } from '../../../plugins/endpoint'
+
+import { MinionPoolDetails } from '../../../@types/MinionPool'
+import StatusPill from '../../atoms/StatusPill/StatusPill'
+import { TransferItem, ReplicaItem, MigrationItem } from '../../../@types/MainItem'
+
+const Wrapper = styled.div<any>`
+  display: flex;
+  flex-direction: column;
+  padding-bottom: 48px;
+`
+const ColumnsLayout = styled.div<any>`
+  display: flex;
+`
+const Column = styled.div<any>`
+  ${props => StyleProps.exactWidth(props.width)}
+`
+const Row = styled.div<any>`
+  margin-bottom: 32px;
+  &:last-child {
+    margin-bottom: 16px;
+  }
+`
+const Field = styled.div<any>`
+  display: flex;
+  flex-direction: column;
+`
+const Label = styled.div<any>`
+  font-size: 10px;
+  color: ${Palette.grayscale[3]};
+  font-weight: ${StyleProps.fontWeights.medium};
+  text-transform: uppercase;
+  display: flex;
+  align-items: center;
+`
+const StatusIconStub = styled.div<any>`
+  ${StyleProps.exactSize('16px')}
+`
+const Value = styled.div<any>`
+  display: ${props => (props.flex ? 'flex' : props.block ? 'block' : 'inline-table')};
+  margin-top: 3px;
+  ${props => (props.capitalize ? 'text-transform: capitalize;' : '')}
+`
+const ValueLink = styled(Link)`
+  display: flex;
+  margin-top: 3px;
+  color: ${Palette.primary};
+  text-decoration: none;
+  cursor: pointer;
+`
+const Loading = styled.div<any>`
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 200px;
+`
+const PropertiesTable = styled.div<any>``
+const PropertyRow = styled.div<any>`
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 4px;
+`
+const PropertyText = css``
+const PropertyName = styled.div<any>`
+  ${PropertyText}
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 50%;
+`
+const PropertyValue = styled.div<any>`
+  ${PropertyText}
+  color: ${Palette.grayscale[4]};
+  text-align: right;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: calc(50% + 16px);
+  margin-right: -16px;
+`
+
+type Props = {
+  item?: MinionPoolDetails | null,
+  replicas: ReplicaItem[]
+  migrations: MigrationItem[]
+  schema: FieldType[],
+  schemaLoading: boolean,
+  endpoints: Endpoint[],
+  bottomControls: React.ReactNode,
+  loading: boolean,
+}
+@observer
+class MinionPoolMainDetails extends React.Component<Props> {
+  getEndpoint(): Endpoint | undefined {
+    const endpoint = this.props.endpoints
+      .find(e => e.id === this.props.item?.endpoint_id)
+    return endpoint
+  }
+
+  renderLastExecutionTime() {
+    return this.props.item?.updated_at ? this.renderValue(DateUtils.getLocalTime(this.props.item.updated_at).format('YYYY-MM-DD HH:mm:ss')) : '-'
+  }
+
+  renderValue(value: string, capitalize?: boolean) {
+    return <CopyValue value={value} maxWidth="90%" capitalize={capitalize} />
+  }
+
+  renderEndpointLink(): React.ReactNode {
+    const endpointIsMissing = (
+      <Value flex>
+        <StatusIcon style={{ marginRight: '8px' }} status="ERROR" />Endpoint is missing
+      </Value>
+    )
+
+    const endpoint = this.getEndpoint()
+
+    if (endpoint) {
+      return <ValueLink to={`/endpoints/${endpoint.id}`}>{endpoint.name}</ValueLink>
+    }
+
+    return endpointIsMissing
+  }
+
+  renderPropertiesTable(propertyNames: string[]) {
+    const endpoint = this.getEndpoint()
+
+    const getValue = (name: string, value: any) => {
+      if (value.join && value.length && value[0].destination && value[0].source) {
+        return value.map((v: { source: any; destination: any }) => `${v.source}=${v.destination}`).join(', ')
+      }
+      const schema = this.props.schema
+      return fieldHelper.getValueAlias(name, value, schema, endpoint && endpoint.type)
+    }
+
+    let properties: any[] = []
+    const plugin = endpoint && OptionsSchemaPlugin.for(endpoint.type)
+    const migrationImageMapFieldName = plugin && plugin.migrationImageMapFieldName
+    let dictionaryKey = ''
+    if (endpoint) {
+      dictionaryKey = `${endpoint.type}-minion-pool`
+    }
+    const environment = this.props.item?.environment_options
+    propertyNames.forEach(pn => {
+      const value = environment ? environment[pn] : ''
+      const label = LabelDictionary.get(pn, dictionaryKey)
+
+      if (value && value.join) {
+        value.forEach((v: any, i: number) => {
+          const useLabel = i === 0 ? label : ''
+          properties.push({ label: useLabel, value: v })
+        })
+      } else if (value && typeof value === 'object') {
+        properties = properties.concat(Object.keys(value).map(p => {
+          if (p === 'disk_mappings') {
+            return null
+          }
+          let fieldName = pn
+          if (migrationImageMapFieldName && fieldName === migrationImageMapFieldName) {
+            fieldName = `${p}${plugin?.imageSuffix}`
+          }
+          return {
+            label: `${label} - ${LabelDictionary.get(p)}`,
+            value: getValue(fieldName, value[p]),
+          }
+        }))
+      } else {
+        properties.push({ label, value: getValue(pn, value) })
+      }
+    })
+
+    return (
+      <PropertiesTable>
+        {properties.filter(Boolean).filter(p => p.value != null && p.value !== '').map(prop => (
+          <PropertyRow key={prop.label}>
+            <PropertyName>{prop.label}</PropertyName>
+            <PropertyValue>
+              <CopyValue value={prop.value} />
+            </PropertyValue>
+          </PropertyRow>
+        ))}
+      </PropertiesTable>
+    )
+  }
+
+  renderUsage(items: TransferItem[]) {
+    return items.map(item => (
+      <span>
+        <ValueLink
+          key={item.id}
+          to={`/${item.type}s/${item.id}`}
+        >
+          {item.instances[0]}
+        </ValueLink>
+        <br />
+      </span>
+    ))
+  }
+
+  renderTable() {
+    if (this.props.loading) {
+      return null
+    }
+    const endpoint = this.getEndpoint()
+    const lastUpdated = this.renderLastExecutionTime()
+
+    const getPropertyNames = () => {
+      const env = this.props.item?.environment_options
+      return env ? Object.keys(env).filter(k => k !== 'network_map' && (
+        k !== 'storage_mappings'
+        || (env[k] != null && typeof env[k] === 'object' && Object.keys(env[k]).length > 0)
+      )) : []
+    }
+
+    const usage: TransferItem[] = this.props.replicas
+      .concat(this.props.migrations as any[])
+
+    return (
+      <ColumnsLayout>
+        <Column width="42.5%">
+          <Row>
+            <Field>
+              <Label>Endpoint</Label>
+              {this.renderEndpointLink()}
+            </Field>
+          </Row>
+          <Row>
+            <EndpointLogos
+              endpoint={(endpoint ? endpoint.type : '') as any}
+            />
+          </Row>
+          <Row>
+            <Field>
+              <Label>Id</Label>
+              {this.renderValue(this.props.item?.id || '-')}
+            </Field>
+          </Row>
+          <Row>
+            <Field>
+              <Label>Pool Platform</Label>
+              {this.renderValue(this.props.item?.pool_platform || '-', true)}
+            </Field>
+          </Row>
+          <Row>
+            <Field>
+              <Label>Created</Label>
+              {this.props.item?.created_at ? this.renderValue(DateUtils.getLocalTime(this.props.item.created_at).format('YYYY-MM-DD HH:mm:ss')) : <Value>-</Value>}
+            </Field>
+          </Row>
+          {this.props.item?.notes
+            ? (
+              <Row>
+                <Field>
+                  <Label>Notes</Label>
+                  <CopyMultilineValue value={this.props.item.notes} />
+                </Field>
+              </Row>
+            )
+            : null}
+          {lastUpdated ? (
+            <Row>
+              <Field>
+                <Label>Last Updated</Label>
+                <Value>{lastUpdated}</Value>
+              </Field>
+            </Row>
+          ) : null}
+          <Row>
+            <Field>
+              <Label>Used in Replicas/Migrations ({usage.length})</Label>
+              {usage.length > 0 ? this.renderUsage(usage) : <Value>-</Value>}
+            </Field>
+          </Row>
+        </Column>
+        <Column width="9.5%" />
+        <Column width="48%" style={{ flexGrow: 1 }}>
+          <Row>
+            <Field>
+              <Label>Last Execution Status</Label>
+              <Value>{this.props.item?.last_execution_status ? <StatusPill status={this.props.item.last_execution_status} /> : '-'}</Value>
+            </Field>
+          </Row>
+          {getPropertyNames().length > 0 ? (
+            <Row>
+              <Field>
+                <Label>Environment options {this.props.schemaLoading ? (
+                  <StatusIcon status="RUNNING" style={{ marginLeft: '8px' }} />
+                ) : <StatusIconStub />}
+                </Label>
+                <Value block>{this.renderPropertiesTable(getPropertyNames())}</Value>
+              </Field>
+            </Row>
+          ) : null}
+        </Column>
+      </ColumnsLayout>
+    )
+  }
+
+  renderBottomControls() {
+    if (this.props.loading) {
+      return null
+    }
+
+    return this.props.bottomControls
+  }
+
+  renderLoading() {
+    if (!this.props.loading) {
+      return null
+    }
+
+    return (
+      <Loading>
+        <StatusImage loading />
+      </Loading>
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        {this.renderTable()}
+        {this.renderLoading()}
+        {this.renderBottomControls()}
+      </Wrapper>
+    )
+  }
+}
+
+export default MinionPoolMainDetails

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

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

+ 357 - 0
src/components/organisms/MinionPoolModal/MinionPoolModal.tsx

@@ -0,0 +1,357 @@
+/*
+Copyright (C) 2020  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/>.
+*/
+
+import React from 'react'
+import styled from 'styled-components'
+import { observer } from 'mobx-react'
+import { observe } from 'mobx'
+
+import StatusImage from '../../atoms/StatusImage'
+import Button from '../../atoms/Button'
+import LoadingButton from '../../molecules/LoadingButton'
+
+import type { Endpoint as EndpointType } from '../../../@types/Endpoint'
+import type { Field } from '../../../@types/Field'
+import ObjectUtils from '../../../utils/ObjectUtils'
+import KeyboardManager from '../../../utils/KeyboardManager'
+import { MinionPool } from '../../../@types/MinionPool'
+import MinionPoolModalContent from './MinionPoolModalContent'
+import minionPoolStore from '../../../stores/MinionPoolStore'
+
+import minionPoolImage from './images/minion-pool.svg'
+import StyleProps from '../../styleUtils/StyleProps'
+import notificationStore from '../../../stores/NotificationStore'
+
+const Wrapper = styled.div<any>`
+  padding: 24px 0 32px 0;
+  display: flex;
+  align-items: center;
+  flex-direction: column;
+  min-height: 0;
+`
+const MinionPoolImageWrapper = styled.div`
+  ${StyleProps.exactSize('128px')}
+  background: url('${minionPoolImage}') center no-repeat;
+`
+const Content = styled.div<any>`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+`
+const LoadingWrapper = styled.div<any>`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin: 32px 0;
+`
+const LoadingText = styled.div<any>`
+  font-size: 18px;
+  margin-top: 32px;
+`
+const Buttons = styled.div<any>`
+  display: flex;
+  justify-content: space-between;
+  margin-top: 32px;
+  flex-shrink: 0;
+  padding: 0 32px;
+`
+
+type Props = {
+  cancelButtonText: string,
+  endpoint: EndpointType,
+  minionPool?: MinionPool | null
+  platform: 'source' | 'destination',
+  onCancelClick: () => void,
+  onResizeUpdate?: (scrollableRef: HTMLElement, scrollOffset?: number) => void,
+  onRequestClose: () => void,
+  onUpdateComplete?: (redirectoTo: string) => void,
+}
+type State = {
+  invalidFields: any[],
+  minionPool: any | null
+  saving: boolean
+}
+@observer
+class MinionPoolModal extends React.Component<Props, State> {
+  static defaultProps = {
+    cancelButtonText: 'Cancel',
+  }
+
+  state: State = {
+    invalidFields: [],
+    minionPool: null,
+    saving: false,
+  }
+
+  scrollableRef!: HTMLElement
+
+  minionPoolStoreObserver!: () => void
+
+  UNSAFE_componentWillMount() {
+    this.UNSAFE_componentWillReceiveProps(this.props)
+    this.minionPoolStoreObserver = observe(minionPoolStore, () => {
+      if (this.props.onResizeUpdate) this.props.onResizeUpdate(this.scrollableRef)
+    })
+  }
+
+  componentDidMount() {
+    const loadSchema = async () => {
+      if (!this.props.endpoint) {
+        return
+      }
+      await minionPoolStore.loadMinionPoolSchema(this.props.endpoint.type, this.props.platform)
+      await minionPoolStore.loadEnvOptions(
+        this.props.endpoint.id,
+        this.props.endpoint.type,
+        this.props.platform,
+      )
+
+      this.fillRequiredDefaults()
+    }
+    loadSchema()
+    KeyboardManager.onEnter('minion-pool', () => {
+      this.create()
+    }, 2)
+  }
+
+  UNSAFE_componentWillReceiveProps(props: Props) {
+    if (props.minionPool) {
+      this.setState(prevState => ({
+        minionPool: {
+          ...prevState.minionPool,
+          ...ObjectUtils.flatten(props.minionPool || {}),
+        },
+      }))
+    }
+
+    if (props.platform) {
+      this.setState(prevState => ({
+        minionPool: {
+          ...prevState.minionPool,
+          pool_platform: props.platform,
+        },
+      }))
+    }
+
+    if (props.onResizeUpdate) props.onResizeUpdate(this.scrollableRef)
+  }
+
+  componentWillUnmount() {
+    KeyboardManager.removeKeyDown('minion-pool')
+    this.minionPoolStoreObserver()
+  }
+
+  get isLoading() {
+    return minionPoolStore.loadingMinionPoolSchema || minionPoolStore.loadingMinionPools
+      || minionPoolStore.loadingEnvOptions
+  }
+
+  getFieldValue(field?: Field | null) {
+    if (!field || !this.state.minionPool) {
+      return ''
+    }
+    if (this.state.minionPool[field.name] != null) {
+      return this.state.minionPool[field.name]
+    }
+
+    if (Object.keys(field).find(k => k === 'default')) {
+      return field.default
+    }
+
+    if (field.type === 'integer' || field.type === 'boolean') {
+      return null
+    }
+    return ''
+  }
+
+  findInvalidFields = () => {
+    const invalidFields = minionPoolStore.minionPoolCombinedSchema.filter(field => {
+      if (field.required) {
+        const value = this.getFieldValue(field)
+        return value === null || value === '' || value.length === 0
+      }
+      return false
+    }).map(f => f.name)
+
+    return invalidFields
+  }
+
+  highlightRequired() {
+    const invalidFields = this.findInvalidFields()
+    this.setState({ invalidFields })
+    if (invalidFields.length > 0) {
+      notificationStore.alert('Please fill the required fields', 'error')
+      return true
+    }
+    return false
+  }
+
+  async create() {
+    if (this.highlightRequired()) {
+      return
+    }
+    this.setState({ saving: true })
+    if (this.state.minionPool?.id) {
+      await this.update()
+    } else {
+      await this.add()
+    }
+  }
+
+  async update() {
+    const stateMinionPool = { ...this.state.minionPool }
+    await minionPoolStore.loadMinionPools()
+    const minionPool = minionPoolStore.minionPools.find(e => e.id === stateMinionPool.id)
+    if (!minionPool) {
+      throw new Error('Minion pool not found!')
+    }
+    try {
+      delete stateMinionPool.pool_platform
+      delete stateMinionPool.endpoint_id
+      await minionPoolStore.update(stateMinionPool)
+      if (this.props.onUpdateComplete) {
+        this.props.onUpdateComplete(`/minion-pools/${stateMinionPool.id}`)
+      }
+    } catch (err) {
+      this.props.onRequestClose()
+    }
+  }
+
+  async add() {
+    try {
+      await minionPoolStore.add(this.props.endpoint.id, this.state.minionPool)
+      notificationStore.alert('Minion Pool created', 'success')
+      this.props.onRequestClose()
+    } catch (err) {
+      this.props.onRequestClose()
+    }
+  }
+
+  fillRequiredDefaults() {
+    this.setState(prevState => {
+      const minionPool: any = { ...prevState.minionPool }
+      const requiredFieldsDefaults = minionPoolStore.minionPoolCombinedSchema
+        .filter(f => f.required && f.default != null)
+      requiredFieldsDefaults.forEach(f => {
+        if (minionPool[f.name] == null) {
+          minionPool[f.name] = f.default
+        }
+      })
+      return { minionPool }
+    })
+  }
+
+  handleFieldsChange(items: { field: Field, value: any }[]) {
+    this.setState(prevState => {
+      const minionPool: any = { ...prevState.minionPool }
+
+      items.forEach(item => {
+        let value = item.value
+        if (item.field.type === 'array') {
+          const arrayItems = minionPool[item.field.name] || []
+          value = arrayItems.find((v: any) => v === item.value)
+            ? arrayItems.filter((v: any) => v !== item.value) : [...arrayItems, item.value]
+        }
+
+        minionPool[item.field.name] = value
+      })
+
+      return { minionPool }
+    })
+  }
+
+  handleCancelClick() {
+    this.props.onCancelClick()
+  }
+
+  renderButtons() {
+    let actionButton = (
+      <Button
+        large
+        onClick={() => this.create()}
+      >Save
+      </Button>
+    )
+
+    if (this.state.saving) {
+      actionButton = <LoadingButton large>Saving ...</LoadingButton>
+    }
+
+    return (
+      <Buttons>
+        <Button
+          large
+          secondary
+          onClick={() => {
+            this.handleCancelClick()
+          }}
+        >{this.props.cancelButtonText}
+        </Button>
+        {actionButton}
+      </Buttons>
+    )
+  }
+
+  renderContent() {
+    return (
+      <Content>
+        <MinionPoolModalContent
+          endpoint={this.props.endpoint}
+          defaultSchema={minionPoolStore.minionPoolDefaultSchema}
+          envSchema={minionPoolStore.minionPoolEnvSchema}
+          invalidFields={this.state.invalidFields}
+          disabled={this.state.saving}
+          cancelButtonText={this.props.cancelButtonText}
+          getFieldValue={field => this.getFieldValue(field)}
+          onFieldChange={(field, value) => {
+            if (field) {
+              this.handleFieldsChange([{ field, value }])
+            }
+          }}
+          onCreateClick={() => { this.create() }}
+          onCancelClick={() => { this.handleCancelClick() }}
+          scrollableRef={ref => { this.scrollableRef = ref }}
+          onResizeUpdate={() => {
+            if (this.props.onResizeUpdate) {
+              this.props.onResizeUpdate(this.scrollableRef)
+            }
+          }}
+        />
+        {this.renderButtons()}
+      </Content>
+    )
+  }
+
+  renderLoading() {
+    return (
+      <LoadingWrapper>
+        <StatusImage loading />
+        <LoadingText>Loading schema ...</LoadingText>
+      </LoadingWrapper>
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <MinionPoolImageWrapper />
+        {!this.isLoading ? this.renderContent() : null}
+        {this.isLoading ? this.renderLoading() : null}
+      </Wrapper>
+    )
+  }
+}
+
+export default MinionPoolModal

+ 245 - 0
src/components/organisms/MinionPoolModal/MinionPoolModalContent.tsx

@@ -0,0 +1,245 @@
+/*
+Copyright (C) 2020  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/>.
+*/
+
+import * as React from 'react'
+import styled from 'styled-components'
+
+import LabelDictionary from '../../../utils/LabelDictionary'
+
+import FieldInput from '../../molecules/FieldInput'
+import type { Field } from '../../../@types/Field'
+
+import StyleProps from '../../styleUtils/StyleProps'
+import Palette from '../../styleUtils/Palette'
+import EndpointLogos from '../../atoms/EndpointLogos/EndpointLogos'
+import { Endpoint } from '../../../@types/Endpoint'
+
+const Wrapper = styled.div<any>`
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+`
+const Fields = styled.div<any>`
+  display: flex;
+  margin-top: 32px;
+  padding: 0 32px;
+  flex-direction: column;
+  overflow: auto;
+`
+const FieldStyled = styled(FieldInput)`
+  min-width: ${props => (props.useTextArea ? '100%' : '224px')};
+  max-width: ${StyleProps.inputSizes.large.width}px;
+  margin-bottom: 16px;
+`
+const Row = styled.div<any>`
+  display: flex;
+  flex-shrink: 0;
+  justify-content: space-between;
+`
+const EndpointField = styled.div`
+  min-width: ${StyleProps.inputSizes.large.width}px;
+  max-width: ${StyleProps.inputSizes.large.width}px;
+  margin-bottom: 16px;
+`
+const EndpointFieldLabel = styled.div<any>`
+  font-weight: ${StyleProps.fontWeights.medium};
+  flex-grow: 1;
+  margin-bottom: 2px;
+  font-size: 10px;
+  color: ${Palette.grayscale[3]};
+  text-transform: uppercase;
+  display: flex;
+  align-items: center;
+`
+const EndpointFieldLabelText = styled.span`
+  margin-right: 24px;
+`
+const EndpointFieldValue = styled.div`
+  display: flex;
+  align-items: center;
+`
+const EndpointFieldValueText = styled.div`
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-left: 8px;
+  font-size: 12px;
+  color: ${Palette.grayscale[4]};
+`
+const PoolPlatformFieldText = styled.div`
+  text-transform: capitalize;
+`
+const EndpointFieldValueLogo = styled.div``
+const Group = styled.div<any>`
+  display: flex;
+  flex-direction: column;
+  flex-shrink: 0;
+`
+const GroupName = styled.div<any>`
+  display: flex;
+  align-items: center;
+  margin: 32px 0 24px 0;
+`
+const GroupNameText = styled.div<any>`
+  margin: 0 32px;
+  font-size: 16px;
+`
+const GroupNameBar = styled.div<any>`
+  flex-grow: 1;
+  background: ${Palette.grayscale[3]};
+  height: 1px;
+`
+const GroupFields = styled.div<any>`
+  display: flex;
+  justify-content: space-between;
+  flex-direction: column;
+`
+type Props = {
+  defaultSchema: Field[],
+  envSchema: Field[],
+  invalidFields: string[],
+  endpoint: Endpoint
+  getFieldValue: (field: Field | null | undefined) => any,
+  onFieldChange: (field: Field | null, value: any) => void,
+  disabled: boolean,
+  cancelButtonText: string,
+  onResizeUpdate: (scrollOffset: number) => void,
+  scrollableRef: (ref: HTMLElement) => void,
+  onCreateClick: () => void
+  onCancelClick: () => void
+}
+class MinionPoolModalContent extends React.Component<Props> {
+  renderEndpoint() {
+    return (
+      <EndpointField>
+        <EndpointFieldLabel>
+          <EndpointFieldLabelText>
+            Endpoint
+          </EndpointFieldLabelText>
+        </EndpointFieldLabel>
+        <EndpointFieldValue>
+          <EndpointFieldValueLogo>
+            <EndpointLogos
+              endpoint={this.props.endpoint.type}
+              height={32}
+            />
+          </EndpointFieldValueLogo>
+          <EndpointFieldValueText title={this.props.endpoint.name}>
+            ({this.props.endpoint.name})
+          </EndpointFieldValueText>
+        </EndpointFieldValue>
+      </EndpointField>
+    )
+  }
+
+  renderPoolPlatform() {
+    return (
+      <EndpointField>
+        <EndpointFieldLabel>
+          <EndpointFieldLabelText>
+            Pool Platform
+          </EndpointFieldLabelText>
+        </EndpointFieldLabel>
+        <EndpointFieldValue>
+          <PoolPlatformFieldText>
+            {this.props.getFieldValue(this.props.defaultSchema.find(f => f.name === 'pool_platform'))}
+          </PoolPlatformFieldText>
+        </EndpointFieldValue>
+      </EndpointField>
+    )
+  }
+
+  renderFieldSet(customFields: Field[]) {
+    const rows: JSX.Element[] = []
+    let lastField: JSX.Element
+    let i = 0
+    customFields.forEach((field, schemaIndex) => {
+      let currentField
+      if (field.name === 'endpoint_id') {
+        currentField = this.renderEndpoint()
+      } else if (field.name === 'pool_platform') {
+        currentField = this.renderPoolPlatform()
+      } else {
+        currentField = (
+          <FieldStyled
+            {...field}
+            label={field.title || LabelDictionary.get(field.name)}
+            width={StyleProps.inputSizes.large.width}
+            disabled={this.props.disabled}
+            highlight={this.props.invalidFields.findIndex(fn => fn === field.name) > -1}
+            value={this.props.getFieldValue(field)}
+            onChange={value => { this.props.onFieldChange(field, value) }}
+            nullableBoolean
+          />
+        )
+      }
+
+      const pushRow = (field1: React.ReactNode, field2?: React.ReactNode) => {
+        rows.push((
+          <Row key={field.name}>
+            {field1}
+            {field2}
+          </Row>
+        ))
+      }
+      if (field.useTextArea) {
+        pushRow(currentField)
+        i -= 1
+      } else if (i % 2 !== 0) {
+        pushRow(lastField, currentField)
+      } else if (schemaIndex === customFields.length - 1) {
+        pushRow(currentField)
+        if (field.useTextArea) {
+          i -= 1
+        }
+      } else {
+        lastField = currentField
+      }
+      i += 1
+    })
+
+    return rows
+  }
+
+  renderFields() {
+    return (
+      <Fields ref={(ref: HTMLElement) => { this.props.scrollableRef(ref) }}>
+        <Group>
+          <GroupFields>
+            {this.renderFieldSet(this.props.defaultSchema)}
+          </GroupFields>
+        </Group>
+        <Group>
+          <GroupName>
+            <GroupNameBar />
+            <GroupNameText>Environment Options</GroupNameText>
+            <GroupNameBar />
+          </GroupName>
+          <GroupFields>
+            {this.renderFieldSet(this.props.envSchema)}
+          </GroupFields>
+        </Group>
+      </Fields>
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        {this.renderFields()}
+      </Wrapper>
+    )
+  }
+}
+
+export default MinionPoolModalContent

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 87 - 0
src/components/organisms/MinionPoolModal/images/minion-pool.svg


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

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

+ 6 - 2
src/components/organisms/Navigation/Navigation.tsx

@@ -12,7 +12,7 @@ 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/>.
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 */
 
 
-import React from 'react'
+import React, { CSSProperties } from 'react'
 import { Link } from 'react-router-dom'
 import { Link } from 'react-router-dom'
 import { observer } from 'mobx-react'
 import { observer } from 'mobx-react'
 import styled from 'styled-components'
 import styled from 'styled-components'
@@ -37,6 +37,7 @@ import projectImage from './images/project-menu.svg'
 import userImage from './images/user-menu.svg'
 import userImage from './images/user-menu.svg'
 import logsImage from './images/logs-menu.svg'
 import logsImage from './images/logs-menu.svg'
 import dashboardImage from './images/dashboard-menu.svg'
 import dashboardImage from './images/dashboard-menu.svg'
+import minionPoolsImage from './images/minion-pools-menu.svg'
 
 
 const isCollapsed = (props: any) => props.collapsed
 const isCollapsed = (props: any) => props.collapsed
   || (window.outerWidth <= StyleProps.mobileMaxWidth)
   || (window.outerWidth <= StyleProps.mobileMaxWidth)
@@ -375,7 +376,7 @@ class Navigation extends React.Component<Props> {
           this.filteredMenu.map(item => {
           this.filteredMenu.map(item => {
             let menuImage
             let menuImage
             let bullet
             let bullet
-            let style = null
+            let style: CSSProperties | null = null
             switch (item.value) {
             switch (item.value) {
               case 'dashboard':
               case 'dashboard':
                 menuImage = dashboardImage
                 menuImage = dashboardImage
@@ -392,6 +393,9 @@ class Navigation extends React.Component<Props> {
               case 'endpoints':
               case 'endpoints':
                 menuImage = endpointImage
                 menuImage = endpointImage
                 break
                 break
+              case 'minion-pools':
+                menuImage = minionPoolsImage
+                break
               case 'planning':
               case 'planning':
                 menuImage = planningImage
                 menuImage = planningImage
                 break
                 break

+ 15 - 0
src/components/organisms/Navigation/images/minion-pools-menu.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns="http://www.w3.org/2000/svg"
+   viewBox="0 0 6.3499999 6.3500002"
+   height="24"
+   width="24">
+  <g
+     transform="scale(0.89) translate(0.5 0.2)"
+     >
+    <path
+       id="path833"
+       style="fill:#feffff;fill-opacity:1;stroke-width:0.3175"
+       d="M 3.9567384,1.4287455 V 0.64703203 H 2.3932744 V 1.42875 Z M 0.48827166,2.234522 v 3.1509535 c 0,0.1746256 0.1428877,0.3175127 0.31751267,0.3175127 H 5.5442279 c 0.174625,0 0.3175127,-0.1428871 0.3175127,-0.3175127 V 2.234522 c 0,-0.1746251 -0.1428877,-0.3175128 -0.3175127,-0.3175128 H 0.80578433 c -0.17462497,0 -0.31751267,0.1428877 -0.31751267,0.3175128 z M 5.715,1.42875 c 0.352425,0 0.635,0.282575 0.635,0.635 v 3.4925001 c 0,0.3524247 -0.282575,0.635 -0.635,0.635 H 0.63500003 C 0.28257503,6.1912501 0,5.9086748 0,5.5562501 L 0.00317498,2.06375 c 0,-0.352425 0.27940005,-0.635 0.63182505,-0.635 H 1.9050001 V 0.79375013 C 1.9050001,0.44132511 2.1875751,0.15875 2.5400002,0.15875 H 3.8100003 C 4.162425,0.15875 4.445,0.44132511 4.445,0.79375013 V 1.42875 Z" />
+  </g>
+</svg>

+ 73 - 0
src/components/organisms/PageHeader/PageHeader.tsx

@@ -39,6 +39,8 @@ import providerStore from '../../../stores/ProviderStore'
 import Palette from '../../styleUtils/Palette'
 import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
 import StyleProps from '../../styleUtils/StyleProps'
 import { ProviderTypes } from '../../../@types/Providers'
 import { ProviderTypes } from '../../../@types/Providers'
+import MinionEndpointModal from '../MinionEndpointModal/MinionEndpointModal'
+import MinionPoolModal from '../MinionPoolModal'
 
 
 const Wrapper = styled.div<any>`
 const Wrapper = styled.div<any>`
   display: flex;
   display: flex;
@@ -77,24 +79,32 @@ type Props = {
 type State = {
 type State = {
   showChooseProviderModal: boolean,
   showChooseProviderModal: boolean,
   showEndpointModal: boolean,
   showEndpointModal: boolean,
+  showChooseMinionEndpointModal: boolean,
+  showMinionPoolModal: boolean,
+  selectedMinionPoolEndpoint: EndpointType | null
   showUserModal: boolean,
   showUserModal: boolean,
   showProjectModal: boolean,
   showProjectModal: boolean,
   showAbout: boolean,
   showAbout: boolean,
   providerType: ProviderTypes | null,
   providerType: ProviderTypes | null,
   uploadedEndpoint: EndpointType | null,
   uploadedEndpoint: EndpointType | null,
   multiValidating: boolean,
   multiValidating: boolean,
+  selectedMinionPoolPlatform: 'source' | 'destination'
 }
 }
 @observer
 @observer
 class PageHeader extends React.Component<Props, State> {
 class PageHeader extends React.Component<Props, State> {
   state: State = {
   state: State = {
     showChooseProviderModal: false,
     showChooseProviderModal: false,
     showEndpointModal: false,
     showEndpointModal: false,
+    showChooseMinionEndpointModal: false,
+    selectedMinionPoolEndpoint: null,
+    showMinionPoolModal: false,
     showUserModal: false,
     showUserModal: false,
     showProjectModal: false,
     showProjectModal: false,
     providerType: null,
     providerType: null,
     uploadedEndpoint: null,
     uploadedEndpoint: null,
     showAbout: false,
     showAbout: false,
     multiValidating: false,
     multiValidating: false,
+    selectedMinionPoolPlatform: 'source',
   }
   }
 
 
   pollTimeout!: number
   pollTimeout!: number
@@ -148,6 +158,14 @@ class PageHeader extends React.Component<Props, State> {
         }
         }
         this.setState({ showChooseProviderModal: true })
         this.setState({ showChooseProviderModal: true })
         break
         break
+      case 'minionPool':
+        providerStore.loadProviders()
+        endpointStore.getEndpoints({ showLoading: true })
+        if (this.props.onModalOpen) {
+          this.props.onModalOpen()
+        }
+        this.setState({ showChooseMinionEndpointModal: true })
+        break
       case 'user':
       case 'user':
         projectStore.getProjects()
         projectStore.getProjects()
         if (this.props.onModalOpen) {
         if (this.props.onModalOpen) {
@@ -176,6 +194,33 @@ class PageHeader extends React.Component<Props, State> {
     this.setState({ showChooseProviderModal: false }, () => { this.pollData() })
     this.setState({ showChooseProviderModal: false }, () => { this.pollData() })
   }
   }
 
 
+  handleCloseChooseMinionPoolEndpointModal() {
+    if (this.props.onModalClose) {
+      this.props.onModalClose()
+    }
+    this.setState({ showChooseMinionEndpointModal: false }, () => { this.pollData() })
+  }
+
+  handleBackMinionPoolModal() {
+    this.setState({ showChooseMinionEndpointModal: true, showMinionPoolModal: false })
+  }
+
+  handleCloseMinionPoolModalRequest() {
+    if (this.props.onModalClose) {
+      this.props.onModalClose()
+    }
+    this.setState({ showMinionPoolModal: false }, () => { this.pollData() })
+  }
+
+  handleChooseMinionPoolSelectEndpoint(selectedMinionPoolEndpoint: EndpointType, platform: 'source' | 'destination') {
+    this.setState({
+      showChooseMinionEndpointModal: false,
+      showMinionPoolModal: true,
+      selectedMinionPoolEndpoint,
+      selectedMinionPoolPlatform: platform,
+    })
+  }
+
   handleProviderClick(providerType: ProviderTypes) {
   handleProviderClick(providerType: ProviderTypes) {
     this.setState({
     this.setState({
       showChooseProviderModal: false,
       showChooseProviderModal: false,
@@ -271,6 +316,8 @@ class PageHeader extends React.Component<Props, State> {
       this.stopPolling
       this.stopPolling
       || this.state.showChooseProviderModal
       || this.state.showChooseProviderModal
       || this.state.showEndpointModal
       || this.state.showEndpointModal
+      || this.state.showChooseMinionEndpointModal
+      || this.state.showMinionPoolModal
       || this.state.showProjectModal
       || this.state.showProjectModal
       || this.state.showUserModal
       || this.state.showUserModal
       || this.state.showAbout
       || this.state.showAbout
@@ -324,6 +371,32 @@ class PageHeader extends React.Component<Props, State> {
             onResetValidation={() => { this.handleResetValidation() }}
             onResetValidation={() => { this.handleResetValidation() }}
           />
           />
         </Modal>
         </Modal>
+        {this.state.showChooseMinionEndpointModal ? (
+          <MinionEndpointModal
+            providers={providerStore.providers}
+            endpoints={endpointStore.endpoints}
+            loading={providerStore.providersLoading || endpointStore.loading}
+            onRequestClose={() => { this.handleCloseChooseMinionPoolEndpointModal() }}
+            onSelectEndpoint={(endpoint, platform) => {
+              this.handleChooseMinionPoolSelectEndpoint(endpoint, platform)
+            }}
+          />
+        ) : null}
+        {this.state.showMinionPoolModal ? (
+          <Modal
+            isOpen
+            title="New Minion Pool"
+            onRequestClose={() => { this.handleCloseMinionPoolModalRequest() }}
+          >
+            <MinionPoolModal
+              cancelButtonText="Back"
+              platform={this.state.selectedMinionPoolPlatform}
+              endpoint={this.state.selectedMinionPoolEndpoint!}
+              onCancelClick={() => { this.handleBackMinionPoolModal() }}
+              onRequestClose={() => { this.handleCloseMinionPoolModalRequest() }}
+            />
+          </Modal>
+        ) : null}
         {this.state.showEndpointModal && this.state.providerType ? (
         {this.state.showEndpointModal && this.state.providerType ? (
           <Modal
           <Modal
             isOpen
             isOpen

+ 3 - 0
src/components/organisms/ReplicaDetailsContent/ReplicaDetailsContent.tsx

@@ -30,6 +30,7 @@ import type { Field } from '../../../@types/Field'
 import type { Schedule as ScheduleType } from '../../../@types/Schedule'
 import type { Schedule as ScheduleType } from '../../../@types/Schedule'
 import StyleProps from '../../styleUtils/StyleProps'
 import StyleProps from '../../styleUtils/StyleProps'
 import { ReplicaItemDetails } from '../../../@types/MainItem'
 import { ReplicaItemDetails } from '../../../@types/MainItem'
+import { MinionPool } from '../../../@types/MinionPool'
 
 
 const Wrapper = styled.div<any>`
 const Wrapper = styled.div<any>`
   display: flex;
   display: flex;
@@ -86,6 +87,7 @@ type Props = {
   executionsLoading: boolean,
   executionsLoading: boolean,
   executionsTasksLoading: boolean,
   executionsTasksLoading: boolean,
   executionsTasks: ExecutionTasks[],
   executionsTasks: ExecutionTasks[],
+  minionPools: MinionPool[]
   onExecutionChange: (executionId: string) => void,
   onExecutionChange: (executionId: string) => void,
   onCancelExecutionClick: (execution: Execution | null, force?: boolean) => void,
   onCancelExecutionClick: (execution: Execution | null, force?: boolean) => void,
   onDeleteExecutionClick: (execution: Execution | null) => void,
   onDeleteExecutionClick: (execution: Execution | null) => void,
@@ -168,6 +170,7 @@ class ReplicaDetailsContent extends React.Component<Props, State> {
     return (
     return (
       <MainDetails
       <MainDetails
         item={this.props.item}
         item={this.props.item}
+        minionPools={this.props.minionPools}
         sourceSchema={this.props.sourceSchema}
         sourceSchema={this.props.sourceSchema}
         sourceSchemaLoading={this.props.sourceSchemaLoading}
         sourceSchemaLoading={this.props.sourceSchemaLoading}
         destinationSchema={this.props.destinationSchema}
         destinationSchema={this.props.destinationSchema}

+ 84 - 20
src/components/organisms/ReplicaMigrationOptions/ReplicaMigrationOptions.tsx

@@ -30,6 +30,9 @@ import replicaMigrationFields from './replicaMigrationFields'
 
 
 import type { Field } from '../../../@types/Field'
 import type { Field } from '../../../@types/Field'
 import type { Instance, InstanceScript } from '../../../@types/Instance'
 import type { Instance, InstanceScript } from '../../../@types/Instance'
+import { TransferItemDetails } from '../../../@types/MainItem'
+import { MinionPool } from '../../../@types/MinionPool'
+import { INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS } from '../WizardOptions/WizardOptions'
 
 
 const Wrapper = styled.div<any>`
 const Wrapper = styled.div<any>`
   display: flex;
   display: flex;
@@ -61,7 +64,7 @@ const Form = styled.div<any>`
   flex-wrap: wrap;
   flex-wrap: wrap;
   margin-left: -64px;
   margin-left: -64px;
   justify-content: space-between;
   justify-content: space-between;
-  margin: 0 auto 46px auto;
+  margin: 0 auto;
 `
 `
 const Buttons = styled.div<any>`
 const Buttons = styled.div<any>`
   display: flex;
   display: flex;
@@ -76,16 +79,23 @@ const FieldInputStyled = styled(FieldInput)`
 
 
 type Props = {
 type Props = {
   instances: Instance[],
   instances: Instance[],
+  transferItem: TransferItemDetails | null,
+  minionPools: MinionPool[]
   loadingInstances: boolean,
   loadingInstances: boolean,
   defaultSkipOsMorphing?: boolean | null,
   defaultSkipOsMorphing?: boolean | null,
   onCancelClick: () => void,
   onCancelClick: () => void,
-  onMigrateClick: (fields: Field[], uploadedScripts: InstanceScript[]) => void,
+  onMigrateClick: (
+    fields: Field[],
+    uploadedScripts: InstanceScript[],
+    minionPoolMappings: { [instance: string]: string }
+  ) => void,
   onResizeUpdate?: (scrollableRef: HTMLElement, scrollOffset?: number) => void,
   onResizeUpdate?: (scrollableRef: HTMLElement, scrollOffset?: number) => void,
 }
 }
 type State = {
 type State = {
   fields: Field[],
   fields: Field[],
   selectedBarButton: string,
   selectedBarButton: string,
   uploadedScripts: InstanceScript[],
   uploadedScripts: InstanceScript[],
+  minionPoolMappings: {[instance: string]: string}
 }
 }
 
 
 @observer
 @observer
@@ -94,15 +104,19 @@ class ReplicaMigrationOptions extends React.Component<Props, State> {
     fields: [],
     fields: [],
     selectedBarButton: 'options',
     selectedBarButton: 'options',
     uploadedScripts: [],
     uploadedScripts: [],
+    minionPoolMappings: {},
   }
   }
 
 
   scrollableRef!: HTMLElement
   scrollableRef!: HTMLElement
 
 
   UNSAFE_componentWillMount() {
   UNSAFE_componentWillMount() {
+    const mappings = this.props.transferItem?.instance_osmorphing_minion_pool_mappings || {}
+
     this.setState({
     this.setState({
       fields: replicaMigrationFields.map(f => (f.name === 'skip_os_morphing' ? (
       fields: replicaMigrationFields.map(f => (f.name === 'skip_os_morphing' ? (
         { ...f, value: this.props.defaultSkipOsMorphing || null }
         { ...f, value: this.props.defaultSkipOsMorphing || null }
       ) : f)),
       ) : f)),
+      minionPoolMappings: { ...mappings },
     })
     })
   }
   }
 
 
@@ -123,7 +137,11 @@ class ReplicaMigrationOptions extends React.Component<Props, State> {
   }
   }
 
 
   migrate() {
   migrate() {
-    this.props.onMigrateClick(this.state.fields, this.state.uploadedScripts)
+    this.props.onMigrateClick(
+      this.state.fields,
+      this.state.uploadedScripts,
+      this.state.minionPoolMappings,
+    )
   }
   }
 
 
   handleValueChange(field: Field, value: boolean) {
   handleValueChange(field: Field, value: boolean) {
@@ -156,25 +174,71 @@ class ReplicaMigrationOptions extends React.Component<Props, State> {
     }))
     }))
   }
   }
 
 
+  renderField(field: Field) {
+    return (
+      <FieldInputStyled
+        width={224}
+        key={field.name}
+        name={field.name}
+        type={field.type}
+        value={field.value || field.default}
+        minimum={field.minimum}
+        maximum={field.maximum}
+        layout="page"
+        label={field.label || LabelDictionary.get(field.name)}
+        onChange={value => this.handleValueChange(field, value)}
+        description={LabelDictionary.getDescription(field.name)}
+      />
+    )
+  }
+
+  renderMinionPoolMappings() {
+    const minionPools = this.props.minionPools
+      .filter(m => m.endpoint_id === this.props.transferItem?.destination_endpoint_id)
+    if (!minionPools.length) {
+      return null
+    }
+
+    const properties: Field[] = this.props.instances.map(instance => ({
+      name: instance.instance_name || instance.name,
+      type: 'string',
+      enum: minionPools.map(minionPool => ({
+        name: minionPool.pool_name,
+        id: minionPool.id,
+      })),
+    }))
+
+    return (
+      <FieldInputStyled
+        width={500}
+        style={{ marginBottom: '64px' }}
+        name={INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS}
+        type="object"
+        valueCallback={field => this.state.minionPoolMappings
+              && this.state.minionPoolMappings[field.name]}
+        layout="page"
+        label="Instance OSMorphing Minion Pool Mappings"
+        onChange={(value, field) => this.setState(prevState => {
+          const minionPoolMappings = { ...prevState.minionPoolMappings }
+          minionPoolMappings[field!.name] = value
+          return { minionPoolMappings }
+        })}
+        properties={properties}
+        labelRenderer={(propName: string) => (
+          propName.indexOf('/') > -1 ? propName.split('/')[propName.split('/').length - 1] : propName
+        )}
+      />
+    )
+  }
+
   renderOptions() {
   renderOptions() {
     return (
     return (
-      <Form>
-        {this.state.fields.map(field => (
-          <FieldInputStyled
-            width={224}
-            key={field.name}
-            name={field.name}
-            type={field.type}
-            value={field.value || field.default}
-            minimum={field.minimum}
-            maximum={field.maximum}
-            layout="page"
-            label={LabelDictionary.get(field.name)}
-            onChange={value => this.handleValueChange(field, value)}
-            description={LabelDictionary.getDescription(field.name)}
-          />
-        ))}
-      </Form>
+      <>
+        <Form>
+          {this.state.fields.map(field => this.renderField(field))}
+        </Form>
+        {this.renderMinionPoolMappings()}
+      </>
     )
     )
   }
   }
 
 

+ 75 - 10
src/components/organisms/WizardOptions/WizardOptions.tsx

@@ -32,6 +32,9 @@ import LabelDictionary from '../../../utils/LabelDictionary'
 import Palette from '../../styleUtils/Palette'
 import Palette from '../../styleUtils/Palette'
 
 
 import endpointImage from './images/endpoint.svg'
 import endpointImage from './images/endpoint.svg'
+import { MinionPool } from '../../../@types/MinionPool'
+
+export const INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS = 'instance_osmorphing_minion_pool_mappings'
 
 
 const Wrapper = styled.div<any>`
 const Wrapper = styled.div<any>`
   display: flex;
   display: flex;
@@ -120,11 +123,17 @@ type FieldRender = {
 }
 }
 type Props = {
 type Props = {
   fields: Field[],
   fields: Field[],
+  minionPools: MinionPool[]
   isSource?: boolean,
   isSource?: boolean,
   selectedInstances?: Instance[] | null,
   selectedInstances?: Instance[] | null,
+  showSeparatePerVm?: boolean
   data?: { [prop: string]: any } | null,
   data?: { [prop: string]: any } | null,
-  getFieldValue?: (fieldName: string, defaultValue: any) => any,
-  onChange: (field: Field, value: any) => void,
+  getFieldValue?: (
+    fieldName: string,
+    defaultValue: any,
+    parentFieldName: string | undefined
+  ) => any,
+  onChange: (field: Field, value: any, parentFieldName?: string) => void,
   useAdvancedOptions?: boolean,
   useAdvancedOptions?: boolean,
   hasStorageMap: boolean,
   hasStorageMap: boolean,
   storageBackends?: StorageBackend[],
   storageBackends?: StorageBackend[],
@@ -151,9 +160,21 @@ class WizardOptions extends React.Component<Props> {
     window.removeEventListener('resize', this.handleResize, false)
     window.removeEventListener('resize', this.handleResize, false)
   }
   }
 
 
-  getFieldValue(fieldName: string, defaultValue: any) {
+  getFieldValue(fieldName: string, defaultValue: any, parentFieldName?: string) {
     if (this.props.getFieldValue) {
     if (this.props.getFieldValue) {
-      return this.props.getFieldValue(fieldName, defaultValue)
+      return this.props.getFieldValue(fieldName, defaultValue, parentFieldName)
+    }
+
+    if (!this.props.data) {
+      return defaultValue
+    }
+
+    if (parentFieldName) {
+      if (this.props.data[parentFieldName]
+        && this.props.data[parentFieldName][fieldName] !== undefined) {
+        return this.props.data[parentFieldName][fieldName]
+      }
+      return defaultValue
     }
     }
 
 
     if (!this.props.data || this.props.data[fieldName] === undefined) {
     if (!this.props.data || this.props.data[fieldName] === undefined) {
@@ -163,8 +184,8 @@ class WizardOptions extends React.Component<Props> {
     return this.props.data[fieldName]
     return this.props.data[fieldName]
   }
   }
 
 
-  getDefaultFieldsSchema() {
-    let fieldsSchema = []
+  getDefaultSimpleFieldsSchema() {
+    let fieldsSchema: Field[] = []
 
 
     if (this.props.wizardType === 'migration' || this.props.wizardType === 'replica') {
     if (this.props.wizardType === 'migration' || this.props.wizardType === 'replica') {
       fieldsSchema.push({ name: 'description', type: 'string' })
       fieldsSchema.push({ name: 'description', type: 'string' })
@@ -174,7 +195,7 @@ class WizardOptions extends React.Component<Props> {
       fieldsSchema.unshift({ name: 'skip_os_morphing', type: 'boolean', default: false })
       fieldsSchema.unshift({ name: 'skip_os_morphing', type: 'boolean', default: false })
     }
     }
 
 
-    if (this.props.selectedInstances && this.props.selectedInstances.length > 1) {
+    if (this.props.showSeparatePerVm) {
       const dictionaryLabel = LabelDictionary.get('separate_vm')
       const dictionaryLabel = LabelDictionary.get('separate_vm')
       const label = this.props.wizardType === 'migration' ? dictionaryLabel : dictionaryLabel.replace('Migration', 'Replica')
       const label = this.props.wizardType === 'migration' ? dictionaryLabel : dictionaryLabel.replace('Migration', 'Replica')
       fieldsSchema.unshift({
       fieldsSchema.unshift({
@@ -182,6 +203,18 @@ class WizardOptions extends React.Component<Props> {
       })
       })
     }
     }
 
 
+    if (this.props.minionPools.length) {
+      fieldsSchema.push({
+        name: 'minion_pool_id',
+        label: 'Minion Pool',
+        type: 'string',
+        enum: this.props.minionPools.map(minionPool => ({
+          label: minionPool.pool_name,
+          value: minionPool.id,
+        })),
+      })
+    }
+
     if (this.props.wizardType === 'replica') {
     if (this.props.wizardType === 'replica') {
       fieldsSchema.push({ name: 'execute_now', type: 'boolean', default: true })
       fieldsSchema.push({ name: 'execute_now', type: 'boolean', default: true })
       const executeNowValue = this.getFieldValue('execute_now', true)
       const executeNowValue = this.getFieldValue('execute_now', true)
@@ -199,6 +232,29 @@ class WizardOptions extends React.Component<Props> {
     return fieldsSchema
     return fieldsSchema
   }
   }
 
 
+  getDefaultAdvancedFieldsSchema() {
+    const fieldsSchema: Field[] = []
+
+    if (this.props.minionPools.length && this.props.selectedInstances) {
+      const properties: Field[] = this.props.selectedInstances.map(instance => ({
+        name: instance.instance_name || instance.name,
+        type: 'string',
+        enum: this.props.minionPools.map(minionPool => ({
+          name: minionPool.pool_name,
+          id: minionPool.id,
+        })),
+      }))
+
+      fieldsSchema.push({
+        name: INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS,
+        label: 'Instance OSMorphing Minion Pool Mappings',
+        type: 'object',
+        properties,
+      })
+    }
+    return fieldsSchema
+  }
+
   isPassword(fieldName: string): boolean {
   isPassword(fieldName: string): boolean {
     return fieldName.indexOf('password') > -1 || Boolean(configLoader.config.passwordFields.find(f => f === fieldName))
     return fieldName.indexOf('password') > -1 || Boolean(configLoader.config.passwordFields.find(f => f === fieldName))
   }
   }
@@ -242,9 +298,17 @@ class WizardOptions extends React.Component<Props> {
   renderOptionsField(field: Field) {
   renderOptionsField(field: Field) {
     let additionalProps
     let additionalProps
     if (field.type === 'object' && field.properties) {
     if (field.type === 'object' && field.properties) {
+      const renderOsMorphingLabels = (propName: string) => (
+        propName.indexOf('/') > -1 ? propName.split('/')[propName.split('/').length - 1] : propName
+      )
+
       additionalProps = {
       additionalProps = {
-        valueCallback: (f: any) => this.getFieldValue(f.name, f.default),
-        onChange: (value: any, f: any) => { this.props.onChange(f, value) },
+        valueCallback: (f: any) => this.getFieldValue(f.name, f.default, field.name),
+        onChange: (value: any, f: any) => {
+          this.props.onChange(f, value, field.name)
+        },
+        labelRenderer: field.name === INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS
+          ? renderOsMorphingLabels : null,
         properties: field.properties,
         properties: field.properties,
       }
       }
     } else {
     } else {
@@ -297,12 +361,13 @@ class WizardOptions extends React.Component<Props> {
       return this.renderNoFieldsMessage()
       return this.renderNoFieldsMessage()
     }
     }
 
 
-    let fieldsSchema: Field[] = this.getDefaultFieldsSchema()
+    let fieldsSchema: Field[] = this.getDefaultSimpleFieldsSchema()
     const nonNullableBooleans: string[] = fieldsSchema.filter(f => f.type === 'boolean').map(f => f.name)
     const nonNullableBooleans: string[] = fieldsSchema.filter(f => f.type === 'boolean').map(f => f.name)
 
 
     fieldsSchema = fieldsSchema.concat(this.props.fields.filter(f => f.required))
     fieldsSchema = fieldsSchema.concat(this.props.fields.filter(f => f.required))
 
 
     if (this.props.useAdvancedOptions) {
     if (this.props.useAdvancedOptions) {
+      fieldsSchema = fieldsSchema.concat(this.getDefaultAdvancedFieldsSchema())
       fieldsSchema = fieldsSchema.concat(this.props.fields.filter(f => !f.required))
       fieldsSchema = fieldsSchema.concat(this.props.fields.filter(f => !f.required))
     }
     }
 
 

+ 19 - 6
src/components/organisms/WizardPageContent/WizardPageContent.tsx

@@ -50,6 +50,7 @@ import networkStore from '../../../stores/NetworkStore'
 
 
 import migrationArrowImage from './images/migration'
 import migrationArrowImage from './images/migration'
 import { ProviderTypes } from '../../../@types/Providers'
 import { ProviderTypes } from '../../../@types/Providers'
+import minionPoolStore from '../../../stores/MinionPoolStore'
 
 
 const Wrapper = styled.div<any>`
 const Wrapper = styled.div<any>`
   ${StyleProps.exactWidth(`${parseInt(StyleProps.contentWidth, 10) + 64}px`)}
   ${StyleProps.exactWidth(`${parseInt(StyleProps.contentWidth, 10) + 64}px`)}
@@ -172,6 +173,7 @@ type Props = {
   instanceStore: typeof instanceStore,
   instanceStore: typeof instanceStore,
   networkStore: typeof networkStore,
   networkStore: typeof networkStore,
   endpointStore: typeof endpointStore,
   endpointStore: typeof endpointStore,
+  minionPoolStore: typeof minionPoolStore,
   wizardData: WizardData,
   wizardData: WizardData,
   schedules: ScheduleType[],
   schedules: ScheduleType[],
   storageMap: StorageMap[],
   storageMap: StorageMap[],
@@ -190,8 +192,8 @@ type Props = {
   onInstancesReloadClick: () => void,
   onInstancesReloadClick: () => void,
   onInstanceClick: (instance: Instance) => void,
   onInstanceClick: (instance: Instance) => void,
   onInstancePageClick: (page: number) => void,
   onInstancePageClick: (page: number) => void,
-  onDestOptionsChange: (field: Field, value: any) => void,
-  onSourceOptionsChange: (field: Field, value: any) => void,
+  onDestOptionsChange: (field: Field, value: any, parentFieldName?: string) => void,
+  onSourceOptionsChange: (field: Field, value: any, parentFieldName?: string) => void,
   onNetworkChange: (nic: Nic, network: Network, secGroups?: SecurityGroup[]) => void,
   onNetworkChange: (nic: Nic, network: Network, secGroups?: SecurityGroup[]) => void,
   onStorageChange: (sourceStorage: Disk, targetStorage: StorageBackend, type: 'backend' | 'disk') => void,
   onStorageChange: (sourceStorage: Disk, targetStorage: StorageBackend, type: 'backend' | 'disk') => void,
   onDefaultStorageChange: (value: string | null) => void,
   onDefaultStorageChange: (value: string | null) => void,
@@ -411,7 +413,7 @@ class WizardPageContent extends React.Component<Props, State> {
             onReloadClick={this.props.onInstancesReloadClick}
             onReloadClick={this.props.onInstancesReloadClick}
             onInstanceClick={this.props.onInstanceClick}
             onInstanceClick={this.props.onInstanceClick}
             onPageClick={this.props.onInstancePageClick}
             onPageClick={this.props.onInstancePageClick}
-            selectedInstances={this.props.wizardData.selectedInstances}
+            selectedInstances={this.props.wizardData.selectedInstances || []}
             hasSourceOptions={this.props.hasSourceOptions}
             hasSourceOptions={this.props.hasSourceOptions}
           />
           />
         )
         )
@@ -420,7 +422,10 @@ class WizardPageContent extends React.Component<Props, State> {
         body = (
         body = (
           <WizardOptions
           <WizardOptions
             loading={this.props.providerStore.sourceSchemaLoading
             loading={this.props.providerStore.sourceSchemaLoading
-              || this.props.providerStore.sourceOptionsPrimaryLoading}
+              || this.props.providerStore.sourceOptionsPrimaryLoading
+              || this.props.minionPoolStore.loadingMinionPools}
+            minionPools={this.props.minionPoolStore.minionPools
+              .filter(m => m.pool_platform === 'source' && m.endpoint_id === this.props.wizardData.source?.id)}
             optionsLoading={this.props.providerStore.sourceOptionsSecondaryLoading}
             optionsLoading={this.props.providerStore.sourceOptionsSecondaryLoading}
             optionsLoadingSkipFields={getOptionsLoadingSkipFields('source')}
             optionsLoadingSkipFields={getOptionsLoadingSkipFields('source')}
             fields={this.props.providerStore.sourceSchema}
             fields={this.props.providerStore.sourceSchema}
@@ -439,12 +444,19 @@ class WizardPageContent extends React.Component<Props, State> {
         body = (
         body = (
           <WizardOptions
           <WizardOptions
             loading={this.props.providerStore.destinationSchemaLoading
             loading={this.props.providerStore.destinationSchemaLoading
-              || this.props.providerStore.destinationOptionsPrimaryLoading}
+              || this.props.providerStore.destinationOptionsPrimaryLoading
+              || this.props.minionPoolStore.loadingMinionPools}
+            minionPools={this.props.minionPoolStore.minionPools
+              .filter(m => m.pool_platform === 'destination' && m.endpoint_id === this.props.wizardData.target?.id)}
             optionsLoading={this.props.providerStore.destinationOptionsSecondaryLoading}
             optionsLoading={this.props.providerStore.destinationOptionsSecondaryLoading}
             optionsLoadingSkipFields={[
             optionsLoadingSkipFields={[
               ...getOptionsLoadingSkipFields('destination'), 'description', 'execute_now',
               ...getOptionsLoadingSkipFields('destination'), 'description', 'execute_now',
               'execute_now_options', ...migrationFields.map(f => f.name)]}
               'execute_now_options', ...migrationFields.map(f => f.name)]}
             selectedInstances={this.props.wizardData.selectedInstances}
             selectedInstances={this.props.wizardData.selectedInstances}
+            showSeparatePerVm={
+              Boolean(this.props.wizardData.selectedInstances
+                && this.props.wizardData.selectedInstances.length > 1)
+            }
             fields={this.props.providerStore.destinationSchema}
             fields={this.props.providerStore.destinationSchema}
             onChange={this.props.onDestOptionsChange}
             onChange={this.props.onDestOptionsChange}
             data={this.props.wizardData.destOptions}
             data={this.props.wizardData.destOptions}
@@ -522,6 +534,7 @@ class WizardPageContent extends React.Component<Props, State> {
             sourceSchema={this.props.providerStore.sourceSchema}
             sourceSchema={this.props.providerStore.sourceSchema}
             destinationSchema={this.props.providerStore.destinationSchema}
             destinationSchema={this.props.providerStore.destinationSchema}
             uploadedUserScripts={this.props.uploadedUserScripts}
             uploadedUserScripts={this.props.uploadedUserScripts}
+            minionPools={this.props.minionPoolStore.minionPools}
           />
           />
         )
         )
         break
         break
@@ -541,7 +554,7 @@ class WizardPageContent extends React.Component<Props, State> {
       <Navigation>
       <Navigation>
         <Button secondary onClick={this.props.onBackClick}>Back</Button>
         <Button secondary onClick={this.props.onBackClick}>Back</Button>
         <IconRepresentation>
         <IconRepresentation>
-          <EndpointLogos height={32} endpoint={sourceEndpoint || ''} />
+          <EndpointLogos height={32} endpoint={(sourceEndpoint || '') as any} />
           <WizardTypeIcon
           <WizardTypeIcon
             dangerouslySetInnerHTML={{
             dangerouslySetInnerHTML={{
               __html: this.props.type === 'replica'
               __html: this.props.type === 'replica'

+ 93 - 0
src/components/organisms/WizardSummary/WizardSummary.tsx

@@ -34,6 +34,9 @@ import fieldHelper from '../../../@types/Field'
 import { getDisks } from '../WizardStorage'
 import { getDisks } from '../WizardStorage'
 
 
 import networkArrowImage from './images/network-arrow.svg'
 import networkArrowImage from './images/network-arrow.svg'
+import { INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS } from '../WizardOptions/WizardOptions'
+import { MinionPool } from '../../../@types/MinionPool'
+import { ProviderTypes } from '../../../@types/Providers'
 
 
 const Wrapper = styled.div<any>`
 const Wrapper = styled.div<any>`
   width: 100%;
   width: 100%;
@@ -160,11 +163,18 @@ const OptionValue = styled.div<any>`
   text-overflow: ellipsis;
   text-overflow: ellipsis;
   overflow: hidden;
   overflow: hidden;
 `
 `
+const ObjectTable = styled.div`
+  margin-top: 24px;
+`
+const ObjectTableTitle = styled.div`
+  margin-bottom: 8px;
+`
 
 
 type Props = {
 type Props = {
   data: WizardData,
   data: WizardData,
   wizardType: 'replica' | 'migration',
   wizardType: 'replica' | 'migration',
   schedules: Schedule[],
   schedules: Schedule[],
+  minionPools: MinionPool[]
   defaultStorage: string | null,
   defaultStorage: string | null,
   storageMap: StorageMap[],
   storageMap: StorageMap[],
   instancesDetails: Instance[],
   instancesDetails: Instance[],
@@ -282,11 +292,89 @@ class WizardSummary extends React.Component<Props> {
               </Option>
               </Option>
             )
             )
           }) : null}
           }) : null}
+          {this.renderObjectTable(data.sourceOptions, this.props.sourceSchema, provider)}
         </OptionsList>
         </OptionsList>
       </Section>
       </Section>
     )
     )
   }
   }
 
 
+  renderObjectTable(options: any, schema: Field[], provider?: ProviderTypes | null) {
+    if (!options) {
+      return null
+    }
+    const objectKeys: string[] = Object.keys(options).filter(key => typeof options[key] === 'object'
+      && key !== INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS)
+
+    return objectKeys.map(key => (
+      <ObjectTable key={key}>
+        <ObjectTableTitle>
+          {LabelDictionary.get(key)}
+        </ObjectTableTitle>
+        {Object.keys(options[key]).map(propertyName => {
+          const value = options[key][propertyName]
+          if (value == null || value === '') {
+            return null
+          }
+
+          const optionValue = fieldHelper.getValueAlias(propertyName,
+            value,
+            schema, provider)
+
+          return (
+            <Option key={propertyName}>
+              <OptionLabel title={propertyName}>
+                {LabelDictionary.get(propertyName)}
+              </OptionLabel>
+              <OptionValue title={options[key][propertyName]}>
+                {optionValue}
+              </OptionValue>
+            </Option>
+          )
+        })}
+      </ObjectTable>
+    ))
+  }
+
+  renderMinionPoolMapping() {
+    const allMappings = this.props.data.destOptions?.[INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS]
+    if (!allMappings) {
+      return null
+    }
+    const mappings: any = {}
+    Object.keys(allMappings).forEach(map => {
+      if (allMappings[map]) {
+        mappings[map] = allMappings[map]
+      }
+    })
+
+    if (!Object.keys(mappings).length) {
+      return null
+    }
+
+    const getMinionPoolName = (id: string) => {
+      const minionPool = this.props.minionPools.find(m => m.id === id)
+      return minionPool?.pool_name || id
+    }
+
+    return (
+      <ObjectTable>
+        <ObjectTableTitle>
+          Instance OSMorphing Minion Pool Mappings
+        </ObjectTableTitle>
+        {Object.keys(mappings).map(instanceName => (
+          <Option key={instanceName}>
+            <OptionLabel title={instanceName}>
+              {instanceName}
+            </OptionLabel>
+            <OptionValue title={mappings[instanceName]}>
+              {getMinionPoolName(mappings[instanceName])}
+            </OptionValue>
+          </Option>
+        ))}
+      </ObjectTable>
+    )
+  }
+
   renderTargetOptionsSection() {
   renderTargetOptionsSection() {
     const data = this.props.data
     const data = this.props.data
     const provider = this.props.data && this.props.data.target && this.props.data.target.type
     const provider = this.props.data && this.props.data.target && this.props.data.target.type
@@ -345,15 +433,18 @@ class WizardSummary extends React.Component<Props> {
               || optionName === 'separate_vm'
               || optionName === 'separate_vm'
               || migrationFields.find(f => f.name === optionName)
               || migrationFields.find(f => f.name === optionName)
               || !data.destOptions || data.destOptions[optionName] == null || data.destOptions[optionName] === ''
               || !data.destOptions || data.destOptions[optionName] == null || data.destOptions[optionName] === ''
+              || typeof data.destOptions[optionName] === 'object'
             ) {
             ) {
               return null
               return null
             }
             }
 
 
             const optionLabel = optionName.split('/')
             const optionLabel = optionName.split('/')
               .map(n => LabelDictionary.get(n, `${data.target ? data.target.type : ''}-destination`)).join(' - ')
               .map(n => LabelDictionary.get(n, `${data.target ? data.target.type : ''}-destination`)).join(' - ')
+
             const optionValue = fieldHelper.getValueAlias(optionName,
             const optionValue = fieldHelper.getValueAlias(optionName,
               data.destOptions && data.destOptions[optionName],
               data.destOptions && data.destOptions[optionName],
               this.props.destinationSchema, provider)
               this.props.destinationSchema, provider)
+
             return (
             return (
               <Option key={optionName}>
               <Option key={optionName}>
                 <OptionLabel data-test-id={`wSummary-optionLabel-${optionName}`} title={optionLabel}>
                 <OptionLabel data-test-id={`wSummary-optionLabel-${optionName}`} title={optionLabel}>
@@ -365,6 +456,8 @@ class WizardSummary extends React.Component<Props> {
               </Option>
               </Option>
             )
             )
           }) : null}
           }) : null}
+          {this.renderMinionPoolMapping()}
+          {this.renderObjectTable(data.destOptions, this.props.destinationSchema, provider)}
         </OptionsList>
         </OptionsList>
       </Section>
       </Section>
     )
     )

+ 29 - 6
src/components/pages/MigrationDetailsPage/MigrationDetailsPage.tsx

@@ -39,6 +39,7 @@ import Palette from '../../styleUtils/Palette'
 
 
 import type { Field } from '../../../@types/Field'
 import type { Field } from '../../../@types/Field'
 import type { InstanceScript } from '../../../@types/Instance'
 import type { InstanceScript } from '../../../@types/Instance'
+import minionPoolStore from '../../../stores/MinionPoolStore'
 
 
 const Wrapper = styled.div<any>``
 const Wrapper = styled.div<any>``
 
 
@@ -145,6 +146,10 @@ class MigrationDetailsPage extends React.Component<Props, State> {
       return
       return
     }
     }
 
 
+    if (details.origin_minion_pool_id || details.destination_minion_pool_id) {
+      minionPoolStore.loadMinionPools()
+    }
+
     networkStore.loadNetworks(details.destination_endpoint_id, details.destination_environment, {
     networkStore.loadNetworks(details.destination_endpoint_id, details.destination_environment, {
       quietError: true,
       quietError: true,
       cache,
       cache,
@@ -225,18 +230,32 @@ class MigrationDetailsPage extends React.Component<Props, State> {
     }
     }
   }
   }
 
 
-  async recreateFromReplica(options: Field[], userScripts: InstanceScript[]) {
+  async recreateFromReplica(
+    options: Field[],
+    userScripts: InstanceScript[],
+    minionPoolMappings: { [instance: string]: string },
+  ) {
     const replicaId = migrationStore.migrationDetails && migrationStore.migrationDetails.replica_id
     const replicaId = migrationStore.migrationDetails && migrationStore.migrationDetails.replica_id
     if (!replicaId) {
     if (!replicaId) {
       return
       return
     }
     }
 
 
-    this.migrate(replicaId, options, userScripts)
+    this.migrate(replicaId, options, userScripts, minionPoolMappings)
     this.handleCloseFromReplicaModal()
     this.handleCloseFromReplicaModal()
   }
   }
 
 
-  async migrate(replicaId: string, options: Field[], userScripts: InstanceScript[]) {
-    const migration = await migrationStore.migrateReplica(replicaId, options, userScripts)
+  async migrate(
+    replicaId: string,
+    options: Field[],
+    userScripts: InstanceScript[],
+    minionPoolMappings: { [instance: string]: string },
+  ) {
+    const migration = await migrationStore.migrateReplica(
+      replicaId,
+      options,
+      userScripts,
+      minionPoolMappings,
+    )
     this.props.history.push(`/migrations/${migration.id}/tasks`)
     this.props.history.push(`/migrations/${migration.id}/tasks`)
   }
   }
 
 
@@ -368,7 +387,9 @@ class MigrationDetailsPage extends React.Component<Props, State> {
               || providerStore.destinationOptionsSecondaryLoading}
               || providerStore.destinationOptionsSecondaryLoading}
               endpoints={endpointStore.endpoints}
               endpoints={endpointStore.endpoints}
               page={this.props.match.params.page || ''}
               page={this.props.match.params.page || ''}
-              detailsLoading={endpointStore.loading || migrationStore.detailsLoading}
+              minionPools={minionPoolStore.minionPools}
+              detailsLoading={migrationStore.detailsLoading || endpointStore.loading
+                || minionPoolStore.loadingMinionPools}
               onDeleteMigrationClick={() => { this.handleDeleteMigrationClick() }}
               onDeleteMigrationClick={() => { this.handleDeleteMigrationClick() }}
             />
             />
 )}
 )}
@@ -407,8 +428,10 @@ Note that this may lead to scheduled cleanup tasks being forcibly skipped, and t
             onRequestClose={() => { this.handleCloseFromReplicaModal() }}
             onRequestClose={() => { this.handleCloseFromReplicaModal() }}
           >
           >
             <ReplicaMigrationOptions
             <ReplicaMigrationOptions
+              transferItem={migrationStore.migrationDetails}
+              minionPools={minionPoolStore.minionPools}
               onCancelClick={() => { this.handleCloseFromReplicaModal() }}
               onCancelClick={() => { this.handleCloseFromReplicaModal() }}
-              onMigrateClick={(o, s) => { this.recreateFromReplica(o, s) }}
+              onMigrateClick={(o, s, m) => { this.recreateFromReplica(o, s, m) }}
               instances={instanceStore.instancesDetails}
               instances={instanceStore.instancesDetails}
               loadingInstances={instanceStore.loadingInstancesDetails}
               loadingInstances={instanceStore.loadingInstancesDetails}
               defaultSkipOsMorphing={migrationStore
               defaultSkipOsMorphing={migrationStore

+ 6 - 1
src/components/pages/MigrationsPage/MigrationsPage.tsx

@@ -141,7 +141,12 @@ class MigrationsPage extends React.Component<{ history: any }, State> {
 
 
     await Promise.all(this.state.selectedMigrations.map(async migration => {
     await Promise.all(this.state.selectedMigrations.map(async migration => {
       if (migration.replica_id) {
       if (migration.replica_id) {
-        await migrationStore.migrateReplica(migration.replica_id, replicaMigrationFields, [])
+        await migrationStore.migrateReplica(
+          migration.replica_id,
+          replicaMigrationFields,
+          [],
+          migration.instance_osmorphing_minion_pool_mappings || {},
+        )
       } else {
       } else {
         await migrationStore.recreateFullCopy(migration as any)
         await migrationStore.recreateFullCopy(migration as any)
       }
       }

+ 420 - 0
src/components/pages/MinionPoolDetailsPage/MinionPoolDetailsPage.tsx

@@ -0,0 +1,420 @@
+/*
+Copyright (C) 2020  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/>.
+*/
+
+import React from 'react'
+import styled from 'styled-components'
+import { observer } from 'mobx-react'
+
+import DetailsTemplate from '../../templates/DetailsTemplate/DetailsTemplate'
+import DetailsPageHeader from '../../organisms/DetailsPageHeader/DetailsPageHeader'
+import DetailsContentHeader from '../../organisms/DetailsContentHeader/DetailsContentHeader'
+import Modal from '../../molecules/Modal/Modal'
+import AlertModal from '../../organisms/AlertModal/AlertModal'
+
+import type { Execution } from '../../../@types/Execution'
+import type { Action as DropdownAction } from '../../molecules/ActionDropdown/ActionDropdown'
+
+import userStore from '../../../stores/UserStore'
+import endpointStore from '../../../stores/EndpointStore'
+import notificationStore from '../../../stores/NotificationStore'
+
+import configLoader from '../../../utils/Config'
+
+import minionPoolImage from './images/minion-pool.svg'
+import Palette from '../../styleUtils/Palette'
+import ObjectUtils from '../../../utils/ObjectUtils'
+import minionPoolStore, { MinionPoolAction } from '../../../stores/MinionPoolStore'
+import MinionPoolModal from '../../organisms/MinionPoolModal/MinionPoolModal'
+import MinionPoolDetailsContent from '../../organisms/MinionPoolDetailsContent/MinionPoolDetailsContent'
+import replicaStore from '../../../stores/ReplicaStore'
+import migrationStore from '../../../stores/MigrationStore'
+
+const Wrapper = styled.div<any>``
+
+type Props = {
+  match: { params: { id: string, page: string | null } },
+  history: any,
+}
+type State = {
+  showEditModal: boolean,
+  showDeleteMinionPoolConfirmation: boolean,
+  showCancelConfirmation: boolean
+  forceCancel: boolean,
+  confirmationExecution: Execution | null,
+  pausePolling: boolean,
+}
+
+@observer
+class MinionPoolDetailsPage extends React.Component<Props, State> {
+  state: State = {
+    showEditModal: false,
+    showDeleteMinionPoolConfirmation: false,
+    confirmationExecution: null,
+    showCancelConfirmation: false,
+    pausePolling: false,
+    forceCancel: false,
+  }
+
+  stopPolling: boolean | null = null
+
+  componentDidMount() {
+    document.title = 'Minion Pool Details'
+
+    this.loadMinionPool()
+
+    this.pollData(true)
+  }
+
+  UNSAFE_componentWillReceiveProps(newProps: Props) {
+    if (newProps.match.params.id !== this.props.match.params.id) {
+      this.loadMinionPool(newProps.match.params.id)
+    }
+  }
+
+  componentWillUnmount() {
+    this.stopPolling = true
+    minionPoolStore.clearMinionPoolDetails()
+  }
+
+  get minionPoolId() {
+    if (!this.props.match || !this.props.match.params || !this.props.match.params.id) {
+      throw new Error('Invalid minion pool id')
+    }
+    return this.props.match.params.id
+  }
+
+  getStatus() {
+    return minionPoolStore.minionPoolDetails?.pool_status
+  }
+
+  async loadMinionPool(minionPoolId?: string) {
+    await Promise.all([
+      endpointStore.getEndpoints({ showLoading: true }),
+      minionPoolStore
+        .loadMinionPoolDetails(minionPoolId || this.minionPoolId, { showLoading: true }),
+    ])
+    const endpoint = endpointStore.endpoints
+      .find(e => e.id === minionPoolStore.minionPoolDetails?.endpoint_id)
+    if (!endpoint) {
+      return
+    }
+    await minionPoolStore.loadMinionPoolSchema(
+      endpoint.type,
+      minionPoolStore.minionPoolDetails!.pool_platform,
+    )
+    await minionPoolStore.loadEnvOptions(
+      endpoint.id,
+      endpoint.type,
+      minionPoolStore.minionPoolDetails!.pool_platform,
+      { useCache: true },
+    )
+  }
+
+  handleUserItemClick(item: { value: string }) {
+    switch (item.value) {
+      case 'signout':
+        userStore.logout()
+        break
+      default:
+    }
+  }
+
+  handleCancelExecutionConfirmation() {
+    if (!minionPoolStore.minionPoolDetails) {
+      return
+    }
+    minionPoolStore.cancelExecution(
+      minionPoolStore.minionPoolDetails.id,
+      this.state.forceCancel,
+      this.state.confirmationExecution?.id,
+    )
+
+    this.handleCloseCancelConfirmation()
+  }
+
+  handleDeleteMinionPoolClick() {
+    this.setState({ showDeleteMinionPoolConfirmation: true })
+  }
+
+  handleDeleteMinionPool() {
+    this.setState({ showDeleteMinionPoolConfirmation: false })
+    this.props.history.push('/replicas')
+    minionPoolStore.deleteMinionPool(minionPoolStore.minionPoolDetails!.id)
+  }
+
+  handleCloseDeleteMinionPoolConfirmation() {
+    this.setState({ showDeleteMinionPoolConfirmation: false })
+  }
+
+  handleMinionPoolEditClick() {
+    this.setState({ showEditModal: true, pausePolling: true })
+  }
+
+  handleCancelExecution(confirmationExecution: Execution | null, force?: boolean) {
+    this.setState({
+      showCancelConfirmation: true,
+      confirmationExecution,
+      forceCancel: force || false,
+    })
+  }
+
+  handleCloseCancelConfirmation() {
+    this.setState({
+      showCancelConfirmation: false,
+    })
+  }
+
+  async pollData(showLoading: boolean) {
+    if (this.state.pausePolling || this.stopPolling) {
+      return
+    }
+
+    await Promise.all([
+      minionPoolStore.loadMinionPoolDetails(this.minionPoolId, {
+        showLoading, skipLog: true,
+      }),
+      (async () => {
+        if (window.location.pathname.indexOf('executions') > -1) {
+          await minionPoolStore.loadExecutionTasks({
+            minionPoolId: this.minionPoolId,
+            skipLog: true,
+          })
+        }
+      })(),
+    ])
+
+    setTimeout(() => { this.pollData(false) }, configLoader.config.requestPollTimeout)
+  }
+
+  closeEditModal() {
+    this.setState({ showEditModal: false, pausePolling: false }, () => {
+      this.pollData(false)
+    })
+  }
+
+  handleUpdateComplete(redirectTo: string) {
+    this.props.history.push(redirectTo)
+    this.closeEditModal()
+  }
+
+  async handleExecutionChange(executionId: string) {
+    await ObjectUtils.waitFor(() => Boolean(minionPoolStore.minionPoolDetails))
+    if (!minionPoolStore.minionPoolDetails?.id) {
+      return
+    }
+    minionPoolStore.loadExecutionTasks(
+      {
+        minionPoolId: minionPoolStore.minionPoolDetails.id,
+        executionId,
+      },
+    )
+  }
+
+  async handleAction(action: MinionPoolAction) {
+    const runAction = async (message: string) => {
+      if (!minionPoolStore.minionPoolDetails) {
+        return
+      }
+      notificationStore.alert(message)
+      await minionPoolStore.runAction(minionPoolStore.minionPoolDetails.id, action)
+      await minionPoolStore.loadMinionPoolDetails(minionPoolStore.minionPoolDetails.id)
+    }
+
+    switch (action) {
+      case 'set-up-shared-resources':
+        runAction('Setting up shared resources...')
+        break
+      case 'tear-down-shared-resources':
+        runAction('Tearing up shared resources...')
+        break
+      case 'allocate-machines':
+        runAction('Allocating machines...')
+        break
+      case 'deallocate-machines':
+        runAction('Deallocating machines...')
+        break
+      default:
+    }
+  }
+
+  renderEditMinionPool() {
+    if (!this.state.showEditModal) {
+      return null
+    }
+    const endpoint = endpointStore.endpoints
+      .find(e => e.id === minionPoolStore.minionPoolDetails?.endpoint_id)
+    if (!endpoint) {
+      return null
+    }
+    return (
+      <Modal
+        isOpen
+        title="Update Minion Pool"
+        onRequestClose={() => { this.closeEditModal() }}
+      >
+        <MinionPoolModal
+          cancelButtonText="Close"
+          endpoint={endpoint}
+          onCancelClick={() => { this.closeEditModal() }}
+          onRequestClose={() => { this.closeEditModal() }}
+          minionPool={minionPoolStore.minionPoolDetails}
+          platform={minionPoolStore.minionPoolDetails?.pool_platform || 'source'}
+          onUpdateComplete={r => { this.handleUpdateComplete(r) }}
+        />
+      </Modal>
+    )
+  }
+
+  render() {
+    const uninitialized = minionPoolStore.minionPoolDetails?.pool_status === 'UNINITIALIZED'
+    const deallocated = minionPoolStore.minionPoolDetails?.pool_status === 'DEALLOCATED'
+    const allocated = minionPoolStore.minionPoolDetails?.pool_status === 'ALLOCATED'
+    const isRunning = minionPoolStore.minionPoolDetails?.pool_status?.indexOf('ING') === ((minionPoolStore.minionPoolDetails?.pool_status?.length || -100) - 3)
+
+    const dropdownActions: DropdownAction[] = [
+      {
+        label: 'Edit',
+        action: () => { this.handleMinionPoolEditClick() },
+        disabled: !uninitialized,
+        title: !uninitialized ? 'The minion pool should be uninitialized' : '',
+      },
+      {
+        label: 'Setup shared resources',
+        color: Palette.primary,
+        action: () => {
+          this.handleAction('set-up-shared-resources')
+        },
+        disabled: !uninitialized,
+        title: !uninitialized ? 'The minion pool should be uninitialized' : '',
+      },
+      {
+        label: 'Tear down shared resources',
+        action: () => {
+          this.handleAction('tear-down-shared-resources')
+        },
+        disabled: !deallocated,
+        title: !deallocated ? 'The minion pool should be deallocated' : '',
+      },
+      {
+        label: 'Allocate Machines',
+        color: Palette.primary,
+        action: () => {
+          this.handleAction('allocate-machines')
+        },
+        disabled: !deallocated,
+        title: !deallocated ? 'The minion pool should be deallocated' : '',
+      },
+      {
+        label: 'Deallocate Machines',
+        action: () => {
+          this.handleAction('deallocate-machines')
+        },
+        disabled: !allocated,
+        title: !allocated ? 'The minion pool should be allocated' : '',
+      },
+      {
+        label: 'Cancel Execution',
+        action: () => {
+          this.setState({
+            showCancelConfirmation: true,
+            confirmationExecution: null,
+            forceCancel: false,
+          })
+        },
+        disabled: !isRunning,
+        title: !isRunning ? 'The minion pool do not have an active execution' : '',
+      },
+      {
+        label: 'Delete Minion Pool',
+        color: Palette.alert,
+        action: () => {
+          this.setState({ showDeleteMinionPoolConfirmation: true })
+        },
+        disabled: !uninitialized,
+        title: !uninitialized ? 'The minion pool should be uninitialized' : '',
+      },
+    ]
+
+    return (
+      <Wrapper>
+        <DetailsTemplate
+          pageHeaderComponent={(
+            <DetailsPageHeader
+              user={userStore.loggedUser}
+              onUserItemClick={item => { this.handleUserItemClick(item) }}
+            />
+)}
+          contentHeaderComponent={(
+            <DetailsContentHeader
+              statusPill={minionPoolStore.minionPoolDetails?.pool_status}
+              itemTitle={minionPoolStore.minionPoolDetails?.pool_name}
+              itemType="minion pool"
+              dropdownActions={dropdownActions}
+              largeDropdownActionItems
+              backLink="/minion-pools"
+              typeImage={minionPoolImage}
+            />
+)}
+          contentComponent={(
+            <MinionPoolDetailsContent
+              item={minionPoolStore.minionPoolDetails}
+              replicas={replicaStore.replicas
+                .filter(r => r.origin_minion_pool_id === minionPoolStore.minionPoolDetails?.id
+                  || r.destination_minion_pool_id === minionPoolStore.minionPoolDetails?.id)}
+              migrations={migrationStore.migrations
+                .filter(r => r.origin_minion_pool_id === minionPoolStore.minionPoolDetails?.id
+                  || r.destination_minion_pool_id === minionPoolStore.minionPoolDetails?.id)}
+              endpoints={endpointStore.endpoints}
+              detailsLoading={minionPoolStore.loadingMinionPoolDetails || endpointStore.loading}
+              schema={minionPoolStore.minionPoolCombinedSchema}
+              schemaLoading={minionPoolStore.loadingMinionPoolSchema
+                || minionPoolStore.loadingEnvOptions}
+              executionsLoading={minionPoolStore.loadingMinionPoolDetails}
+              onExecutionChange={id => { this.handleExecutionChange(id) }}
+              executions={minionPoolStore.minionPoolDetails?.executions || []}
+              executionsTasksLoading={minionPoolStore.loadingMinionPoolDetails
+                || minionPoolStore.loadingExecutionsTasks}
+              executionsTasks={minionPoolStore.executionsTasks}
+              page={this.props.match.params.page || ''}
+              onCancelExecutionClick={(e, f) => { this.handleCancelExecution(e, f) }}
+              onDeleteMinionPoolClick={() => { this.handleDeleteMinionPoolClick() }}
+              onRunAction={a => { this.handleAction(a) }}
+            />
+          )}
+        />
+        {this.state.showDeleteMinionPoolConfirmation ? (
+          <AlertModal
+            isOpen
+            title="Delete Minion Pool?"
+            message="Are you sure you want to delete the Minion Pool"
+            extraMessage="Deleting a Coriolis Minion Pool is permanent!"
+            onConfirmation={() => { this.handleDeleteMinionPool() }}
+            onRequestClose={() => { this.setState({ showDeleteMinionPoolConfirmation: false }) }}
+          />
+        ) : null}
+        <AlertModal
+          isOpen={this.state.showCancelConfirmation}
+          title="Cancel Execution?"
+          message="Are you sure you want to cancel the current execution?"
+          extraMessage=" "
+          onConfirmation={() => { this.handleCancelExecutionConfirmation() }}
+          onRequestClose={() => { this.handleCloseCancelConfirmation() }}
+        />
+        {this.renderEditMinionPool()}
+      </Wrapper>
+    )
+  }
+}
+
+export default MinionPoolDetailsPage

+ 128 - 0
src/components/pages/MinionPoolDetailsPage/images/minion-pool.svg

@@ -0,0 +1,128 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   sodipodi:docname="minion-pool.svg"
+   inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
+   id="svg8"
+   version="1.1"
+   viewBox="0 0 16.933333 16.933334"
+   height="64"
+   width="64">
+  <defs
+     id="defs2">
+    <path
+       d="M 24,48 C 37.254834,48 48,37.254834 48,24 48,10.745166 37.254834,0 24,0 10.745166,0 0,10.745166 0,24 0,37.254834 10.745166,48 24,48 Z"
+       id="path-1" />
+    <path
+       d="M24,48 C37.254834,48 48,37.254834 48,24 C48,10.745166 37.254834,0 24,0 C10.745166,0 0,10.745166 0,24 C0,37.254834 10.745166,48 24,48 Z"
+       id="path-1-2" />
+    <linearGradient
+       osb:paint="solid"
+       id="linearGradient892">
+      <stop
+         id="stop890"
+         offset="0"
+         style="stop-color:#0044ca;stop-opacity:1;" />
+    </linearGradient>
+    <path
+       d="M24,48 C37.254834,48 48,37.254834 48,24 C48,10.745166 37.254834,0 24,0 C10.745166,0 0,10.745166 0,24 C0,37.254834 10.745166,48 24,48 Z"
+       id="path-1-3" />
+  </defs>
+  <sodipodi:namedview
+     inkscape:window-maximized="0"
+     inkscape:window-y="0"
+     inkscape:window-x="355"
+     inkscape:window-height="1040"
+     inkscape:window-width="1274"
+     units="px"
+     showgrid="false"
+     inkscape:document-rotation="0"
+     inkscape:current-layer="Icon/Project/ProjectListItem-4"
+     inkscape:document-units="mm"
+     inkscape:cy="59.411436"
+     inkscape:cx="0.035328786"
+     inkscape:zoom="1.979899"
+     inkscape:pageshadow="2"
+     inkscape:pageopacity="0.0"
+     borderopacity="1.0"
+     bordercolor="#666666"
+     pagecolor="#ffffff"
+     id="base" />
+  <metadata
+     id="metadata5">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     id="layer1"
+     inkscape:groupmode="layer"
+     inkscape:label="Layer 1">
+    <g
+       transform="scale(0.35158512)"
+       style="fill:none;fill-rule:evenodd;stroke:none;stroke-width:1"
+       id="Icon/Project/ProjectListItem">
+      <mask
+         fill="#ffffff"
+         id="mask-2">
+        <use
+           height="100%"
+           width="100%"
+           y="0"
+           x="0"
+           id="use15"
+           xlink:href="#path-1-2" />
+      </mask>
+      <use
+         height="100%"
+         width="100%"
+         y="0"
+         x="0"
+         xlink:href="#path-1-2"
+         fill-rule="evenodd"
+         fill="#c8ccd7"
+         id="Pat-Benetar"
+         style="fill:#ffffff;fill-opacity:1" />
+    </g>
+    <g
+       transform="matrix(0.26458333,0,0,0.26458333,29.063212,-7.2854432)"
+       style="fill:none;fill-rule:evenodd;stroke:none;stroke-width:1"
+       id="Icon/Project/ProjectListItem-4">
+      <mask
+         fill="#ffffff"
+         id="mask-2-1">
+        <use
+           height="100%"
+           width="100%"
+           y="0"
+           x="0"
+           id="use15-5"
+           xlink:href="#path-1-3" />
+      </mask>
+      <path
+         d="m 27.675789,16.752495 v -3.472062 h -6.944288 v 3.472083 z m -15.405554,3.578939 v 13.995287 c 0,0.775617 0.63465,1.408655 1.410265,1.408713 l 21.046288,0.0016 c 0.775615,5.7e-5 1.410265,-0.634648 1.410265,-1.410265 V 20.331434 c 0,-0.775615 -0.63465,-1.410266 -1.410265,-1.410266 H 13.6805 c -0.775615,0 -1.410265,0.634651 -1.410265,1.410266 z M 35.48529,16.752516 c 1.565332,0 2.820418,1.255085 2.820418,2.820417 v 15.5123 c 0,1.565331 -1.255086,2.820418 -2.820418,2.820418 H 12.921944 c -1.565332,0 -2.820418,-1.255087 -2.820418,-2.820418 l 0.0141,-15.5123 c 0,-1.565332 1.240984,-2.820417 2.806316,-2.820417 h 5.640837 v -2.820419 c 0,-1.565332 1.255085,-2.820419 2.820418,-2.820419 h 5.640835 c 1.565332,0 2.820418,1.255087 2.820418,2.820419 v 2.820419 z"
+         style="fill:none;fill-opacity:1;stroke:none;stroke-width:1.41021;stroke-opacity:1"
+         id="path917-7"
+         sodipodi:nodetypes="cccccssssssssssssssccscsssscs" />
+      <path
+         sodipodi:nodetypes="cccccssssssssssssssccscsssscs"
+         d="m -72.499106,49.675981 v -5.272804 h -10.544297 v 5.273474 z m -21.776155,4.086187 v 19.904625 c 0,1.029498 0.842037,1.871532 1.871536,1.871532 h 29.263615 c 1.029495,0 1.871533,-0.842036 1.871533,-1.871532 V 53.762168 c 0,-1.029493 -0.842038,-1.871533 -1.871533,-1.871533 h -29.263615 c -1.029499,0 -1.871536,0.842041 -1.871536,1.871533 z m 31.477988,-4.086158 c 2.07771,0 3.74362,1.665912 3.74362,3.743619 v 20.589922 c 0,2.07771 -1.66591,3.743622 -3.74362,3.743622 h -29.948978 c -2.077712,0 -3.743623,-1.665912 -3.743623,-3.743622 l 0.01871,-20.589922 c 0,-2.077707 1.647194,-3.743619 3.724906,-3.743619 h 7.487245 v -3.743624 c 0,-2.077712 1.665911,-3.743624 3.743622,-3.743624 h 7.487243 c 2.077708,0 3.743621,1.665912 3.743621,3.743624 v 3.743624 z"
+         style="fill:#0044ca;fill-opacity:1;stroke:none;stroke-width:1.87158;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         id="path896" />
+    </g>
+  </g>
+</svg>

+ 6 - 0
src/components/pages/MinionPoolDetailsPage/package.json

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

+ 411 - 0
src/components/pages/MinionPoolsPage/MinionPoolsPage.tsx

@@ -0,0 +1,411 @@
+/*
+Copyright (C) 2020  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/>.
+*/
+
+import React from 'react'
+import styled from 'styled-components'
+import { observer } from 'mobx-react'
+
+import { RouteComponentProps } from 'react-router-dom'
+
+import Modal from '../../molecules/Modal'
+import MainTemplate from '../../templates/MainTemplate/MainTemplate'
+import Navigation from '../../organisms/Navigation/Navigation'
+import FilterList from '../../organisms/FilterList/FilterList'
+import PageHeader from '../../organisms/PageHeader/PageHeader'
+
+import type { Action as DropdownAction } from '../../molecules/ActionDropdown/ActionDropdown'
+
+import projectStore from '../../../stores/ProjectStore'
+
+import configLoader from '../../../utils/Config'
+import { MinionPool } from '../../../@types/MinionPool'
+
+import emptyListImage from './images/minion-pool-empty-list.svg'
+import providerStore from '../../../stores/ProviderStore'
+import endpointStore from '../../../stores/EndpointStore'
+import minionPoolStore, { MinionPoolAction } from '../../../stores/MinionPoolStore'
+import { Endpoint } from '../../../@types/Endpoint'
+import MinionEndpointModal from '../../organisms/MinionEndpointModal/MinionEndpointModal'
+import MinionPoolModal from '../../organisms/MinionPoolModal/MinionPoolModal'
+import MinionPoolListItem from '../../molecules/MinionPoolListItem/MinionPoolListItem'
+import Palette from '../../styleUtils/Palette'
+import notificationStore from '../../../stores/NotificationStore'
+import AlertModal from '../../organisms/AlertModal/AlertModal'
+
+const Wrapper = styled.div<any>``
+
+type State = {
+  modalIsOpen: boolean,
+  selectedMinionPools: MinionPool[],
+  showChooseMinionEndpointModal: boolean,
+  showMinionPoolModal: boolean,
+  selectedMinionPoolEndpoint: Endpoint | null
+  showCancelExecutionModal: boolean,
+  showDeletePoolsModal: boolean,
+  selectedMinionPoolPlatform: 'source' | 'destination'
+}
+
+@observer
+class MinionPoolsPage extends React.Component<RouteComponentProps, State> {
+  state: State = {
+    modalIsOpen: false,
+    selectedMinionPools: [],
+    showChooseMinionEndpointModal: false,
+    selectedMinionPoolEndpoint: null,
+    showMinionPoolModal: false,
+    showCancelExecutionModal: false,
+    showDeletePoolsModal: false,
+    selectedMinionPoolPlatform: 'source',
+  }
+
+  pollTimeout: number = 0
+
+  stopPolling: boolean = false
+
+  componentDidMount() {
+    document.title = 'Coriolis Minion Pools'
+
+    projectStore.getProjects()
+    endpointStore.getEndpoints()
+    this.stopPolling = false
+    this.pollData(minionPoolStore.minionPools.length === 0)
+  }
+
+  componentWillUnmount() {
+    clearTimeout(this.pollTimeout)
+    this.stopPolling = true
+  }
+
+  getFilterItems() {
+    return [
+      { label: 'All', value: 'all' },
+      { label: 'Allocating', value: 'ALLOCATING' },
+      { label: 'Allocated', value: 'ALLOCATED' },
+      { label: 'Initializing', value: 'INITIALIZING' },
+      { label: 'Initialized', value: 'INITIALIZED' },
+      { label: 'Error', value: 'ERROR' },
+    ]
+  }
+
+  getEndpoint(endpointId: string) {
+    return endpointStore.endpoints.find(endpoint => endpoint.id === endpointId)
+  }
+
+  async pollData(showLoading: boolean) {
+    if (this.state.modalIsOpen || this.stopPolling) {
+      return
+    }
+
+    await Promise.all([
+      minionPoolStore.loadMinionPools({ showLoading }),
+    ])
+
+    this.pollTimeout = setTimeout(() => {
+      this.pollData(false)
+    }, configLoader.config.requestPollTimeout)
+  }
+
+  searchText(item: MinionPool, text?: string | null) {
+    const result = false
+    if (item.pool_name.toLowerCase().indexOf(text || '') > -1) {
+      return true
+    }
+    return result
+  }
+
+  itemFilterFunction(item: MinionPool, filterStatus?: string | null, filterText?: string) {
+    if ((filterStatus !== 'all' && item.pool_status !== filterStatus)
+      || !this.searchText(item, filterText)
+    ) {
+      return false
+    }
+
+    return true
+  }
+
+  cancelExecutions() {
+    this.state.selectedMinionPools.forEach(pool => {
+      minionPoolStore.cancelExecution(pool.id)
+    })
+    this.setState({ showCancelExecutionModal: false })
+  }
+
+  deleteSelectedMinionPools() {
+    this.state.selectedMinionPools.forEach(pool => {
+      minionPoolStore.deleteMinionPool(pool.id)
+    })
+    this.setState({ showDeletePoolsModal: false })
+  }
+
+  handleProjectChange() {
+    projectStore.getProjects()
+    endpointStore.getEndpoints()
+    minionPoolStore.loadMinionPools({ showLoading: true })
+  }
+
+  handleItemClick(item: MinionPool) {
+    this.props.history.push(`/minion-pools/${item.id}`)
+  }
+
+  handleReloadButtonClick() {
+    projectStore.getProjects()
+    endpointStore.getEndpoints()
+    minionPoolStore.loadMinionPools({ showLoading: true })
+  }
+
+  handleEmptyListButtonClick() {
+    providerStore.loadProviders()
+    endpointStore.getEndpoints({ showLoading: true })
+    this.setState({ showChooseMinionEndpointModal: true, modalIsOpen: true })
+  }
+
+  handleCloseChooseMinionPoolEndpointModal() {
+    this.setState({
+      showChooseMinionEndpointModal: false,
+      modalIsOpen: false,
+    }, () => { this.pollData(false) })
+  }
+
+  handleBackMinionPoolModal() {
+    this.setState({
+      showChooseMinionEndpointModal: true,
+      showMinionPoolModal: false,
+    })
+  }
+
+  handleCloseMinionPoolModalRequest() {
+    this.setState({
+      showMinionPoolModal: false,
+      modalIsOpen: false,
+    }, () => { this.pollData(false) })
+  }
+
+  handleChooseMinionPoolSelectEndpoint(selectedMinionPoolEndpoint: Endpoint, platform: 'source' | 'destination') {
+    this.setState({
+      showChooseMinionEndpointModal: false,
+      showMinionPoolModal: true,
+      selectedMinionPoolEndpoint,
+      selectedMinionPoolPlatform: platform,
+    })
+  }
+
+  handleModalOpen() {
+    this.setState({ modalIsOpen: true })
+  }
+
+  handleModalClose() {
+    this.setState({ modalIsOpen: false }, () => {
+      this.pollData(false)
+    })
+  }
+
+  async handleAction(action: MinionPoolAction) {
+    const runAction = async (message: string) => {
+      notificationStore.alert(message)
+      await Promise.all(this.state.selectedMinionPools
+        .map(minionPool => minionPoolStore.runAction(minionPool.id, action)))
+      await minionPoolStore.loadMinionPools()
+    }
+
+    switch (action) {
+      case 'set-up-shared-resources':
+        runAction('Setting up shared resources...')
+        break
+      case 'tear-down-shared-resources':
+        runAction('Tearing up shared resources...')
+        break
+      case 'allocate-machines':
+        runAction('Allocating machines...')
+        break
+      case 'deallocate-machines':
+        runAction('Deallocating machines...')
+        break
+      default:
+    }
+  }
+
+  render() {
+    const allPoolsAreStatus = () => {
+      const statuses: any = {}
+      this.state.selectedMinionPools.forEach(pool => {
+        if (!statuses[pool.pool_status]) {
+          statuses[pool.pool_status] = 0
+        }
+        statuses[pool.pool_status] += 1
+      })
+      if (Object.keys(statuses).length === 1) {
+        return Object.keys(statuses)[0]
+      }
+      return null
+    }
+    const allPoolStatusResult = allPoolsAreStatus()
+    const uninitialized = allPoolStatusResult === 'UNINITIALIZED'
+    const deallocated = allPoolStatusResult === 'DEALLOCATED'
+    const allocated = allPoolStatusResult === 'ALLOCATED'
+    const isRunning = allPoolStatusResult?.indexOf('ING') === ((allPoolStatusResult?.length || -100) - 3)
+    const BulkActions: DropdownAction[] = [
+      {
+        label: 'Setup shared resources',
+        color: Palette.primary,
+        action: () => {
+          this.handleAction('set-up-shared-resources')
+        },
+        disabled: !uninitialized,
+        title: !uninitialized ? 'The minion pools should be uninitialized' : '',
+      },
+      {
+        label: 'Tear down shared resources',
+        action: () => {
+          this.handleAction('tear-down-shared-resources')
+        },
+        disabled: !deallocated,
+        title: !deallocated ? 'The minion pools should be deallocated' : '',
+      },
+      {
+        label: 'Allocate Machines',
+        color: Palette.primary,
+        action: () => {
+          this.handleAction('allocate-machines')
+        },
+        disabled: !deallocated,
+        title: !deallocated ? 'The minion pools should be deallocated' : '',
+      },
+      {
+        label: 'Deallocate Machines',
+        action: () => {
+          this.handleAction('deallocate-machines')
+        },
+        disabled: !allocated,
+        title: !allocated ? 'The minion pools should be allocated' : '',
+      },
+      {
+        label: 'Cancel Execution',
+        action: () => {
+          this.setState({ showCancelExecutionModal: true })
+        },
+        disabled: !isRunning,
+        title: !isRunning ? 'The minion pools do not have an active execution' : '',
+      },
+      {
+        label: 'Delete Minion Pools',
+        color: Palette.alert,
+        action: () => {
+          this.setState({ showDeletePoolsModal: true })
+        },
+        disabled: !uninitialized,
+        title: !uninitialized ? 'The minion pools should be uninitialized' : '',
+      },
+    ]
+
+    return (
+      <Wrapper>
+        <MainTemplate
+          navigationComponent={<Navigation currentPage="minion-pools" />}
+          listComponent={(
+            <FilterList
+              filterItems={this.getFilterItems()}
+              selectionLabel="minion pool"
+              loading={minionPoolStore.loadingMinionPools}
+              items={minionPoolStore.minionPools}
+              dropdownActions={BulkActions}
+              largeDropdownActionItems
+              onItemClick={item => { this.handleItemClick(item) }}
+              onReloadButtonClick={() => { this.handleReloadButtonClick() }}
+              itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
+              onSelectedItemsChange={selectedMinionPools => {
+                this.setState({ selectedMinionPools })
+              }}
+              renderItemComponent={options => (
+                <MinionPoolListItem
+                  {...options}
+                  endpointType={id => {
+                    const endpoint = this.getEndpoint(id)
+                    if (endpoint) {
+                      return endpoint.type
+                    }
+                    if (endpointStore.loading) {
+                      return 'Loading...'
+                    }
+                    return 'Not Found'
+                  }}
+                />
+              )}
+              emptyListImage={emptyListImage}
+              emptyListMessage="It seems like you don’t have any Minion Pools in this project."
+              emptyListExtraMessage="A minion pool defines a set of machines to be created on a certain endpoint with a certain set of options. These machines can then be used during Migrations/Replicas to avoid having to create/delete them during each transfer, thus reducing the time duration."
+              emptyListButtonLabel="Create a Minion Pool"
+              onEmptyListButtonClick={() => { this.handleEmptyListButtonClick() }}
+            />
+          )}
+          headerComponent={(
+            <PageHeader
+              title="Coriolis Minion Pools"
+              onProjectChange={() => { this.handleProjectChange() }}
+              onModalOpen={() => { this.handleModalOpen() }}
+              onModalClose={() => { this.handleModalClose() }}
+            />
+          )}
+        />
+        {this.state.showChooseMinionEndpointModal ? (
+          <MinionEndpointModal
+            providers={providerStore.providers}
+            endpoints={endpointStore.endpoints}
+            loading={providerStore.providersLoading || endpointStore.loading}
+            onRequestClose={() => { this.handleCloseChooseMinionPoolEndpointModal() }}
+            onSelectEndpoint={(endpoint, platform) => {
+              this.handleChooseMinionPoolSelectEndpoint(endpoint, platform)
+            }}
+          />
+        ) : null}
+        {this.state.showMinionPoolModal ? (
+          <Modal
+            isOpen
+            title="New Minion Pool"
+            onRequestClose={() => { this.handleCloseMinionPoolModalRequest() }}
+          >
+            <MinionPoolModal
+              platform={this.state.selectedMinionPoolPlatform}
+              cancelButtonText="Back"
+              endpoint={this.state.selectedMinionPoolEndpoint!}
+              onCancelClick={() => { this.handleBackMinionPoolModal() }}
+              onRequestClose={() => { this.handleCloseMinionPoolModalRequest() }}
+            />
+          </Modal>
+        ) : null}
+        {this.state.showCancelExecutionModal ? (
+          <AlertModal
+            isOpen
+            title="Cancel Executions?"
+            message="Are you sure you want to cancel the selected Minion Pools executions?"
+            extraMessage=" "
+            onConfirmation={() => { this.cancelExecutions() }}
+            onRequestClose={() => { this.setState({ showCancelExecutionModal: false }) }}
+          />
+        ) : null}
+        {this.state.showDeletePoolsModal ? (
+          <AlertModal
+            isOpen
+            title="Delete Minion Pools?"
+            message="Are you sure you want to delete the selected Minion Pools?"
+            extraMessage="Deleting a Coriolis Minion Pool is permanent!"
+            onConfirmation={() => { this.deleteSelectedMinionPools() }}
+            onRequestClose={() => { this.setState({ showDeletePoolsModal: false }) }}
+          />
+        ) : null}
+      </Wrapper>
+    )
+  }
+}
+
+export default MinionPoolsPage

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 87 - 0
src/components/pages/MinionPoolsPage/images/minion-pool-empty-list.svg


+ 6 - 0
src/components/pages/MinionPoolsPage/package.json

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

+ 21 - 5
src/components/pages/ReplicaDetailsPage/ReplicaDetailsPage.tsx

@@ -50,6 +50,7 @@ import replicaImage from './images/replica.svg'
 import Palette from '../../styleUtils/Palette'
 import Palette from '../../styleUtils/Palette'
 import { ReplicaItemDetails } from '../../../@types/MainItem'
 import { ReplicaItemDetails } from '../../../@types/MainItem'
 import ObjectUtils from '../../../utils/ObjectUtils'
 import ObjectUtils from '../../../utils/ObjectUtils'
+import minionPoolStore from '../../../stores/MinionPoolStore'
 
 
 const Wrapper = styled.div<any>``
 const Wrapper = styled.div<any>``
 
 
@@ -201,6 +202,8 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     if (!replica) {
     if (!replica) {
       return null
       return null
     }
     }
+    minionPoolStore.loadMinionPools()
+
     this.loadIsEditable(replica)
     this.loadIsEditable(replica)
     networkStore.loadNetworks(replica.destination_endpoint_id, replica.destination_environment, {
     networkStore.loadNetworks(replica.destination_endpoint_id, replica.destination_environment, {
       quietError: true,
       quietError: true,
@@ -384,12 +387,20 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     })
     })
   }
   }
 
 
-  migrateReplica(options: Field[], uploadedScripts: InstanceScript[]) {
-    this.migrate(options, uploadedScripts)
+  migrateReplica(
+    options: Field[],
+    uploadedScripts: InstanceScript[],
+    minionPoolMappings: { [instance: string]: string },
+  ) {
+    this.migrate(options, uploadedScripts, minionPoolMappings)
     this.handleCloseMigrationModal()
     this.handleCloseMigrationModal()
   }
   }
 
 
-  async migrate(options: Field[], uploadedScripts: InstanceScript[]) {
+  async migrate(
+    options: Field[],
+    uploadedScripts: InstanceScript[],
+    minionPoolMappings: { [instance: string]: string },
+  ) {
     const replica = this.replica
     const replica = this.replica
     if (!replica) {
     if (!replica) {
       return
       return
@@ -398,6 +409,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
       replica.id,
       replica.id,
       options,
       options,
       uploadedScripts,
       uploadedScripts,
+      minionPoolMappings,
     )
     )
     notificationStore.alert('Migration successfully created from replica.', 'success', {
     notificationStore.alert('Migration successfully created from replica.', 'success', {
       action: {
       action: {
@@ -573,7 +585,9 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
               endpoints={endpointStore.endpoints}
               endpoints={endpointStore.endpoints}
               scheduleStore={scheduleStore}
               scheduleStore={scheduleStore}
               networks={networkStore.networks}
               networks={networkStore.networks}
-              detailsLoading={replicaStore.replicaDetailsLoading || endpointStore.loading}
+              minionPools={minionPoolStore.minionPools}
+              detailsLoading={replicaStore.replicaDetailsLoading || endpointStore.loading
+                || minionPoolStore.loadingMinionPools}
               sourceSchema={providerStore.sourceSchema}
               sourceSchema={providerStore.sourceSchema}
               sourceSchemaLoading={providerStore.sourceSchemaLoading
               sourceSchemaLoading={providerStore.sourceSchemaLoading
               || providerStore.sourceOptionsPrimaryLoading
               || providerStore.sourceOptionsPrimaryLoading
@@ -621,10 +635,12 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
             onRequestClose={() => { this.handleCloseMigrationModal() }}
             onRequestClose={() => { this.handleCloseMigrationModal() }}
           >
           >
             <ReplicaMigrationOptions
             <ReplicaMigrationOptions
+              transferItem={this.replica}
+              minionPools={minionPoolStore.minionPools}
               loadingInstances={instanceStore.loadingInstancesDetails}
               loadingInstances={instanceStore.loadingInstancesDetails}
               instances={instanceStore.instancesDetails}
               instances={instanceStore.instancesDetails}
               onCancelClick={() => { this.handleCloseMigrationModal() }}
               onCancelClick={() => { this.handleCloseMigrationModal() }}
-              onMigrateClick={(o, s) => { this.migrateReplica(o, s) }}
+              onMigrateClick={(o, s, m) => { this.migrateReplica(o, s, m) }}
             />
             />
           </Modal>
           </Modal>
         ) : null}
         ) : null}

+ 3 - 0
src/components/pages/ReplicasPage/ReplicasPage.tsx

@@ -161,6 +161,7 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
         fields,
         fields,
         uploadedScripts.filter(s => !s.instanceName
         uploadedScripts.filter(s => !s.instanceName
           || replica.instances.find(i => i === s.instanceName)),
           || replica.instances.find(i => i === s.instanceName)),
+        replica.instance_osmorphing_minion_pool_mappings || {},
       )))
       )))
     notificationStore.alert('Migrations successfully created from replicas.', 'success')
     notificationStore.alert('Migrations successfully created from replicas.', 'success')
     this.props.history.push('/migrations')
     this.props.history.push('/migrations')
@@ -418,6 +419,8 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
             }}
             }}
           >
           >
             <ReplicaMigrationOptions
             <ReplicaMigrationOptions
+              transferItem={null}
+              minionPools={[]}
               instances={instanceStore.instancesDetails}
               instances={instanceStore.instancesDetails}
               loadingInstances={instanceStore.loadingInstancesDetails}
               loadingInstances={instanceStore.loadingInstancesDetails}
               onCancelClick={() => {
               onCancelClick={() => {

+ 14 - 7
src/components/pages/WizardPage/WizardPage.tsx

@@ -46,6 +46,7 @@ import type { WizardPage as WizardPageType } from '../../../@types/WizardData'
 import ObjectUtils from '../../../utils/ObjectUtils'
 import ObjectUtils from '../../../utils/ObjectUtils'
 import { ProviderTypes } from '../../../@types/Providers'
 import { ProviderTypes } from '../../../@types/Providers'
 import { TransferItem, ReplicaItem } from '../../../@types/MainItem'
 import { TransferItem, ReplicaItem } from '../../../@types/MainItem'
+import minionPoolStore from '../../../stores/MinionPoolStore'
 
 
 const Wrapper = styled.div<any>``
 const Wrapper = styled.div<any>``
 
 
@@ -229,6 +230,7 @@ class WizardPage extends React.Component<Props, State> {
     if (!source) {
     if (!source) {
       return
       return
     }
     }
+
     await providerStore.loadOptionsSchema({
     await providerStore.loadOptionsSchema({
       providerName: source.type,
       providerName: source.type,
       optionsType: 'source',
       optionsType: 'source',
@@ -315,10 +317,10 @@ class WizardPage extends React.Component<Props, State> {
     instanceStore.setPage(page)
     instanceStore.setPage(page)
   }
   }
 
 
-  handleDestOptionsChange(field: Field, value: any) {
+  handleDestOptionsChange(field: Field, value: any, parentFieldName?: string) {
     wizardStore.updateData({ networks: null })
     wizardStore.updateData({ networks: null })
     wizardStore.clearStorageMap()
     wizardStore.clearStorageMap()
-    wizardStore.updateDestOptions({ field, value })
+    wizardStore.updateDestOptions({ field, value, parentFieldName })
     // If the field is a string and doesn't have an enum property,
     // 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,
     // we can't call destination options on "change" since too many calls will be made,
     // it also means a potential problem with the server not populating the "enum" prop.
     // it also means a potential problem with the server not populating the "enum" prop.
@@ -331,9 +333,9 @@ class WizardPage extends React.Component<Props, State> {
     wizardStore.updateUrlState()
     wizardStore.updateUrlState()
   }
   }
 
 
-  handleSourceOptionsChange(field: Field, value: any) {
+  handleSourceOptionsChange(field: Field, value: any, parentFieldName?: string) {
     wizardStore.updateData({ selectedInstances: [] })
     wizardStore.updateData({ selectedInstances: [] })
-    wizardStore.updateSourceOptions({ field, value })
+    wizardStore.updateSourceOptions({ field, value, parentFieldName })
     if (field.type !== 'string' || field.enum) {
     if (field.type !== 'string' || field.enum) {
       this.loadExtraOptions(field, 'source')
       this.loadExtraOptions(field, 'source')
     }
     }
@@ -378,6 +380,7 @@ class WizardPage extends React.Component<Props, State> {
     if (!endpoint) {
     if (!endpoint) {
       return
       return
     }
     }
+    minionPoolStore.loadMinionPools()
     await providerStore.loadOptionsSchema({
     await providerStore.loadOptionsSchema({
       providerName: endpoint.type,
       providerName: endpoint.type,
       optionsType,
       optionsType,
@@ -452,6 +455,7 @@ class WizardPage extends React.Component<Props, State> {
 
 
     switch (page.id) {
     switch (page.id) {
       case 'source': {
       case 'source': {
+        minionPoolStore.loadMinionPools()
         providerStore.loadProviders()
         providerStore.loadProviders()
         endpointStore.getEndpoints()
         endpointStore.getEndpoints()
         // Preload instances if data is set from 'Permalink'
         // Preload instances if data is set from 'Permalink'
@@ -647,6 +651,7 @@ class WizardPage extends React.Component<Props, State> {
               instanceStore={instanceStore}
               instanceStore={instanceStore}
               networkStore={networkStore}
               networkStore={networkStore}
               endpointStore={endpointStore}
               endpointStore={endpointStore}
+              minionPoolStore={minionPoolStore}
               wizardData={wizardStore.data}
               wizardData={wizardStore.data}
               hasStorageMap={Boolean(this.pages.find(p => p.id === 'storage'))}
               hasStorageMap={Boolean(this.pages.find(p => p.id === 'storage'))}
               hasSourceOptions={Boolean(this.pages.find(p => p.id === 'source-options'))}
               hasSourceOptions={Boolean(this.pages.find(p => p.id === 'source-options'))}
@@ -667,9 +672,11 @@ class WizardPage extends React.Component<Props, State> {
               onInstancesReloadClick={() => { this.handleInstancesReloadClick() }}
               onInstancesReloadClick={() => { this.handleInstancesReloadClick() }}
               onInstanceClick={instance => { this.handleInstanceClick(instance) }}
               onInstanceClick={instance => { this.handleInstanceClick(instance) }}
               onInstancePageClick={page => { this.handleInstancePageClick(page) }}
               onInstancePageClick={page => { this.handleInstancePageClick(page) }}
-              onDestOptionsChange={(field, value) => { this.handleDestOptionsChange(field, value) }}
-              onSourceOptionsChange={(field, value) => {
-                this.handleSourceOptionsChange(field, value)
+              onDestOptionsChange={(field, value, parent) => {
+                this.handleDestOptionsChange(field, value, parent)
+              }}
+              onSourceOptionsChange={(field, value, parent) => {
+                this.handleSourceOptionsChange(field, value, parent)
               }}
               }}
               onNetworkChange={(sourceNic, targetNetwork, secGroups) => {
               onNetworkChange={(sourceNic, targetNetwork, secGroups) => {
                 this.handleNetworkChange(sourceNic, targetNetwork, secGroups)
                 this.handleNetworkChange(sourceNic, targetNetwork, secGroups)

+ 3 - 0
src/constants.ts

@@ -22,6 +22,7 @@ export const navigationMenu: NavigationMenuType[] = [
   { label: 'Replicas', value: 'replicas' },
   { label: 'Replicas', value: 'replicas' },
   { label: 'Migrations', value: 'migrations' },
   { label: 'Migrations', value: 'migrations' },
   { label: 'Cloud Endpoints', value: 'endpoints' },
   { label: 'Cloud Endpoints', value: 'endpoints' },
+  { label: 'Minion Pools', value: 'minion-pools' },
 
 
   // Optional pages
   // Optional pages
   { label: 'Planning', value: 'planning' },
   { label: 'Planning', value: 'planning' },
@@ -42,6 +43,8 @@ export const providerTypes = {
   STORAGE: 32768,
   STORAGE: 32768,
   SOURCE_UPDATE: 65536,
   SOURCE_UPDATE: 65536,
   TARGET_UPDATE: 262144,
   TARGET_UPDATE: 262144,
+  SOURCE_MINION_POOL: 524288,
+  DESTINATION_MINION_POOL: 1048576,
 }
 }
 
 
 export const loginButtons = [
 export const loginButtons = [

+ 3 - 4
src/plugins/endpoint/default/ConnectionSchemaPlugin.ts

@@ -21,7 +21,6 @@ import { Endpoint } from '../../../@types/Endpoint'
 export const defaultSchemaToFields = (
 export const defaultSchemaToFields = (
   schema: SchemaProperties,
   schema: SchemaProperties,
   schemaDefinitions?: SchemaDefinitions | null,
   schemaDefinitions?: SchemaDefinitions | null,
-  parent?: string | null,
   dictionaryKey?: string,
   dictionaryKey?: string,
 ): any[] => {
 ): any[] => {
   if (!schema.properties) {
   if (!schema.properties) {
@@ -38,17 +37,17 @@ export const defaultSchemaToFields = (
         name: fieldName,
         name: fieldName,
         type: properties.type ? properties.type : '',
         type: properties.type ? properties.type : '',
         properties: properties.properties
         properties: properties.properties
-          ? defaultSchemaToFields(properties, null, fieldName, dictionaryKey) : [],
+          ? defaultSchemaToFields(properties, null, dictionaryKey) : [],
       }
       }
     } if (properties.type === 'object' && properties.properties && Object.keys(properties.properties).length) {
     } if (properties.type === 'object' && properties.properties && Object.keys(properties.properties).length) {
       return {
       return {
         name: fieldName,
         name: fieldName,
         type: 'object',
         type: 'object',
-        properties: defaultSchemaToFields(properties, null, fieldName, dictionaryKey),
+        properties: defaultSchemaToFields(properties, null, dictionaryKey),
       }
       }
     }
     }
 
 
-    const name = parent ? `${parent}/${fieldName}` : fieldName
+    const name = fieldName
     LabelDictionary.pushToCache({ name, title: properties.title, description: properties.description }, dictionaryKey || '')
     LabelDictionary.pushToCache({ name, title: properties.title, description: properties.description }, dictionaryKey || '')
 
 
     return {
     return {

+ 42 - 20
src/plugins/endpoint/default/OptionsSchemaPlugin.ts

@@ -43,7 +43,10 @@ export const defaultFillFieldValues = (field: Field, option: OptionValues) => {
 }
 }
 
 
 export const defaultFillMigrationImageMapValues = (
 export const defaultFillMigrationImageMapValues = (
-  field: Field, option: OptionValues, migrationImageMapFieldName: string,
+  field: Field,
+  option: OptionValues,
+  migrationImageMapFieldName: string,
+  imageSuffix: string,
 ): boolean => {
 ): boolean => {
   if (field.name !== migrationImageMapFieldName) {
   if (field.name !== migrationImageMapFieldName) {
     return false
     return false
@@ -65,7 +68,7 @@ export const defaultFillMigrationImageMapValues = (
     }
     }
 
 
     return {
     return {
-      name: `${os}_os_image`,
+      name: `${os}${imageSuffix}`,
       type: 'string',
       type: 'string',
       enum: values,
       enum: values,
     }
     }
@@ -74,13 +77,15 @@ export const defaultFillMigrationImageMapValues = (
 }
 }
 
 
 export const defaultGetDestinationEnv = (
 export const defaultGetDestinationEnv = (
-  options?: { [prop: string]: any } | null, oldOptions?: { [prop: string]: any } | null,
+  options?: { [prop: string]: any } | null,
+  oldOptions?: { [prop: string]: any } | null,
+  imageSuffix?: string,
 ): any => {
 ): any => {
   const env: any = {}
   const env: any = {}
   const specialOptions = ['execute_now', 'separate_vm', 'skip_os_morphing', 'description']
   const specialOptions = ['execute_now', 'separate_vm', 'skip_os_morphing', 'description']
     .concat(migrationFields.map(f => f.name))
     .concat(migrationFields.map(f => f.name))
     .concat(executionOptions.map(o => o.name))
     .concat(executionOptions.map(o => o.name))
-    .concat(migrationImageOsTypes.map(o => `${o}_os_image`))
+    .concat(migrationImageOsTypes.map(o => `${o}${imageSuffix}`))
 
 
   if (!options) {
   if (!options) {
     return env
     return env
@@ -90,13 +95,12 @@ export const defaultGetDestinationEnv = (
     if (specialOptions.find(o => o === optionName) || !options || options[optionName] == null || options[optionName] === '') {
     if (specialOptions.find(o => o === optionName) || !options || options[optionName] == null || options[optionName] === '') {
       return
       return
     }
     }
-    if (optionName.indexOf('/') > 0) {
-      const parentName = optionName.substr(0, optionName.lastIndexOf('/'))
-      if (!env[parentName]) {
-        env[parentName] = oldOptions ? oldOptions[parentName] || {} : {}
+    if (typeof options[optionName] === 'object') {
+      const oldOption = oldOptions?.[optionName] || {}
+      env[optionName] = {
+        ...oldOption,
+        ...options[optionName],
       }
       }
-      env[parentName][optionName.substr(optionName.lastIndexOf('/') + 1)] = options
-        ? Utils.trim(optionName, options[optionName]) : null
     } else {
     } else {
       env[optionName] = options ? Utils.trim(optionName, options[optionName]) : null
       env[optionName] = options ? Utils.trim(optionName, options[optionName]) : null
     }
     }
@@ -106,26 +110,28 @@ export const defaultGetDestinationEnv = (
 
 
 export const defaultGetMigrationImageMap = (
 export const defaultGetMigrationImageMap = (
   options: { [prop: string]: any } | null | undefined,
   options: { [prop: string]: any } | null | undefined,
-  oldOptions: any, migrationImageMapFieldName: string,
+  oldOptions: any,
+  migrationImageMapFieldName: string,
+  imageSuffix: string,
 ) => {
 ) => {
   const env: any = {}
   const env: any = {}
   const usableOptions = options
   const usableOptions = options
   if (!usableOptions) {
   if (!usableOptions) {
     return env
     return env
   }
   }
-  const hasMigrationMap = Object.keys(usableOptions).find(k => migrationImageOsTypes.find(os => `${os}_os_image` === k))
+
+  const hasMigrationMap = Object.keys(usableOptions).find(k => k === migrationImageMapFieldName)
   if (!hasMigrationMap) {
   if (!hasMigrationMap) {
     return env
     return env
   }
   }
   migrationImageOsTypes.forEach(os => {
   migrationImageOsTypes.forEach(os => {
-    let value = usableOptions[`${os}_os_image`]
+    let value = usableOptions[migrationImageMapFieldName][`${os}${imageSuffix}`]
 
 
     // Make sure the migr. image mapping has all the OSes filled,
     // Make sure the migr. image mapping has all the OSes filled,
     // even if only one OS mapping was updated,
     // even if only one OS mapping was updated,
     // ie. don't send just the updated OS map to the server, send them all if one was updated.
     // ie. don't send just the updated OS map to the server, send them all if one was updated.
     if (!value) {
     if (!value) {
-      value = oldOptions && oldOptions[migrationImageMapFieldName]
-        && oldOptions[migrationImageMapFieldName][os]
+      value = oldOptions?.[migrationImageMapFieldName]?.[`${os}${imageSuffix}`]
       if (!value) {
       if (!value) {
         return
         return
       }
       }
@@ -135,7 +141,7 @@ export const defaultGetMigrationImageMap = (
       env[migrationImageMapFieldName] = {}
       env[migrationImageMapFieldName] = {}
     }
     }
 
 
-    env[migrationImageMapFieldName][os] = value
+    env[migrationImageMapFieldName][`${os}${imageSuffix}`] = value
   })
   })
 
 
   return env
   return env
@@ -144,10 +150,12 @@ export const defaultGetMigrationImageMap = (
 export default class OptionsSchemaParser {
 export default class OptionsSchemaParser {
   static migrationImageMapFieldName = 'migr_image_map'
   static migrationImageMapFieldName = 'migr_image_map'
 
 
+  static imageSuffix = '_os_image'
+
   static parseSchemaToFields(
   static parseSchemaToFields(
     schema: SchemaProperties, schemaDefinitions?: SchemaDefinitions | null, dictionaryKey?: string,
     schema: SchemaProperties, schemaDefinitions?: SchemaDefinitions | null, dictionaryKey?: string,
   ) {
   ) {
-    return defaultSchemaToFields(schema, schemaDefinitions, null, dictionaryKey)
+    return defaultSchemaToFields(schema, schemaDefinitions, dictionaryKey)
   }
   }
 
 
   static fillFieldValues(field: Field, options: OptionValues[], customFieldName?: string) {
   static fillFieldValues(field: Field, options: OptionValues[], customFieldName?: string) {
@@ -156,15 +164,29 @@ export default class OptionsSchemaParser {
     if (!option) {
     if (!option) {
       return
       return
     }
     }
-    if (!defaultFillMigrationImageMapValues(field, option, this.migrationImageMapFieldName)) {
+    if (!defaultFillMigrationImageMapValues(
+      field,
+      option,
+      this.migrationImageMapFieldName,
+      this.imageSuffix,
+    )) {
       defaultFillFieldValues(field, option)
       defaultFillFieldValues(field, option)
     }
     }
   }
   }
 
 
   static getDestinationEnv(options?: { [prop: string]: any } | null, oldOptions?: any) {
   static getDestinationEnv(options?: { [prop: string]: any } | null, oldOptions?: any) {
     const env = {
     const env = {
-      ...defaultGetDestinationEnv(options, oldOptions),
-      ...defaultGetMigrationImageMap(options, oldOptions, this.migrationImageMapFieldName),
+      ...defaultGetDestinationEnv(
+        options,
+        oldOptions,
+        this.imageSuffix,
+      ),
+      ...defaultGetMigrationImageMap(
+        options,
+        oldOptions,
+        this.migrationImageMapFieldName,
+        this.imageSuffix,
+      ),
     }
     }
     return env
     return env
   }
   }

+ 30 - 3
src/plugins/endpoint/openstack/OptionsSchemaPlugin.ts

@@ -12,7 +12,13 @@ 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/>.
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 */
 
 
-import DefaultOptionsSchemaPlugin from '../default/OptionsSchemaPlugin'
+import DefaultOptionsSchemaPlugin, {
+  defaultGetDestinationEnv,
+  defaultGetMigrationImageMap,
+  defaultFillFieldValues,
+  defaultFillMigrationImageMapValues,
+} from '../default/OptionsSchemaPlugin'
+
 import LabelDictionary from '../../../utils/LabelDictionary'
 import LabelDictionary from '../../../utils/LabelDictionary'
 
 
 import type { InstanceScript } from '../../../@types/Instance'
 import type { InstanceScript } from '../../../@types/Instance'
@@ -24,6 +30,8 @@ import type { NetworkMap } from '../../../@types/Network'
 export default class OptionsSchemaParser {
 export default class OptionsSchemaParser {
   static migrationImageMapFieldName = DefaultOptionsSchemaPlugin.migrationImageMapFieldName
   static migrationImageMapFieldName = DefaultOptionsSchemaPlugin.migrationImageMapFieldName
 
 
+  static imageSuffix = ''
+
   static parseSchemaToFields(
   static parseSchemaToFields(
     schema: SchemaProperties,
     schema: SchemaProperties,
     schemaDefinitions: SchemaDefinitions | null | undefined,
     schemaDefinitions: SchemaDefinitions | null | undefined,
@@ -60,12 +68,31 @@ export default class OptionsSchemaParser {
         }
         }
       })
       })
     } else {
     } else {
-      DefaultOptionsSchemaPlugin.fillFieldValues(field, options)
+      const option = options.find(f => f.name === field.name)
+      if (!option) {
+        return
+      }
+      if (!defaultFillMigrationImageMapValues(
+        field,
+        option,
+        this.migrationImageMapFieldName,
+        this.imageSuffix,
+      )) {
+        defaultFillFieldValues(field, option)
+      }
     }
     }
   }
   }
 
 
   static getDestinationEnv(options: { [prop: string]: any } | null, oldOptions?: any) {
   static getDestinationEnv(options: { [prop: string]: any } | null, oldOptions?: any) {
-    return DefaultOptionsSchemaPlugin.getDestinationEnv(options, oldOptions)
+    const env = {
+      ...defaultGetDestinationEnv(options, oldOptions, this.imageSuffix),
+      ...defaultGetMigrationImageMap(
+        options,
+        oldOptions,
+        this.migrationImageMapFieldName, this.imageSuffix,
+      ),
+    }
+    return env
   }
   }
 
 
   static getNetworkMap(networkMappings: NetworkMap[] | null) {
   static getNetworkMap(networkMappings: NetworkMap[] | null) {

+ 15 - 3
src/plugins/endpoint/ovm/OptionsSchemaPlugin.ts

@@ -28,6 +28,8 @@ import type { NetworkMap } from '../../../@types/Network'
 export default class OptionsSchemaParser {
 export default class OptionsSchemaParser {
   static migrationImageMapFieldName = 'migr_template_map'
   static migrationImageMapFieldName = 'migr_template_map'
 
 
+  static imageSuffix = '_os_image'
+
   static parseSchemaToFields(
   static parseSchemaToFields(
     schema: SchemaProperties,
     schema: SchemaProperties,
     schemaDefinitions: SchemaDefinitions | null | undefined,
     schemaDefinitions: SchemaDefinitions | null | undefined,
@@ -66,15 +68,25 @@ export default class OptionsSchemaParser {
     if (!option) {
     if (!option) {
       return
       return
     }
     }
-    if (!defaultFillMigrationImageMapValues(field, option, this.migrationImageMapFieldName)) {
+    if (!defaultFillMigrationImageMapValues(
+      field,
+      option,
+      this.migrationImageMapFieldName,
+      this.imageSuffix,
+    )) {
       defaultFillFieldValues(field, option)
       defaultFillFieldValues(field, option)
     }
     }
   }
   }
 
 
   static getDestinationEnv(options: { [prop: string]: any } | null, oldOptions?: any) {
   static getDestinationEnv(options: { [prop: string]: any } | null, oldOptions?: any) {
     const env = {
     const env = {
-      ...defaultGetDestinationEnv(options, oldOptions),
-      ...defaultGetMigrationImageMap(options, oldOptions, this.migrationImageMapFieldName),
+      ...defaultGetDestinationEnv(options, oldOptions, this.imageSuffix),
+      ...defaultGetMigrationImageMap(
+        options,
+        oldOptions,
+        this.migrationImageMapFieldName,
+        this.imageSuffix,
+      ),
     }
     }
     return env
     return env
   }
   }

+ 55 - 11
src/sources/MigrationSource.ts

@@ -27,6 +27,7 @@ import type { Endpoint, StorageMap } from '../@types/Endpoint'
 import configLoader from '../utils/Config'
 import configLoader from '../utils/Config'
 import { Task } from '../@types/Task'
 import { Task } from '../@types/Task'
 import { MigrationItem, MigrationItemOptions, MigrationItemDetails } from '../@types/MainItem'
 import { MigrationItem, MigrationItemOptions, MigrationItemDetails } from '../@types/MainItem'
+import { INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS } from '../components/organisms/WizardOptions/WizardOptions'
 
 
 class MigrationSourceUtils {
 class MigrationSourceUtils {
   static sortTaskUpdates(updates: any[]) {
   static sortTaskUpdates(updates: any[]) {
@@ -75,7 +76,8 @@ class MigrationSource {
   async recreateFullCopy(migration: MigrationItemOptions): Promise<MigrationItem> {
   async recreateFullCopy(migration: MigrationItemOptions): Promise<MigrationItem> {
     const {
     const {
       origin_endpoint_id, destination_endpoint_id, destination_environment,
       origin_endpoint_id, destination_endpoint_id, destination_environment,
-      network_map, instances, storage_mappings, notes,
+      network_map, instances, storage_mappings, notes, destination_minion_pool_id,
+      origin_minion_pool_id, instance_osmorphing_minion_pool_mappings,
     } = migration
     } = migration
 
 
     const payload: any = {
     const payload: any = {
@@ -87,6 +89,9 @@ class MigrationSource {
         instances,
         instances,
         storage_mappings,
         storage_mappings,
         notes,
         notes,
+        destination_minion_pool_id,
+        origin_minion_pool_id,
+        instance_osmorphing_minion_pool_mappings,
       },
       },
     }
     }
 
 
@@ -125,6 +130,7 @@ class MigrationSource {
     updatedNetworkMappings: NetworkMap[] | null,
     updatedNetworkMappings: NetworkMap[] | null,
     defaultSkipOsMorphing: boolean | null,
     defaultSkipOsMorphing: boolean | null,
     replicationCount?: number | null,
     replicationCount?: number | null,
+    migration: MigrationItemDetails
   }): Promise<MigrationItemDetails> {
   }): Promise<MigrationItemDetails> {
     const getValue = (fieldName: string): string | null => {
     const getValue = (fieldName: string): string | null => {
       const updatedDestEnv = opts.updatedDestEnv && opts.updatedDestEnv[fieldName]
       const updatedDestEnv = opts.updatedDestEnv && opts.updatedDestEnv[fieldName]
@@ -139,10 +145,6 @@ class MigrationSource {
     payload.migration = {
     payload.migration = {
       origin_endpoint_id: opts.sourceEndpoint.id,
       origin_endpoint_id: opts.sourceEndpoint.id,
       destination_endpoint_id: opts.destEndpoint.id,
       destination_endpoint_id: opts.destEndpoint.id,
-      destination_environment: {
-        ...opts.destEnv,
-        ...destParser.getDestinationEnv(opts.updatedDestEnv),
-      },
       shutdown_instances: Boolean(opts.updatedDestEnv && opts.updatedDestEnv.shutdown_instances),
       shutdown_instances: Boolean(opts.updatedDestEnv && opts.updatedDestEnv.shutdown_instances),
       replication_count: (opts.updatedDestEnv
       replication_count: (opts.updatedDestEnv
         && opts.updatedDestEnv.replication_count) || opts.replicationCount || 2,
         && opts.updatedDestEnv.replication_count) || opts.replicationCount || 2,
@@ -174,12 +176,45 @@ class MigrationSource {
             || opts.defaultStorage, opts.updatedStorageMappings),
             || opts.defaultStorage, opts.updatedStorageMappings),
       }
       }
     }
     }
+    const { migration } = opts
+    const sourceEnv: any = {
+      ...opts.sourceEnv,
+    }
+    const updatedSourceEnv = opts.updatedSourceEnv
+      ? sourceParser.getDestinationEnv(opts.updatedSourceEnv) : {}
+    const sourceMinionPoolId = updatedSourceEnv.minion_pool_id || migration.origin_minion_pool_id
+    if (sourceMinionPoolId) {
+      payload.migration.origin_minion_pool_id = sourceMinionPoolId
+    }
+    delete updatedSourceEnv.minion_pool_id
+    payload.migration.source_environment = {
+      ...sourceEnv,
+      ...updatedSourceEnv,
+    }
 
 
-    if (opts.sourceEnv || opts.updatedSourceEnv) {
-      payload.migration.source_environment = {
-        ...opts.sourceEnv,
-        ...sourceParser.getDestinationEnv(opts.updatedSourceEnv),
-      }
+    const destEnv: any = {
+      ...opts.destEnv,
+    }
+    const updatedDestEnv = opts.updatedDestEnv
+      ? sourceParser.getDestinationEnv(opts.updatedDestEnv) : {}
+    const destMinionPoolId = updatedDestEnv.minion_pool_id || migration.destination_minion_pool_id
+    if (destMinionPoolId) {
+      payload.migration.destination_minion_pool_id = destMinionPoolId
+    }
+    delete updatedDestEnv.minion_pool_id
+
+    const updatedDestEnvMappings = updatedDestEnv[INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS] || {}
+    const oldMappings = migration[INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS] || {}
+    const newMappings = { ...oldMappings, ...updatedDestEnvMappings }
+    if (Object.keys(newMappings).length) {
+      payload.migration[INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS] = newMappings
+    }
+
+    delete updatedDestEnv[INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS]
+
+    payload.migration.destination_environment = {
+      ...destEnv,
+      ...updatedDestEnv,
     }
     }
 
 
     const response = await Api.send({
     const response = await Api.send({
@@ -212,7 +247,10 @@ class MigrationSource {
   }
   }
 
 
   async migrateReplica(
   async migrateReplica(
-    replicaId: string, options: Field[], userScripts: InstanceScript[],
+    replicaId: string,
+    options: Field[],
+    userScripts: InstanceScript[],
+    minionPoolMappings: { [instance: string]: string },
   ): Promise<MigrationItem> {
   ): Promise<MigrationItem> {
     const payload: any = {
     const payload: any = {
       migration: {
       migration: {
@@ -227,6 +265,12 @@ class MigrationSource {
       payload.migration.user_scripts = DefaultOptionsSchemaPlugin.getUserScripts(userScripts)
       payload.migration.user_scripts = DefaultOptionsSchemaPlugin.getUserScripts(userScripts)
     }
     }
 
 
+    if (Object.keys(minionPoolMappings).length) {
+      payload.migration[INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS] = minionPoolMappings
+    } else {
+      payload.migration[INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS] = null
+    }
+
     const response = await Api.send({
     const response = await Api.send({
       url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/migrations`,
       url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/migrations`,
       method: 'POST',
       method: 'POST',

+ 242 - 0
src/sources/MinionPoolSource.ts

@@ -0,0 +1,242 @@
+/*
+Copyright (C) 2020 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/>.
+*/
+
+import moment from 'moment'
+
+import Api from '../utils/ApiCaller'
+
+import configLoader from '../utils/Config'
+import { MinionPool, MinionPoolDetails } from '../@types/MinionPool'
+import { ProviderTypes } from '../@types/Providers'
+import { Field } from '../@types/Field'
+import { providerTypes } from '../constants'
+import { SchemaParser } from './Schemas'
+import { OptionValues } from '../@types/Endpoint'
+import { MinionPoolAction } from '../stores/MinionPoolStore'
+import { Execution, ExecutionTasks } from '../@types/Execution'
+import { sortTasks } from './ReplicaSource'
+import { ProgressUpdate } from '../@types/Task'
+
+const transformFieldsToPayload = (schema: Field[], data: any) => {
+  const payload: any = {}
+  schema.forEach(field => {
+    if (data[field.name] === null || data[field.name] === undefined || data[field.name] === '') {
+      if (field.default !== null) {
+        payload[field.name] = field.default
+      }
+    } else {
+      payload[field.name] = data[field.name]
+    }
+  })
+  return payload
+}
+
+class MinionPoolSource {
+  getMinionPoolDefaultSchema(): Field[] {
+    return [
+      {
+        name: 'endpoint_id',
+        label: 'Endpoint',
+        type: 'string',
+      },
+      {
+        name: 'pool_platform',
+        type: 'string',
+      },
+      {
+        name: 'pool_name',
+        type: 'string',
+        required: true,
+      },
+      {
+        name: 'pool_os_type',
+        type: 'string',
+        required: true,
+        enum: [
+          {
+            value: 'linux',
+            label: 'Linux',
+          },
+          {
+            value: 'windows',
+            label: 'Windows',
+          },
+        ],
+      },
+      {
+        name: 'minimum_minions',
+        type: 'integer',
+        minimum: 1,
+        default: 1,
+      },
+      {
+        name: 'notes',
+        type: 'string',
+      },
+    ]
+  }
+
+  async loadMinionPools(options?: { skipLog?: boolean }): Promise<MinionPool[]> {
+    const response = await Api.send({
+      url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/minion_pools`,
+      skipLog: options?.skipLog,
+    })
+    return response.data.minion_pools
+  }
+
+  async loadEnvOptions(endpointId: string, platform: 'source' | 'destination', useCache?: boolean): Promise<OptionValues[]> {
+    const response = await Api.send({
+      url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/endpoints/${endpointId}/${platform}-minion-pool-options`,
+      cache: useCache,
+    })
+    return response.data[`${platform}_minion_pool_options`]
+  }
+
+  async loadMinionPoolSchema(providerName: ProviderTypes, platform: 'source' | 'destination'): Promise<Field[]> {
+    const providerType = platform === 'source' ? providerTypes.SOURCE_MINION_POOL : providerTypes.DESTINATION_MINION_POOL
+
+    try {
+      const response = await Api.send({
+        url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/providers/${providerName}/schemas/${providerType}`,
+      })
+      const schema = response.data?.schemas?.[`${platform}_minion_pool_environment_schema`]
+      let fields = []
+      if (schema) {
+        fields = SchemaParser.optionsSchemaToFields(providerName, schema, `${providerName}-minion-pool`)
+      }
+      return fields
+    } catch (err) {
+      console.error(err)
+      return []
+    }
+  }
+
+  async add(endpointId: string, data: any, defaultSchema: Field[], envSchema: Field[]) {
+    const payload = {
+      minion_pool: {
+        ...transformFieldsToPayload(defaultSchema, data),
+        endpoint_id: endpointId,
+        environment_options: {
+          ...transformFieldsToPayload(envSchema, data),
+        },
+      },
+    }
+    const response = await Api.send({
+      url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/minion_pools`,
+      method: 'POST',
+      data: payload,
+    })
+    return response.data.minion_pool
+  }
+
+  async update(data: any, defaultSchema: Field[], envSchema: Field[]) {
+    const payload = {
+      minion_pool: {
+        ...transformFieldsToPayload(defaultSchema, data),
+        environment_options: {
+          ...transformFieldsToPayload(envSchema, data),
+        },
+      },
+    }
+    const response = await Api.send({
+      url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/minion_pools/${data.id}`,
+      method: 'PUT',
+      data: payload,
+    })
+    return response.data.minion_pool
+  }
+
+  async runAction(minionPoolId: string, minionPoolAction: MinionPoolAction): Promise<Execution> {
+    const payload: any = {}
+    payload[minionPoolAction] = null
+
+    const response = await Api.send({
+      url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/minion_pools/${minionPoolId}/actions`,
+      method: 'POST',
+      data: payload,
+    })
+    return response.data.execution
+  }
+
+  async getMinionPoolDetails(
+    minionPoolId: string,
+    options?: { skipLog?: boolean },
+  ): Promise<MinionPoolDetails> {
+    const response = await Api.send({
+      url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/minion_pools/${minionPoolId}`,
+      skipLog: options?.skipLog,
+    })
+    const minionPool: MinionPoolDetails = response.data.minion_pool
+    minionPool.executions.sort((a, b) => a.number - b.number)
+    return minionPool
+  }
+
+  async cancelExecution(minionPoolId: string, force?: boolean, executionId?: string) {
+    let usableExecutionId = executionId
+    if (!usableExecutionId) {
+      const details = await this.getMinionPoolDetails(minionPoolId)
+      const lastExecution = details.executions[details.executions.length - 1]
+
+      if (!lastExecution) {
+        return null
+      }
+      usableExecutionId = lastExecution.id
+    }
+
+    const payload: any = { cancel: { force: force || false } }
+
+    await Api.send({
+      url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/minion_pools/${minionPoolId}/executions/${usableExecutionId}/actions`,
+      method: 'POST',
+      data: payload,
+    })
+    return null
+  }
+
+  async deleteMinionPool(minionPoolId: string) {
+    const response = await Api.send({
+      url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/minion_pools/${minionPoolId}`,
+      method: 'DELETE',
+    })
+    return response.data.execution
+  }
+
+  async getExecutionTasks(options: {
+    minionPoolId: string,
+    executionId?: string,
+    skipLog?: boolean,
+  }): Promise<ExecutionTasks> {
+    const {
+      minionPoolId, executionId, skipLog,
+    } = options
+
+    const response = await Api.send({
+      url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/minion_pools/${minionPoolId}/executions/${executionId}`,
+      skipLog,
+      quietError: true,
+    })
+    const execution: ExecutionTasks = response.data.execution
+    const sortTaskUpdates = (updates: ProgressUpdate[]) => {
+      if (!updates) {
+        return
+      }
+      updates.sort((a, b) => moment(a.created_at)
+        .toDate().getTime() - moment(b.created_at).toDate().getTime())
+    }
+    sortTasks(execution.tasks, sortTaskUpdates)
+    return execution
+  }
+}
+
+export default new MinionPoolSource()

+ 47 - 11
src/sources/ReplicaSource.ts

@@ -23,6 +23,7 @@ import type { Execution, ExecutionTasks } from '../@types/Execution'
 import type { Endpoint } from '../@types/Endpoint'
 import type { Endpoint } from '../@types/Endpoint'
 import type { Task, ProgressUpdate } from '../@types/Task'
 import type { Task, ProgressUpdate } from '../@types/Task'
 import type { Field } from '../@types/Field'
 import type { Field } from '../@types/Field'
+import { INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS } from '../components/organisms/WizardOptions/WizardOptions'
 
 
 export const sortTasks = (
 export const sortTasks = (
   tasks?: Task[], taskUpdatesSortFunction?: (updates: ProgressUpdate[]) => void,
   tasks?: Task[], taskUpdatesSortFunction?: (updates: ProgressUpdate[]) => void,
@@ -65,7 +66,7 @@ export const sortTasks = (
   tasks.splice(0, tasks.length, ...sortedTasks)
   tasks.splice(0, tasks.length, ...sortedTasks)
 }
 }
 
 
-class ReplicaSourceUtils {
+export class ReplicaSourceUtils {
   static filterDeletedExecutions(executions?: Execution[]) {
   static filterDeletedExecutions(executions?: Execution[]) {
     if (!executions || !executions.length) {
     if (!executions || !executions.length) {
       return []
       return []
@@ -75,7 +76,12 @@ class ReplicaSourceUtils {
   }
   }
 
 
   static sortReplicas(replicas: ReplicaItem[]) {
   static sortReplicas(replicas: ReplicaItem[]) {
-    replicas.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
+    replicas
+      .sort(
+        (a, b) => new Date(b.updated_at || b.created_at).getTime()
+          - new Date(a.updated_at || a.created_at)
+            .getTime(),
+      )
   }
   }
 
 
   static sortExecutions(executions: Execution[]) {
   static sortExecutions(executions: Execution[]) {
@@ -131,6 +137,7 @@ class ReplicaSource {
     const response = await Api.send({
     const response = await Api.send({
       url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/replicas/${replicaId}/executions/${executionId}`,
       url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/replicas/${replicaId}/executions/${executionId}`,
       skipLog: polling,
       skipLog: polling,
+      quietError: true,
     })
     })
     const execution: ExecutionTasks = response.data.execution
     const execution: ExecutionTasks = response.data.execution
     sortTasks(execution.tasks, ReplicaSourceUtils.sortTaskUpdates)
     sortTasks(execution.tasks, ReplicaSourceUtils.sortTaskUpdates)
@@ -206,28 +213,57 @@ class ReplicaSource {
     return response.data.execution
     return response.data.execution
   }
   }
 
 
-  async update(
-    replica: ReplicaItem,
+  async update(options: {
+    replica: ReplicaItemDetails,
     destinationEndpoint: Endpoint,
     destinationEndpoint: Endpoint,
     updateData: UpdateData,
     updateData: UpdateData,
     defaultStorage: string | null | undefined,
     defaultStorage: string | null | undefined,
     storageConfigDefault: string,
     storageConfigDefault: string,
-  ): Promise<Execution> {
+  }): Promise<Execution> {
+    const {
+      replica, destinationEndpoint, updateData, defaultStorage, storageConfigDefault,
+    } = options
+
     const parser = OptionsSchemaPlugin.for(destinationEndpoint.type)
     const parser = OptionsSchemaPlugin.for(destinationEndpoint.type)
     const payload: any = { replica: {} }
     const payload: any = { replica: {} }
 
 
     if (updateData.network.length > 0) {
     if (updateData.network.length > 0) {
       payload.replica.network_map = parser.getNetworkMap(updateData.network)
       payload.replica.network_map = parser.getNetworkMap(updateData.network)
     }
     }
+    if (Object.keys(updateData.source).length > 0) {
+      const sourceEnv = parser.getDestinationEnv(updateData.source, replica.source_environment)
+      if (sourceEnv.minion_pool_id) {
+        payload.replica.origin_minion_pool_id = sourceEnv.minion_pool_id
+        delete sourceEnv.minion_pool_id
+      }
+      if (Object.keys(sourceEnv).length) {
+        payload.replica.source_environment = sourceEnv
+      }
+    }
 
 
     if (Object.keys(updateData.destination).length > 0) {
     if (Object.keys(updateData.destination).length > 0) {
-      payload.replica.destination_environment = parser
-        .getDestinationEnv(updateData.destination, replica.destination_environment)
-    }
+      const destEnv = parser.getDestinationEnv(updateData.destination,
+        replica.destination_environment)
 
 
-    if (Object.keys(updateData.source).length > 0) {
-      payload.replica.source_environment = parser
-        .getDestinationEnv(updateData.source, replica.source_environment)
+      const newMinionMappings = destEnv[INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS]
+      if (newMinionMappings) {
+        const oldMinionMappings = replica[INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS] || {}
+        payload.replica[
+          INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS
+        ] = {
+          ...oldMinionMappings,
+          ...newMinionMappings,
+        }
+      }
+      delete destEnv[INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS]
+
+      if (destEnv.minion_pool_id) {
+        payload.replica.destination_minion_pool_id = destEnv.minion_pool_id
+        delete destEnv.minion_pool_id
+      }
+      if (Object.keys(destEnv).length) {
+        payload.replica.destination_environment = destEnv
+      }
     }
     }
 
 
     if (defaultStorage || updateData.storage.length > 0) {
     if (defaultStorage || updateData.storage.length > 0) {

+ 36 - 2
src/sources/WizardSource.ts

@@ -23,6 +23,7 @@ import type { StorageMap } from '../@types/Endpoint'
 import type { InstanceScript } from '../@types/Instance'
 import type { InstanceScript } from '../@types/Instance'
 import DefaultOptionsSchemaParser from '../plugins/endpoint/default/OptionsSchemaPlugin'
 import DefaultOptionsSchemaParser from '../plugins/endpoint/default/OptionsSchemaPlugin'
 import { TransferItem } from '../@types/MainItem'
 import { TransferItem } from '../@types/MainItem'
+import { INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS } from '../components/organisms/WizardOptions/WizardOptions'
 
 
 class WizardSource {
 class WizardSource {
   async create(
   async create(
@@ -40,7 +41,6 @@ class WizardSource {
     payload[type] = {
     payload[type] = {
       origin_endpoint_id: data.source ? data.source.id : 'null',
       origin_endpoint_id: data.source ? data.source.id : 'null',
       destination_endpoint_id: data.target ? data.target.id : 'null',
       destination_endpoint_id: data.target ? data.target.id : 'null',
-      destination_environment: destParser.getDestinationEnv(data.destOptions),
       network_map: destParser.getNetworkMap(data.networks),
       network_map: destParser.getNetworkMap(data.networks),
       instances: data.selectedInstances ? data.selectedInstances.map(i => i.instance_name || i.name) : 'null',
       instances: data.selectedInstances ? data.selectedInstances.map(i => i.instance_name || i.name) : 'null',
       storage_mappings: destParser.getStorageMap(defaultStorage, storageMap),
       storage_mappings: destParser.getStorageMap(defaultStorage, storageMap),
@@ -52,9 +52,43 @@ class WizardSource {
     }
     }
 
 
     if (data.sourceOptions) {
     if (data.sourceOptions) {
-      payload[type].source_environment = sourceParser.getDestinationEnv(data.sourceOptions)
+      const sourceEnv = sourceParser.getDestinationEnv(data.sourceOptions)
+      if (sourceEnv.minion_pool_id) {
+        payload[type].origin_minion_pool_id = sourceEnv.minion_pool_id
+        delete sourceEnv.minion_pool_id
+      }
+      payload[type].source_environment = sourceEnv
+    }
+
+    const destEnv = destParser.getDestinationEnv(data.destOptions)
+    if (destEnv.minion_pool_id) {
+      payload[type].destination_minion_pool_id = destEnv.minion_pool_id
+      delete destEnv.minion_pool_id
     }
     }
 
 
+    const poolMappings = destEnv[INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS]
+    if (poolMappings) {
+      Object.keys(poolMappings).forEach(instanceName => {
+        if (poolMappings[instanceName]
+          && payload[type].instances.find((i: string) => i === instanceName)) {
+          if (!payload[type][
+            INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS
+          ]) {
+            payload[type][
+              INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS
+            ] = {}
+          }
+          payload[type][
+            INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS
+          ][instanceName] = poolMappings[instanceName]
+        }
+      })
+    }
+
+    delete destEnv[INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS]
+
+    payload[type].destination_environment = destEnv
+
     if (type === 'migration') {
     if (type === 'migration') {
       payload[type].shutdown_instances = Boolean(
       payload[type].shutdown_instances = Boolean(
         data.destOptions && data.destOptions.shutdown_instances,
         data.destOptions && data.destOptions.shutdown_instances,

+ 13 - 2
src/stores/MigrationStore.ts

@@ -75,6 +75,7 @@ class MigrationStore {
     const migrationResult = await MigrationSource.recreate({
     const migrationResult = await MigrationSource.recreate({
       sourceEndpoint,
       sourceEndpoint,
       destEndpoint,
       destEndpoint,
+      migration,
       instanceNames: migration.instances,
       instanceNames: migration.instances,
       sourceEnv: migration.source_environment,
       sourceEnv: migration.source_environment,
       updatedSourceEnv: updateData.source,
       updatedSourceEnv: updateData.source,
@@ -119,8 +120,18 @@ class MigrationStore {
     runInAction(() => { this.migrations = this.migrations.filter(r => r.id !== migrationId) })
     runInAction(() => { this.migrations = this.migrations.filter(r => r.id !== migrationId) })
   }
   }
 
 
-  @action async migrateReplica(replicaId: string, options: Field[], userScripts: InstanceScript[]) {
-    const migration = await MigrationSource.migrateReplica(replicaId, options, userScripts)
+  @action async migrateReplica(
+    replicaId: string,
+    options: Field[],
+    userScripts: InstanceScript[],
+    minionPoolMappings: { [instance: string]: string },
+  ) {
+    const migration = await MigrationSource.migrateReplica(
+      replicaId,
+      options,
+      userScripts,
+      minionPoolMappings,
+    )
     runInAction(() => {
     runInAction(() => {
       this.migrations = [
       this.migrations = [
         migration,
         migration,

+ 231 - 0
src/stores/MinionPoolStore.ts

@@ -0,0 +1,231 @@
+/*
+Copyright (C) 2020  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/>.
+*/
+
+import {
+  action, observable, runInAction, computed,
+} from 'mobx'
+import { MinionPool, MinionPoolDetails } from '../@types/MinionPool'
+import MinionPoolSource from '../sources/MinionPoolSource'
+import { Field } from '../@types/Field'
+import { ProviderTypes } from '../@types/Providers'
+import { OptionsSchemaPlugin } from '../plugins/endpoint'
+import { ExecutionTasks } from '../@types/Execution'
+
+export type MinionPoolAction = 'set-up-shared-resources' | 'allocate-machines' | 'deallocate-machines' | 'tear-down-shared-resources'
+
+class MinionPoolStore {
+  @observable
+  loadingMinionPools: boolean = false
+
+  @observable
+  minionPools: MinionPool[] = []
+
+  @observable
+  loadingMinionPoolSchema: boolean = false
+
+  @observable
+  minionPoolDefaultSchema: Field[] = []
+
+  @observable
+  minionPoolEnvSchema: Field[] = []
+
+  @observable
+  loadingMinionPoolDetails: boolean = false
+
+  @observable
+  minionPoolDetails: MinionPoolDetails | null = null
+
+  @observable
+  loadingEnvOptions: boolean = false
+
+  @observable
+  executionsTasks: ExecutionTasks[] = []
+
+  @observable
+  loadingExecutionsTasks: boolean = false
+
+  @computed
+  get minionPoolCombinedSchema() {
+    return this.minionPoolDefaultSchema.concat(this.minionPoolEnvSchema)
+  }
+
+  @action
+  async loadMinionPools(options?: { showLoading?: boolean, skipLog?: boolean }) {
+    if (options?.showLoading) {
+      this.loadingMinionPools = true
+    }
+
+    try {
+      const minionPools = await MinionPoolSource.loadMinionPools({ skipLog: options?.skipLog })
+
+      runInAction(() => {
+        this.minionPools = minionPools
+      })
+    } finally {
+      runInAction(() => {
+        this.loadingMinionPools = false
+      })
+    }
+  }
+
+  @action
+  async loadMinionPoolDetails(id: string, options?: { showLoading?: boolean, skipLog?: boolean }) {
+    if (options?.showLoading) {
+      this.loadingMinionPoolDetails = true
+    }
+    try {
+      const minionPoolDetails = await MinionPoolSource.getMinionPoolDetails(
+        id,
+        { skipLog: options?.skipLog },
+      )
+
+      runInAction(() => {
+        this.minionPoolDetails = minionPoolDetails
+      })
+    } finally {
+      runInAction(() => {
+        this.loadingMinionPoolDetails = false
+      })
+    }
+  }
+
+  @action
+  clearMinionPoolDetails() {
+    this.minionPoolDetails = null
+  }
+
+  @action
+  async loadMinionPoolSchema(provider: ProviderTypes, platform: 'source' | 'destination') {
+    this.loadingMinionPoolSchema = true
+
+    this.minionPoolDefaultSchema = MinionPoolSource.getMinionPoolDefaultSchema()
+
+    try {
+      const schema = await MinionPoolSource.loadMinionPoolSchema(provider, platform)
+
+      runInAction(() => {
+        this.minionPoolEnvSchema = schema
+      })
+    } finally {
+      runInAction(() => {
+        this.loadingMinionPoolSchema = false
+      })
+    }
+  }
+
+  @action
+  async loadEnvOptions(
+    endpointId: string,
+    providerName: ProviderTypes,
+    platform: 'source' | 'destination',
+    opts?: { useCache?: boolean },
+  ) {
+    this.loadingEnvOptions = true
+
+    try {
+      const options = await MinionPoolSource.loadEnvOptions(endpointId, platform, opts?.useCache)
+
+      runInAction(() => {
+        this.minionPoolEnvSchema.forEach(field => {
+          const parser = OptionsSchemaPlugin.for(providerName)
+          parser.fillFieldValues(field, options)
+        })
+      })
+    } finally {
+      runInAction(() => {
+        this.loadingEnvOptions = false
+      })
+    }
+  }
+
+  @action
+  async update(minionPoolData: any) {
+    return MinionPoolSource.update(
+      minionPoolData,
+      this.minionPoolDefaultSchema,
+      this.minionPoolEnvSchema,
+    )
+  }
+
+  @action
+  async add(endpointId: string, minionPoolData: any) {
+    return MinionPoolSource.add(
+      endpointId,
+      minionPoolData,
+      this.minionPoolDefaultSchema,
+      this.minionPoolEnvSchema,
+    )
+  }
+
+  @action
+  async runAction(minionPoolId: string, minionPoolAction: MinionPoolAction) {
+    return MinionPoolSource.runAction(minionPoolId, minionPoolAction)
+  }
+
+  @action
+  async cancelExecution(minionPoolId: string, force?: boolean, executionId?: string) {
+    return MinionPoolSource.cancelExecution(minionPoolId, force, executionId)
+  }
+
+  async deleteMinionPool(minionPoolId: string) {
+    return MinionPoolSource.deleteMinionPool(minionPoolId)
+  }
+
+  private currentlyLoadingExecution: string = ''
+
+  @action
+  async loadExecutionTasks(
+    options: {
+      minionPoolId: string,
+      executionId?: string,
+      skipLog?: boolean,
+    },
+  ) {
+    const {
+      minionPoolId, executionId, skipLog,
+    } = options
+
+    if (!skipLog && this.currentlyLoadingExecution === executionId) {
+      return
+    }
+    this.currentlyLoadingExecution = skipLog ? this.currentlyLoadingExecution : executionId || ''
+    if (!this.currentlyLoadingExecution) {
+      return
+    }
+
+    if (!this.executionsTasks.find(e => e.id === this.currentlyLoadingExecution)) {
+      this.loadingExecutionsTasks = true
+    }
+
+    try {
+      const executionTasks = await MinionPoolSource.getExecutionTasks({
+        minionPoolId, executionId: this.currentlyLoadingExecution, skipLog,
+      })
+      runInAction(() => {
+        this.executionsTasks = [
+          ...this.executionsTasks.filter(e => e.id !== this.currentlyLoadingExecution),
+          executionTasks,
+        ]
+      })
+    } catch (err) {
+      console.error(err)
+    } finally {
+      runInAction(() => {
+        this.loadingExecutionsTasks = false
+      })
+    }
+  }
+}
+
+export default new MinionPoolStore()

+ 6 - 10
src/stores/ReplicaStore.ts

@@ -144,7 +144,9 @@ class ReplicaStore {
 
 
     try {
     try {
       const executionTasks = await ReplicaSource.getExecutionTasks({
       const executionTasks = await ReplicaSource.getExecutionTasks({
-        replicaId, executionId: this.currentlyLoadingExecution, polling,
+        replicaId,
+        executionId: this.currentlyLoadingExecution,
+        polling,
       })
       })
       runInAction(() => {
       runInAction(() => {
         this.executionsTasks = [
         this.executionsTasks = [
@@ -225,20 +227,14 @@ class ReplicaStore {
     }
     }
   }
   }
 
 
-  async update(
+  async update(options: {
     replica: ReplicaItemDetails,
     replica: ReplicaItemDetails,
     destinationEndpoint: Endpoint,
     destinationEndpoint: Endpoint,
     updateData: UpdateData,
     updateData: UpdateData,
     defaultStorage: string | null | undefined,
     defaultStorage: string | null | undefined,
     storageConfigDefault: string,
     storageConfigDefault: string,
-  ) {
-    await ReplicaSource.update(
-      replica,
-      destinationEndpoint,
-      updateData,
-      defaultStorage,
-      storageConfigDefault,
-    )
+  }) {
+    await ReplicaSource.update(options)
   }
   }
 
 
   testReplicaHasDisks(replica: ReplicaItemDetails | null) {
   testReplicaHasDisks(replica: ReplicaItemDetails | null) {

+ 16 - 3
src/stores/WizardStore.ts

@@ -27,7 +27,7 @@ import { TransferItem } from '../@types/MainItem'
 
 
 const updateOptions = (
 const updateOptions = (
   oldOptions: { [prop: string]: any } | null | undefined,
   oldOptions: { [prop: string]: any } | null | undefined,
-  data: { field: Field, value: any },
+  data: { field: Field, value: any, parentFieldName: string | undefined },
 ) => {
 ) => {
   const options: any = oldOptions ? { ...oldOptions } : {}
   const options: any = oldOptions ? { ...oldOptions } : {}
   if (data.field.type === 'array') {
   if (data.field.type === 'array') {
@@ -37,6 +37,11 @@ const updateOptions = (
     } else {
     } else {
       options[data.field.name] = [...oldValues, data.value]
       options[data.field.name] = [...oldValues, data.value]
     }
     }
+  } else if (data.parentFieldName) {
+    if (!options[data.parentFieldName]) {
+      options[data.parentFieldName] = {}
+    }
+    options[data.parentFieldName][data.field.name] = data.value
   } else {
   } else {
     options[data.field.name] = data.value
     options[data.field.name] = data.value
   }
   }
@@ -140,12 +145,20 @@ class WizardStore {
     this.currentPage = page
     this.currentPage = page
   }
   }
 
 
-  @action updateSourceOptions(data: { field: Field, value: any }) {
+  @action updateSourceOptions(data: {
+    field: Field,
+    value: any
+    parentFieldName: string | undefined
+  }) {
     this.data = { ...this.data }
     this.data = { ...this.data }
     this.data.sourceOptions = updateOptions(this.data.sourceOptions, data)
     this.data.sourceOptions = updateOptions(this.data.sourceOptions, data)
   }
   }
 
 
-  @action updateDestOptions(data: { field: Field, value: any }) {
+  @action updateDestOptions(data: {
+    field: Field,
+    value: any,
+    parentFieldName: string | undefined
+  }) {
     this.data = { ...this.data }
     this.data = { ...this.data }
     this.data.destOptions = updateOptions(this.data.destOptions, data)
     this.data.destOptions = updateOptions(this.data.destOptions, data)
   }
   }

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä