Просмотр исходного кода

Merge pull request #570 from smiclea/minion-pools

Add Coriolis Minion Pools operations support
Nashwan Azhari 5 лет назад
Родитель
Сommit
dd11b86a85
70 измененных файлов с 4408 добавлено и 236 удалено
  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(', ')
     }
 
-    const isImageMapField = migrationImageOsTypes.find(os => `${os}_os_image` === name)
+    const isImageMapField = migrationImageOsTypes.find(os => `${os}${plugin?.imageSuffix}` === name)
     if (isImageMapField) {
       const migrImageField = plugin && fields
         .find(f => f.name === plugin.migrationImageMapFieldName)

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

@@ -65,6 +65,8 @@ type BaseItem = {
   updated_at: string,
   origin_endpoint_id: string,
   destination_endpoint_id: string,
+  origin_minion_pool_id: string | null,
+  destination_minion_pool_id: string | null,
   instances: string[],
   info: { [prop: string]: MainItemInfo },
   destination_environment: { [prop: string]: any },
@@ -75,6 +77,7 @@ type BaseItem = {
   network_map?: TransferNetworkMap,
   last_execution_status: string
   user_id: string
+  instance_osmorphing_minion_pool_mappings?: {[instanceName: string]: string}
 }
 
 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 StyleProps from './styleUtils/StyleProps'
 import configLoader from '../utils/Config'
+import MinionPoolsPage from './pages/MinionPoolsPage/MinionPoolsPage'
+import MinionPoolDetailsPage from './pages/MinionPoolDetailsPage/MinionPoolDetailsPage'
 
 const GlobalStyle = createGlobalStyle`
  ${Fonts}
@@ -185,6 +187,9 @@ class App extends React.Component<{}, State> {
             {renderRoute('/migrations/:id/:page', MigrationDetailsPage)}
             {renderRoute('/endpoints', EndpointsPage, true)}
             {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)}
             {renderOptionalRoute('planning', AssessmentsPage)}
             {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 { observer } from 'mobx-react'
-import styled from 'styled-components'
+import styled, { css } from 'styled-components'
 
 import CopyButton from '../CopyButton'
 import DomUtils from '../../../utils/DomUtils'
@@ -26,6 +26,9 @@ const Wrapper = styled.div<any>`
   &:hover > span:last-child {
     opacity: 1;
   }
+  ${props => (props.capitalize ? css`
+    text-transform: capitalize;
+  ` : '')}
 `
 const Value = styled.span<any>`
   width: ${(props: any) => `${props.width || 'auto'}`};
@@ -41,6 +44,7 @@ type Props = {
   value: string,
   width?: string,
   maxWidth?: string,
+  capitalize?: boolean,
   'data-test-id'?: string,
   onCopy?: (value: string) => void,
 }
@@ -66,6 +70,7 @@ class CopyValue extends React.Component<Props> {
         onMouseDown={(e: { stopPropagation: () => void }) => { e.stopPropagation() }}
         onMouseUp={(e: { stopPropagation: () => void }) => { e.stopPropagation() }}
         data-test-id={this.props['data-test-id'] || 'copyValue'}
+        capitalize={this.props.capitalize}
       >
         <Value
           data-test-id="copyValue-value"

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

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

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

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

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

@@ -39,6 +39,16 @@ const STATUSES = [
   'FAILED_TO_SCHEDULE',
   'DEADLOCKED',
   'STRANDED_AFTER_DEADLOCK',
+  // Minion Pool statuses
+  'INITIALIZED',
+  'UNINITIALIZED',
+  'UNINITIALIZING',
+  'INITIALIZING',
+  'DEALLOCATING',
+  'DEALLOCATED',
+  'ALLOCATING',
+  'ALLOCATED',
+  'RECONFIGURING',
 ]
 
 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 StyleProps from '../../styleUtils/StyleProps'
+import requiredImage from './images/required.svg'
 
 const Wrapper = styled.div<any>`
+  position: relative;
   display: flex;
   height: ${StyleProps.inputSizes.regular.height}px;
   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.justifyContent ? `justify-content: ${props.justifyContent};` : '')}
   ${(props: any) => (props.width ? `width: ${props.width};` : '')}
   ${(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>`
   position: relative;
   width: ${(props: any) => props.height * 2}px;
@@ -131,6 +146,8 @@ type Props = {
   uncheckedLabel?: string,
   'data-test-id'?: string,
   style?: React.CSSProperties,
+  required?: boolean,
+  highlight?: boolean,
 }
 type State = {
   lastChecked: boolean | null | undefined,
@@ -241,11 +258,14 @@ class Switch extends React.Component<Props, State> {
         disabledLoading={this.props.disabledLoading}
         style={this.props.style}
         width={this.props.width}
+        height={this.props.height}
         justifyContent={this.props.justifyContent}
+        highlight={this.props.highlight}
       >
         {this.renderLeftLabel()}
         {this.renderInput()}
         {this.renderRightLabel()}
+        {this.props.required ? <Required /> : null}
       </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>`
   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;
   cursor: ${(props: any) => (props.disabled ? 'default' : 'pointer')};
   display: flex;
@@ -66,6 +66,7 @@ export type Props = {
   actions: Action[],
   style?: any,
   'data-test-id'?: string,
+  largeItems?: boolean
 }
 
 type State = {
@@ -172,6 +173,7 @@ class ActionDropdown extends React.Component<Props, State> {
             disabled={action.disabled}
             data-test-id={`${TEST_ID}-listItem-${action.label}`}
             title={action.title}
+            large={this.props.largeItems}
           >
             {action.label}
           </ListItem>

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

@@ -45,6 +45,7 @@ const Wrapper = styled.div<any>`
 `
 
 const Label = styled.div<any>`
+  ${props => (props.width ? `width: ${props.width}px;` : '')}
   font-weight: ${StyleProps.fontWeights.medium};
   flex-grow: 1;
   ${props => (props.layout === 'page' ? css`
@@ -96,6 +97,7 @@ type Props = {
   description?: string,
   addNullValue?: boolean,
   nullableBoolean?: boolean,
+  labelRenderer?: ((prop: string) => string) | null,
   style?: React.CSSProperties,
 }
 @observer
@@ -113,6 +115,8 @@ class FieldInput extends React.Component<Props> {
         onChange={checked => { if (this.props.onChange) this.props.onChange(checked) }}
         leftLabel={this.props.layout === 'page'}
         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 (
       <PropertiesTable
+        width={this.props.width}
         properties={this.props.properties}
         valueCallback={field => this.props.valueCallback && this.props.valueCallback(field)}
         onChange={(field, value) => {
@@ -185,6 +190,7 @@ class FieldInput extends React.Component<Props> {
             this.props.onChange(value, field)
           }
         }}
+        labelRenderer={this.props.labelRenderer}
         hideRequiredSymbol={this.props.layout === 'page'}
         disabledLoading={this.props.disabledLoading}
       />
@@ -235,6 +241,7 @@ class FieldInput extends React.Component<Props> {
       items,
       disabledLoading: this.props.disabledLoading,
       disabled: this.props.disabled,
+      highlight: this.props.highlight,
       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
 
     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 }}>
           {this.props.label}
         </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 storageIcon from './images/storage.svg'
 import arrowIcon from './images/arrow.svg'
+import { MinionPool } from '../../../@types/MinionPool'
 
 const GlobalStyle = createGlobalStyle`
   .ReactCollapse--collapse {
@@ -151,6 +152,7 @@ export type Props = {
   item?: TransferItem | null,
   instancesDetails: Instance[],
   networks?: Network[],
+  minionPools: MinionPool[]
 }
 type State = {
   openedRows: string[],
@@ -382,6 +384,14 @@ class MainDetailsTable extends React.Component<Props, State> {
     ]
 
     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 destinationName: string = ''
     const transferResult = this.getTransferResult(instance)

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

@@ -88,6 +88,7 @@ type Props = {
   customFilterComponent?: React.ReactNode,
   searchValue?: string,
   dropdownActions: DropdownAction[] | null,
+  largeDropdownActionItems?: boolean
 }
 @observer
 class MainListFilter extends React.Component<Props> {
@@ -131,6 +132,7 @@ class MainListFilter extends React.Component<Props> {
         {this.props.dropdownActions && this.props.dropdownActions.length ? (
           <ActionDropdown
             actions={this.props.dropdownActions}
+            largeItems={this.props.largeDropdownActionItems}
             style={{ marginLeft: '8px' }}
             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 endpointImages = (
       <EndpointsImages>
-        <EndpointLogos data-test-id="mainListItem-sourceLogo" height={32} endpoint={sourceType} />
+        <EndpointLogos data-test-id="mainListItem-sourceLogo" height={32} endpoint={sourceType as any} />
         <EndpointImageArrow />
-        <EndpointLogos data-test-id="mainListItem-destLogo" height={32} endpoint={destinationType} />
+        <EndpointLogos data-test-id="mainListItem-destLogo" height={32} endpoint={destinationType as any} />
       </EndpointsImages>
     )
     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 userImage from './images/user.svg'
 import projectImage from './images/project.svg'
+import minionPoolImage from './images/minionPool.svg'
 
 import { navigationMenu } from '../../../constants'
 
+const ICON_MAP = {
+  migration: migrationImage,
+  replica: replicaImage,
+  endpoint: endpointImage,
+  minionPool: minionPoolImage,
+  user: userImage,
+  project: projectImage,
+}
+
 const Wrapper = styled.div<any>`
   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;
   height: 48px;
-  background: url('${props => getIcon(props)}') no-repeat center;
+  background: url('${props => ICON_MAP[props.iconName]}') no-repeat center;
   margin: 16px;
 `
 const Content = styled.div<any>`
@@ -122,10 +116,7 @@ const Description = styled.div<any>`
 
 export type ItemType = {
   href?: string,
-  icon: {
-    migration?: boolean,
-    replica?: boolean, endpoint?: boolean, user?: boolean, project?: boolean
-  },
+  iconName: keyof typeof ICON_MAP,
   title: string,
   description: string,
   value?: string,
@@ -180,36 +171,48 @@ class NewItemDropdown extends React.Component<Props, State> {
 
     const isAdmin = userStore.loggedUser ? userStore.loggedUser.isAdmin : false
     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)))),
-    }, {
-      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)))),
-    }]
+      },
+    ]
 
     const list = (
       <List>
@@ -217,7 +220,6 @@ class NewItemDropdown extends React.Component<Props, State> {
           items.filter(i => (i.disabled ? !i.disabled : i.requiresAdmin ? isAdmin : true))
             .map(item => (
               <ListItem
-                data-test-id={`newItemDropdown-listItem-${item.title}`}
                 key={item.title}
                 onMouseDown={() => { this.itemMouseDown = true }}
                 onMouseUp={() => { this.itemMouseDown = false }}
@@ -225,8 +227,7 @@ class NewItemDropdown extends React.Component<Props, State> {
                 onClick={() => { this.handleItemClick(item) }}
               >
                 <Icon
-                  // eslint-disable-next-line react/jsx-props-no-spreading
-                  {...item.icon}
+                  iconName={item.iconName}
                 />
                 <Content>
                   <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>`
   display: flex;
+  ${props => (props.width ? `width: ${props.width}px;` : '')}
   flex-direction: column;
   border: 1px solid ${Palette.grayscale[2]};
   border-radius: ${StyleProps.borderRadius};
@@ -39,6 +40,13 @@ const Column = styled.div<any>`
   padding: 0 8px 0 16px;
   display: flex;
   align-items: center;
+  > span {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    min-width: 0;
+  }
+
   ${props => (props.header ? css`
     color: ${Palette.grayscale[4]};
     background: ${Palette.grayscale[7]};
@@ -65,10 +73,16 @@ type Props = {
   valueCallback: (property: Field) => any,
   hideRequiredSymbol?: boolean,
   disabledLoading?: boolean,
+  labelRenderer?: ((propName: string) => string) | null,
+  width?: number,
 }
 @observer
 class PropertiesTable extends React.Component<Props> {
   getName(propName: string): string {
+    if (this.props.labelRenderer) {
+      return this.props.labelRenderer(propName)
+    }
+
     if (propName.indexOf('/') > -1) {
       return LabelDictionary.get(propName.substr(propName.lastIndexOf('/') + 1))
     }
@@ -181,10 +195,10 @@ class PropertiesTable extends React.Component<Props> {
 
   render() {
     return (
-      <Wrapper disabledLoading={this.props.disabledLoading}>
+      <Wrapper disabledLoading={this.props.disabledLoading} width={this.props.width}>
         {this.props.properties.map(prop => (
           <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>
           </Row>
         ))}

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

@@ -89,6 +89,7 @@ type Props = {
   itemTitle?: string | null
   itemType?: string
   itemDescription?: string
+  largeDropdownActionItems?: boolean
 }
 @observer
 class DetailsContentHeader extends React.Component<Props> {
@@ -124,6 +125,7 @@ class DetailsContentHeader extends React.Component<Props> {
     return (
       <ActionDropdown
         actions={this.props.dropdownActions}
+        largeItems={this.props.largeDropdownActionItems}
         style={{ marginLeft: '32px' }}
         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 { isOptionsPageValid } from '../WizardPageContent'
 import WizardNetworks from '../WizardNetworks'
-import WizardOptions from '../WizardOptions'
+import WizardOptions, { INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS } from '../WizardOptions'
 import WizardStorage from '../WizardStorage/WizardStorage'
 
 import type {
@@ -44,6 +44,7 @@ import { providerTypes, migrationFields } from '../../../constants'
 import configLoader from '../../../utils/Config'
 import StyleProps from '../../styleUtils/StyleProps'
 import LoadingButton from '../../molecules/LoadingButton/LoadingButton'
+import minionPoolStore from '../../../stores/MinionPoolStore'
 
 const PanelContent = styled.div<any>`
   display: flex;
@@ -209,21 +210,49 @@ class EditReplica extends React.Component<Props, State> {
       ? 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 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) {
       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) {
       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) {
-      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
     }
     const anyData = this.props.replica as any
@@ -237,6 +266,7 @@ class EditReplica extends React.Component<Props, State> {
   }
 
   async loadData(useCache: boolean) {
+    minionPoolStore.loadMinionPools()
     await providerStore.loadProviders()
 
     if (this.hasStorageMap()) {
@@ -298,7 +328,7 @@ class EditReplica extends React.Component<Props, State> {
       providerName: endpoint.type,
       schema: type === 'source' ? providerStore.sourceSchema : providerStore.destinationSchema,
       data: {
-        ...this.parseReplicaData(env),
+        ...env,
         ...stateEnv,
       },
       field,
@@ -353,33 +383,12 @@ class EditReplica extends React.Component<Props, State> {
       || 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') {
     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 schema = type === 'source' ? providerStore.sourceSchema : providerStore.destinationSchema
     const isValid = isOptionsPageValid({
-      ...this.parseReplicaData(env),
+      ...env,
       ...data,
     }, schema)
 
@@ -395,8 +404,9 @@ class EditReplica extends React.Component<Props, State> {
     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 }
+
     if (field.type === 'array') {
       const oldValues: string[] = data[field.name] || []
       if (oldValues.find(v => v === value)) {
@@ -404,6 +414,9 @@ class EditReplica extends React.Component<Props, State> {
       } else {
         data[field.name] = [...oldValues, value]
       }
+    } else if (parentFieldName) {
+      data[parentFieldName] = data[parentFieldName] || {}
+      data[parentFieldName][field.name] = value
     } else {
       data[field.name] = value
     }
@@ -441,11 +454,13 @@ class EditReplica extends React.Component<Props, State> {
     }
     if (this.props.type === 'replica') {
       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.onUpdateComplete(`/replicas/${this.props.replica.id}/executions`)
       } catch (err) {
@@ -531,15 +546,20 @@ class EditReplica extends React.Component<Props, State> {
     if (endpoint) {
       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 (
       <WizardOptions
+        minionPools={minionPools}
         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}
+        selectedInstances={type === 'destination' ? this.props.instancesDetails : null}
         hasStorageMap={type === 'source' ? false : this.hasStorageMap()}
         storageBackends={endpointStore.storageBackends}
         storageConfigDefault={endpointStore.storageConfigDefault}
-        onChange={(f, v) => { this.handleFieldChange(type, f, v) }}
+        onChange={(f, v, fp) => { this.handleFieldChange(type, f, v, fp) }}
         oneColumnStyle={{
           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>
         <LinkStyled
           key={item.id}
-          to={`/${item.type}/${item.id}`}
+          to={`/${item.type}s/${item.id}`}
         >
           {item.instances[0]}
         </LinkStyled>

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

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

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

@@ -49,6 +49,7 @@ type Props = {
   emptyListButtonLabel?: string,
   onEmptyListButtonClick?: () => void,
   customFilterComponent?: React.ReactNode,
+  largeDropdownActionItems?: boolean
 }
 type State = {
   items: any[],
@@ -210,6 +211,7 @@ class FilterList extends React.Component<Props, State> {
           }}
           items={this.props.filterItems}
           dropdownActions={this.props.dropdownActions || []}
+          largeDropdownActionItems={this.props.largeDropdownActionItems}
           data-test-id="filterList-filter"
         />
         <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 { TransferItem } from '../../../@types/MainItem'
+import { MinionPool } from '../../../@types/MinionPool'
 
 const Wrapper = styled.div<any>`
   display: flex;
@@ -121,6 +122,7 @@ const PropertyValue = styled.div<any>`
 
 type Props = {
   item?: TransferItem | null,
+  minionPools: MinionPool[]
   destinationSchema: FieldType[],
   destinationSchemaLoading: boolean,
   sourceSchema: FieldType[],
@@ -235,7 +237,7 @@ class MainDetails extends React.Component<Props, State> {
           }
           let fieldName = pn
           if (migrationImageMapFieldName && fieldName === migrationImageMapFieldName) {
-            fieldName = `${p}_os_image`
+            fieldName = `${p}${plugin?.imageSuffix}`
           }
           return {
             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 (
       <ColumnsLayout>
         <Column width="42.5%">
@@ -294,7 +301,7 @@ class MainDetails extends React.Component<Props, State> {
           </Row>
           <Row>
             <EndpointLogos
-              endpoint={sourceEndpoint ? sourceEndpoint.type : ''}
+              endpoint={(sourceEndpoint ? sourceEndpoint.type : '') as any}
               data-test-id="mainDetails-sourceLogo"
             />
           </Row>
@@ -339,6 +346,18 @@ class MainDetails extends React.Component<Props, State> {
               </Field>
             </Row>
           ) : 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 ? (
             <Row>
               <Field>
@@ -360,7 +379,7 @@ class MainDetails extends React.Component<Props, State> {
           </Row>
           <Row>
             <EndpointLogos
-              endpoint={destinationEndpoint ? destinationEndpoint.type : ''}
+              endpoint={(destinationEndpoint ? destinationEndpoint.type : '') as any}
               data-test-id="mainDetails-targetLogo"
             />
           </Row>
@@ -375,6 +394,18 @@ class MainDetails extends React.Component<Props, State> {
               </Field>
             </Row>
           ) : 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>
       </ColumnsLayout>
     )
@@ -407,6 +438,7 @@ class MainDetails extends React.Component<Props, State> {
         {this.props.instancesDetailsLoading || this.props.loading ? null : (
           <MainDetailsTable
             item={this.props.item}
+            minionPools={this.props.minionPools}
             instancesDetails={this.props.instancesDetails}
             networks={this.props.networks}
           />

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

@@ -74,6 +74,7 @@ const EmptyListMessage = styled.div<any>`
   font-size: 18px;
 `
 const EmptyListExtraMessage = styled.div<any>`
+  max-width: 700px;
   text-align: center;
   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 { Field } from '../../../@types/Field'
 import { MigrationItemDetails } from '../../../@types/MainItem'
+import { MinionPool } from '../../../@types/MinionPool'
 
 const Wrapper = styled.div<any>`
   display: flex;
@@ -54,6 +55,7 @@ const NavigationItems = [
 
 type Props = {
   item: MigrationItemDetails | null,
+  minionPools: MinionPool[]
   detailsLoading: boolean,
   instancesDetails: Instance[],
   instancesDetailsLoading: boolean,
@@ -88,6 +90,7 @@ class MigrationDetailsContent extends React.Component<Props> {
     return (
       <MainDetails
         item={this.props.item}
+        minionPools={this.props.minionPools}
         instancesDetails={this.props.instancesDetails}
         instancesDetailsLoading={this.props.instancesDetailsLoading}
         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

Разница между файлами не показана из-за своего большого размера
+ 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/>.
 */
 
-import React from 'react'
+import React, { CSSProperties } from 'react'
 import { Link } from 'react-router-dom'
 import { observer } from 'mobx-react'
 import styled from 'styled-components'
@@ -37,6 +37,7 @@ import projectImage from './images/project-menu.svg'
 import userImage from './images/user-menu.svg'
 import logsImage from './images/logs-menu.svg'
 import dashboardImage from './images/dashboard-menu.svg'
+import minionPoolsImage from './images/minion-pools-menu.svg'
 
 const isCollapsed = (props: any) => props.collapsed
   || (window.outerWidth <= StyleProps.mobileMaxWidth)
@@ -375,7 +376,7 @@ class Navigation extends React.Component<Props> {
           this.filteredMenu.map(item => {
             let menuImage
             let bullet
-            let style = null
+            let style: CSSProperties | null = null
             switch (item.value) {
               case 'dashboard':
                 menuImage = dashboardImage
@@ -392,6 +393,9 @@ class Navigation extends React.Component<Props> {
               case 'endpoints':
                 menuImage = endpointImage
                 break
+              case 'minion-pools':
+                menuImage = minionPoolsImage
+                break
               case 'planning':
                 menuImage = planningImage
                 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 StyleProps from '../../styleUtils/StyleProps'
 import { ProviderTypes } from '../../../@types/Providers'
+import MinionEndpointModal from '../MinionEndpointModal/MinionEndpointModal'
+import MinionPoolModal from '../MinionPoolModal'
 
 const Wrapper = styled.div<any>`
   display: flex;
@@ -77,24 +79,32 @@ type Props = {
 type State = {
   showChooseProviderModal: boolean,
   showEndpointModal: boolean,
+  showChooseMinionEndpointModal: boolean,
+  showMinionPoolModal: boolean,
+  selectedMinionPoolEndpoint: EndpointType | null
   showUserModal: boolean,
   showProjectModal: boolean,
   showAbout: boolean,
   providerType: ProviderTypes | null,
   uploadedEndpoint: EndpointType | null,
   multiValidating: boolean,
+  selectedMinionPoolPlatform: 'source' | 'destination'
 }
 @observer
 class PageHeader extends React.Component<Props, State> {
   state: State = {
     showChooseProviderModal: false,
     showEndpointModal: false,
+    showChooseMinionEndpointModal: false,
+    selectedMinionPoolEndpoint: null,
+    showMinionPoolModal: false,
     showUserModal: false,
     showProjectModal: false,
     providerType: null,
     uploadedEndpoint: null,
     showAbout: false,
     multiValidating: false,
+    selectedMinionPoolPlatform: 'source',
   }
 
   pollTimeout!: number
@@ -148,6 +158,14 @@ class PageHeader extends React.Component<Props, State> {
         }
         this.setState({ showChooseProviderModal: true })
         break
+      case 'minionPool':
+        providerStore.loadProviders()
+        endpointStore.getEndpoints({ showLoading: true })
+        if (this.props.onModalOpen) {
+          this.props.onModalOpen()
+        }
+        this.setState({ showChooseMinionEndpointModal: true })
+        break
       case 'user':
         projectStore.getProjects()
         if (this.props.onModalOpen) {
@@ -176,6 +194,33 @@ class PageHeader extends React.Component<Props, State> {
     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) {
     this.setState({
       showChooseProviderModal: false,
@@ -271,6 +316,8 @@ class PageHeader extends React.Component<Props, State> {
       this.stopPolling
       || this.state.showChooseProviderModal
       || this.state.showEndpointModal
+      || this.state.showChooseMinionEndpointModal
+      || this.state.showMinionPoolModal
       || this.state.showProjectModal
       || this.state.showUserModal
       || this.state.showAbout
@@ -324,6 +371,32 @@ class PageHeader extends React.Component<Props, State> {
             onResetValidation={() => { this.handleResetValidation() }}
           />
         </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 ? (
           <Modal
             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 StyleProps from '../../styleUtils/StyleProps'
 import { ReplicaItemDetails } from '../../../@types/MainItem'
+import { MinionPool } from '../../../@types/MinionPool'
 
 const Wrapper = styled.div<any>`
   display: flex;
@@ -86,6 +87,7 @@ type Props = {
   executionsLoading: boolean,
   executionsTasksLoading: boolean,
   executionsTasks: ExecutionTasks[],
+  minionPools: MinionPool[]
   onExecutionChange: (executionId: string) => void,
   onCancelExecutionClick: (execution: Execution | null, force?: boolean) => void,
   onDeleteExecutionClick: (execution: Execution | null) => void,
@@ -168,6 +170,7 @@ class ReplicaDetailsContent extends React.Component<Props, State> {
     return (
       <MainDetails
         item={this.props.item}
+        minionPools={this.props.minionPools}
         sourceSchema={this.props.sourceSchema}
         sourceSchemaLoading={this.props.sourceSchemaLoading}
         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 { 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>`
   display: flex;
@@ -61,7 +64,7 @@ const Form = styled.div<any>`
   flex-wrap: wrap;
   margin-left: -64px;
   justify-content: space-between;
-  margin: 0 auto 46px auto;
+  margin: 0 auto;
 `
 const Buttons = styled.div<any>`
   display: flex;
@@ -76,16 +79,23 @@ const FieldInputStyled = styled(FieldInput)`
 
 type Props = {
   instances: Instance[],
+  transferItem: TransferItemDetails | null,
+  minionPools: MinionPool[]
   loadingInstances: boolean,
   defaultSkipOsMorphing?: boolean | null,
   onCancelClick: () => void,
-  onMigrateClick: (fields: Field[], uploadedScripts: InstanceScript[]) => void,
+  onMigrateClick: (
+    fields: Field[],
+    uploadedScripts: InstanceScript[],
+    minionPoolMappings: { [instance: string]: string }
+  ) => void,
   onResizeUpdate?: (scrollableRef: HTMLElement, scrollOffset?: number) => void,
 }
 type State = {
   fields: Field[],
   selectedBarButton: string,
   uploadedScripts: InstanceScript[],
+  minionPoolMappings: {[instance: string]: string}
 }
 
 @observer
@@ -94,15 +104,19 @@ class ReplicaMigrationOptions extends React.Component<Props, State> {
     fields: [],
     selectedBarButton: 'options',
     uploadedScripts: [],
+    minionPoolMappings: {},
   }
 
   scrollableRef!: HTMLElement
 
   UNSAFE_componentWillMount() {
+    const mappings = this.props.transferItem?.instance_osmorphing_minion_pool_mappings || {}
+
     this.setState({
       fields: replicaMigrationFields.map(f => (f.name === 'skip_os_morphing' ? (
         { ...f, value: this.props.defaultSkipOsMorphing || null }
       ) : f)),
+      minionPoolMappings: { ...mappings },
     })
   }
 
@@ -123,7 +137,11 @@ class ReplicaMigrationOptions extends React.Component<Props, State> {
   }
 
   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) {
@@ -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() {
     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 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>`
   display: flex;
@@ -120,11 +123,17 @@ type FieldRender = {
 }
 type Props = {
   fields: Field[],
+  minionPools: MinionPool[]
   isSource?: boolean,
   selectedInstances?: Instance[] | null,
+  showSeparatePerVm?: boolean
   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,
   hasStorageMap: boolean,
   storageBackends?: StorageBackend[],
@@ -151,9 +160,21 @@ class WizardOptions extends React.Component<Props> {
     window.removeEventListener('resize', this.handleResize, false)
   }
 
-  getFieldValue(fieldName: string, defaultValue: any) {
+  getFieldValue(fieldName: string, defaultValue: any, parentFieldName?: string) {
     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) {
@@ -163,8 +184,8 @@ class WizardOptions extends React.Component<Props> {
     return this.props.data[fieldName]
   }
 
-  getDefaultFieldsSchema() {
-    let fieldsSchema = []
+  getDefaultSimpleFieldsSchema() {
+    let fieldsSchema: Field[] = []
 
     if (this.props.wizardType === 'migration' || this.props.wizardType === 'replica') {
       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 })
     }
 
-    if (this.props.selectedInstances && this.props.selectedInstances.length > 1) {
+    if (this.props.showSeparatePerVm) {
       const dictionaryLabel = LabelDictionary.get('separate_vm')
       const label = this.props.wizardType === 'migration' ? dictionaryLabel : dictionaryLabel.replace('Migration', 'Replica')
       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') {
       fieldsSchema.push({ name: 'execute_now', type: 'boolean', default: true })
       const executeNowValue = this.getFieldValue('execute_now', true)
@@ -199,6 +232,29 @@ class WizardOptions extends React.Component<Props> {
     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 {
     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) {
     let additionalProps
     if (field.type === 'object' && field.properties) {
+      const renderOsMorphingLabels = (propName: string) => (
+        propName.indexOf('/') > -1 ? propName.split('/')[propName.split('/').length - 1] : propName
+      )
+
       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,
       }
     } else {
@@ -297,12 +361,13 @@ class WizardOptions extends React.Component<Props> {
       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)
 
     fieldsSchema = fieldsSchema.concat(this.props.fields.filter(f => f.required))
 
     if (this.props.useAdvancedOptions) {
+      fieldsSchema = fieldsSchema.concat(this.getDefaultAdvancedFieldsSchema())
       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 { ProviderTypes } from '../../../@types/Providers'
+import minionPoolStore from '../../../stores/MinionPoolStore'
 
 const Wrapper = styled.div<any>`
   ${StyleProps.exactWidth(`${parseInt(StyleProps.contentWidth, 10) + 64}px`)}
@@ -172,6 +173,7 @@ type Props = {
   instanceStore: typeof instanceStore,
   networkStore: typeof networkStore,
   endpointStore: typeof endpointStore,
+  minionPoolStore: typeof minionPoolStore,
   wizardData: WizardData,
   schedules: ScheduleType[],
   storageMap: StorageMap[],
@@ -190,8 +192,8 @@ type Props = {
   onInstancesReloadClick: () => void,
   onInstanceClick: (instance: Instance) => 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,
   onStorageChange: (sourceStorage: Disk, targetStorage: StorageBackend, type: 'backend' | 'disk') => void,
   onDefaultStorageChange: (value: string | null) => void,
@@ -411,7 +413,7 @@ class WizardPageContent extends React.Component<Props, State> {
             onReloadClick={this.props.onInstancesReloadClick}
             onInstanceClick={this.props.onInstanceClick}
             onPageClick={this.props.onInstancePageClick}
-            selectedInstances={this.props.wizardData.selectedInstances}
+            selectedInstances={this.props.wizardData.selectedInstances || []}
             hasSourceOptions={this.props.hasSourceOptions}
           />
         )
@@ -420,7 +422,10 @@ class WizardPageContent extends React.Component<Props, State> {
         body = (
           <WizardOptions
             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}
             optionsLoadingSkipFields={getOptionsLoadingSkipFields('source')}
             fields={this.props.providerStore.sourceSchema}
@@ -439,12 +444,19 @@ class WizardPageContent extends React.Component<Props, State> {
         body = (
           <WizardOptions
             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}
             optionsLoadingSkipFields={[
               ...getOptionsLoadingSkipFields('destination'), 'description', 'execute_now',
               'execute_now_options', ...migrationFields.map(f => f.name)]}
             selectedInstances={this.props.wizardData.selectedInstances}
+            showSeparatePerVm={
+              Boolean(this.props.wizardData.selectedInstances
+                && this.props.wizardData.selectedInstances.length > 1)
+            }
             fields={this.props.providerStore.destinationSchema}
             onChange={this.props.onDestOptionsChange}
             data={this.props.wizardData.destOptions}
@@ -522,6 +534,7 @@ class WizardPageContent extends React.Component<Props, State> {
             sourceSchema={this.props.providerStore.sourceSchema}
             destinationSchema={this.props.providerStore.destinationSchema}
             uploadedUserScripts={this.props.uploadedUserScripts}
+            minionPools={this.props.minionPoolStore.minionPools}
           />
         )
         break
@@ -541,7 +554,7 @@ class WizardPageContent extends React.Component<Props, State> {
       <Navigation>
         <Button secondary onClick={this.props.onBackClick}>Back</Button>
         <IconRepresentation>
-          <EndpointLogos height={32} endpoint={sourceEndpoint || ''} />
+          <EndpointLogos height={32} endpoint={(sourceEndpoint || '') as any} />
           <WizardTypeIcon
             dangerouslySetInnerHTML={{
               __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 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>`
   width: 100%;
@@ -160,11 +163,18 @@ const OptionValue = styled.div<any>`
   text-overflow: ellipsis;
   overflow: hidden;
 `
+const ObjectTable = styled.div`
+  margin-top: 24px;
+`
+const ObjectTableTitle = styled.div`
+  margin-bottom: 8px;
+`
 
 type Props = {
   data: WizardData,
   wizardType: 'replica' | 'migration',
   schedules: Schedule[],
+  minionPools: MinionPool[]
   defaultStorage: string | null,
   storageMap: StorageMap[],
   instancesDetails: Instance[],
@@ -282,11 +292,89 @@ class WizardSummary extends React.Component<Props> {
               </Option>
             )
           }) : null}
+          {this.renderObjectTable(data.sourceOptions, this.props.sourceSchema, provider)}
         </OptionsList>
       </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() {
     const data = this.props.data
     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'
               || migrationFields.find(f => f.name === optionName)
               || !data.destOptions || data.destOptions[optionName] == null || data.destOptions[optionName] === ''
+              || typeof data.destOptions[optionName] === 'object'
             ) {
               return null
             }
 
             const optionLabel = optionName.split('/')
               .map(n => LabelDictionary.get(n, `${data.target ? data.target.type : ''}-destination`)).join(' - ')
+
             const optionValue = fieldHelper.getValueAlias(optionName,
               data.destOptions && data.destOptions[optionName],
               this.props.destinationSchema, provider)
+
             return (
               <Option key={optionName}>
                 <OptionLabel data-test-id={`wSummary-optionLabel-${optionName}`} title={optionLabel}>
@@ -365,6 +456,8 @@ class WizardSummary extends React.Component<Props> {
               </Option>
             )
           }) : null}
+          {this.renderMinionPoolMapping()}
+          {this.renderObjectTable(data.destOptions, this.props.destinationSchema, provider)}
         </OptionsList>
       </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 { InstanceScript } from '../../../@types/Instance'
+import minionPoolStore from '../../../stores/MinionPoolStore'
 
 const Wrapper = styled.div<any>``
 
@@ -145,6 +146,10 @@ class MigrationDetailsPage extends React.Component<Props, State> {
       return
     }
 
+    if (details.origin_minion_pool_id || details.destination_minion_pool_id) {
+      minionPoolStore.loadMinionPools()
+    }
+
     networkStore.loadNetworks(details.destination_endpoint_id, details.destination_environment, {
       quietError: true,
       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
     if (!replicaId) {
       return
     }
 
-    this.migrate(replicaId, options, userScripts)
+    this.migrate(replicaId, options, userScripts, minionPoolMappings)
     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`)
   }
 
@@ -368,7 +387,9 @@ class MigrationDetailsPage extends React.Component<Props, State> {
               || providerStore.destinationOptionsSecondaryLoading}
               endpoints={endpointStore.endpoints}
               page={this.props.match.params.page || ''}
-              detailsLoading={endpointStore.loading || migrationStore.detailsLoading}
+              minionPools={minionPoolStore.minionPools}
+              detailsLoading={migrationStore.detailsLoading || endpointStore.loading
+                || minionPoolStore.loadingMinionPools}
               onDeleteMigrationClick={() => { this.handleDeleteMigrationClick() }}
             />
 )}
@@ -407,8 +428,10 @@ Note that this may lead to scheduled cleanup tasks being forcibly skipped, and t
             onRequestClose={() => { this.handleCloseFromReplicaModal() }}
           >
             <ReplicaMigrationOptions
+              transferItem={migrationStore.migrationDetails}
+              minionPools={minionPoolStore.minionPools}
               onCancelClick={() => { this.handleCloseFromReplicaModal() }}
-              onMigrateClick={(o, s) => { this.recreateFromReplica(o, s) }}
+              onMigrateClick={(o, s, m) => { this.recreateFromReplica(o, s, m) }}
               instances={instanceStore.instancesDetails}
               loadingInstances={instanceStore.loadingInstancesDetails}
               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 => {
       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 {
         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

Разница между файлами не показана из-за своего большого размера
+ 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 { ReplicaItemDetails } from '../../../@types/MainItem'
 import ObjectUtils from '../../../utils/ObjectUtils'
+import minionPoolStore from '../../../stores/MinionPoolStore'
 
 const Wrapper = styled.div<any>``
 
@@ -201,6 +202,8 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     if (!replica) {
       return null
     }
+    minionPoolStore.loadMinionPools()
+
     this.loadIsEditable(replica)
     networkStore.loadNetworks(replica.destination_endpoint_id, replica.destination_environment, {
       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()
   }
 
-  async migrate(options: Field[], uploadedScripts: InstanceScript[]) {
+  async migrate(
+    options: Field[],
+    uploadedScripts: InstanceScript[],
+    minionPoolMappings: { [instance: string]: string },
+  ) {
     const replica = this.replica
     if (!replica) {
       return
@@ -398,6 +409,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
       replica.id,
       options,
       uploadedScripts,
+      minionPoolMappings,
     )
     notificationStore.alert('Migration successfully created from replica.', 'success', {
       action: {
@@ -573,7 +585,9 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
               endpoints={endpointStore.endpoints}
               scheduleStore={scheduleStore}
               networks={networkStore.networks}
-              detailsLoading={replicaStore.replicaDetailsLoading || endpointStore.loading}
+              minionPools={minionPoolStore.minionPools}
+              detailsLoading={replicaStore.replicaDetailsLoading || endpointStore.loading
+                || minionPoolStore.loadingMinionPools}
               sourceSchema={providerStore.sourceSchema}
               sourceSchemaLoading={providerStore.sourceSchemaLoading
               || providerStore.sourceOptionsPrimaryLoading
@@ -621,10 +635,12 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
             onRequestClose={() => { this.handleCloseMigrationModal() }}
           >
             <ReplicaMigrationOptions
+              transferItem={this.replica}
+              minionPools={minionPoolStore.minionPools}
               loadingInstances={instanceStore.loadingInstancesDetails}
               instances={instanceStore.instancesDetails}
               onCancelClick={() => { this.handleCloseMigrationModal() }}
-              onMigrateClick={(o, s) => { this.migrateReplica(o, s) }}
+              onMigrateClick={(o, s, m) => { this.migrateReplica(o, s, m) }}
             />
           </Modal>
         ) : null}

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

@@ -161,6 +161,7 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
         fields,
         uploadedScripts.filter(s => !s.instanceName
           || replica.instances.find(i => i === s.instanceName)),
+        replica.instance_osmorphing_minion_pool_mappings || {},
       )))
     notificationStore.alert('Migrations successfully created from replicas.', 'success')
     this.props.history.push('/migrations')
@@ -418,6 +419,8 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
             }}
           >
             <ReplicaMigrationOptions
+              transferItem={null}
+              minionPools={[]}
               instances={instanceStore.instancesDetails}
               loadingInstances={instanceStore.loadingInstancesDetails}
               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 { ProviderTypes } from '../../../@types/Providers'
 import { TransferItem, ReplicaItem } from '../../../@types/MainItem'
+import minionPoolStore from '../../../stores/MinionPoolStore'
 
 const Wrapper = styled.div<any>``
 
@@ -229,6 +230,7 @@ class WizardPage extends React.Component<Props, State> {
     if (!source) {
       return
     }
+
     await providerStore.loadOptionsSchema({
       providerName: source.type,
       optionsType: 'source',
@@ -315,10 +317,10 @@ class WizardPage extends React.Component<Props, State> {
     instanceStore.setPage(page)
   }
 
-  handleDestOptionsChange(field: Field, value: any) {
+  handleDestOptionsChange(field: Field, value: any, parentFieldName?: string) {
     wizardStore.updateData({ networks: null })
     wizardStore.clearStorageMap()
-    wizardStore.updateDestOptions({ field, value })
+    wizardStore.updateDestOptions({ field, value, parentFieldName })
     // 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,
     // 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()
   }
 
-  handleSourceOptionsChange(field: Field, value: any) {
+  handleSourceOptionsChange(field: Field, value: any, parentFieldName?: string) {
     wizardStore.updateData({ selectedInstances: [] })
-    wizardStore.updateSourceOptions({ field, value })
+    wizardStore.updateSourceOptions({ field, value, parentFieldName })
     if (field.type !== 'string' || field.enum) {
       this.loadExtraOptions(field, 'source')
     }
@@ -378,6 +380,7 @@ class WizardPage extends React.Component<Props, State> {
     if (!endpoint) {
       return
     }
+    minionPoolStore.loadMinionPools()
     await providerStore.loadOptionsSchema({
       providerName: endpoint.type,
       optionsType,
@@ -452,6 +455,7 @@ class WizardPage extends React.Component<Props, State> {
 
     switch (page.id) {
       case 'source': {
+        minionPoolStore.loadMinionPools()
         providerStore.loadProviders()
         endpointStore.getEndpoints()
         // Preload instances if data is set from 'Permalink'
@@ -647,6 +651,7 @@ class WizardPage extends React.Component<Props, State> {
               instanceStore={instanceStore}
               networkStore={networkStore}
               endpointStore={endpointStore}
+              minionPoolStore={minionPoolStore}
               wizardData={wizardStore.data}
               hasStorageMap={Boolean(this.pages.find(p => p.id === 'storage'))}
               hasSourceOptions={Boolean(this.pages.find(p => p.id === 'source-options'))}
@@ -667,9 +672,11 @@ class WizardPage extends React.Component<Props, State> {
               onInstancesReloadClick={() => { this.handleInstancesReloadClick() }}
               onInstanceClick={instance => { this.handleInstanceClick(instance) }}
               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) => {
                 this.handleNetworkChange(sourceNic, targetNetwork, secGroups)

+ 3 - 0
src/constants.ts

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

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

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

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

@@ -43,7 +43,10 @@ export const defaultFillFieldValues = (field: Field, option: OptionValues) => {
 }
 
 export const defaultFillMigrationImageMapValues = (
-  field: Field, option: OptionValues, migrationImageMapFieldName: string,
+  field: Field,
+  option: OptionValues,
+  migrationImageMapFieldName: string,
+  imageSuffix: string,
 ): boolean => {
   if (field.name !== migrationImageMapFieldName) {
     return false
@@ -65,7 +68,7 @@ export const defaultFillMigrationImageMapValues = (
     }
 
     return {
-      name: `${os}_os_image`,
+      name: `${os}${imageSuffix}`,
       type: 'string',
       enum: values,
     }
@@ -74,13 +77,15 @@ export const defaultFillMigrationImageMapValues = (
 }
 
 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 => {
   const env: any = {}
   const specialOptions = ['execute_now', 'separate_vm', 'skip_os_morphing', 'description']
     .concat(migrationFields.map(f => f.name))
     .concat(executionOptions.map(o => o.name))
-    .concat(migrationImageOsTypes.map(o => `${o}_os_image`))
+    .concat(migrationImageOsTypes.map(o => `${o}${imageSuffix}`))
 
   if (!options) {
     return env
@@ -90,13 +95,12 @@ export const defaultGetDestinationEnv = (
     if (specialOptions.find(o => o === optionName) || !options || options[optionName] == null || options[optionName] === '') {
       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 {
       env[optionName] = options ? Utils.trim(optionName, options[optionName]) : null
     }
@@ -106,26 +110,28 @@ export const defaultGetDestinationEnv = (
 
 export const defaultGetMigrationImageMap = (
   options: { [prop: string]: any } | null | undefined,
-  oldOptions: any, migrationImageMapFieldName: string,
+  oldOptions: any,
+  migrationImageMapFieldName: string,
+  imageSuffix: string,
 ) => {
   const env: any = {}
   const usableOptions = options
   if (!usableOptions) {
     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) {
     return env
   }
   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,
     // 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.
     if (!value) {
-      value = oldOptions && oldOptions[migrationImageMapFieldName]
-        && oldOptions[migrationImageMapFieldName][os]
+      value = oldOptions?.[migrationImageMapFieldName]?.[`${os}${imageSuffix}`]
       if (!value) {
         return
       }
@@ -135,7 +141,7 @@ export const defaultGetMigrationImageMap = (
       env[migrationImageMapFieldName] = {}
     }
 
-    env[migrationImageMapFieldName][os] = value
+    env[migrationImageMapFieldName][`${os}${imageSuffix}`] = value
   })
 
   return env
@@ -144,10 +150,12 @@ export const defaultGetMigrationImageMap = (
 export default class OptionsSchemaParser {
   static migrationImageMapFieldName = 'migr_image_map'
 
+  static imageSuffix = '_os_image'
+
   static parseSchemaToFields(
     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) {
@@ -156,15 +164,29 @@ export default class OptionsSchemaParser {
     if (!option) {
       return
     }
-    if (!defaultFillMigrationImageMapValues(field, option, this.migrationImageMapFieldName)) {
+    if (!defaultFillMigrationImageMapValues(
+      field,
+      option,
+      this.migrationImageMapFieldName,
+      this.imageSuffix,
+    )) {
       defaultFillFieldValues(field, option)
     }
   }
 
   static getDestinationEnv(options?: { [prop: string]: any } | null, oldOptions?: any) {
     const env = {
-      ...defaultGetDestinationEnv(options, oldOptions),
-      ...defaultGetMigrationImageMap(options, oldOptions, this.migrationImageMapFieldName),
+      ...defaultGetDestinationEnv(
+        options,
+        oldOptions,
+        this.imageSuffix,
+      ),
+      ...defaultGetMigrationImageMap(
+        options,
+        oldOptions,
+        this.migrationImageMapFieldName,
+        this.imageSuffix,
+      ),
     }
     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/>.
 */
 
-import DefaultOptionsSchemaPlugin from '../default/OptionsSchemaPlugin'
+import DefaultOptionsSchemaPlugin, {
+  defaultGetDestinationEnv,
+  defaultGetMigrationImageMap,
+  defaultFillFieldValues,
+  defaultFillMigrationImageMapValues,
+} from '../default/OptionsSchemaPlugin'
+
 import LabelDictionary from '../../../utils/LabelDictionary'
 
 import type { InstanceScript } from '../../../@types/Instance'
@@ -24,6 +30,8 @@ import type { NetworkMap } from '../../../@types/Network'
 export default class OptionsSchemaParser {
   static migrationImageMapFieldName = DefaultOptionsSchemaPlugin.migrationImageMapFieldName
 
+  static imageSuffix = ''
+
   static parseSchemaToFields(
     schema: SchemaProperties,
     schemaDefinitions: SchemaDefinitions | null | undefined,
@@ -60,12 +68,31 @@ export default class OptionsSchemaParser {
         }
       })
     } 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) {
-    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) {

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

@@ -28,6 +28,8 @@ import type { NetworkMap } from '../../../@types/Network'
 export default class OptionsSchemaParser {
   static migrationImageMapFieldName = 'migr_template_map'
 
+  static imageSuffix = '_os_image'
+
   static parseSchemaToFields(
     schema: SchemaProperties,
     schemaDefinitions: SchemaDefinitions | null | undefined,
@@ -66,15 +68,25 @@ export default class OptionsSchemaParser {
     if (!option) {
       return
     }
-    if (!defaultFillMigrationImageMapValues(field, option, this.migrationImageMapFieldName)) {
+    if (!defaultFillMigrationImageMapValues(
+      field,
+      option,
+      this.migrationImageMapFieldName,
+      this.imageSuffix,
+    )) {
       defaultFillFieldValues(field, option)
     }
   }
 
   static getDestinationEnv(options: { [prop: string]: any } | null, oldOptions?: any) {
     const env = {
-      ...defaultGetDestinationEnv(options, oldOptions),
-      ...defaultGetMigrationImageMap(options, oldOptions, this.migrationImageMapFieldName),
+      ...defaultGetDestinationEnv(options, oldOptions, this.imageSuffix),
+      ...defaultGetMigrationImageMap(
+        options,
+        oldOptions,
+        this.migrationImageMapFieldName,
+        this.imageSuffix,
+      ),
     }
     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 { Task } from '../@types/Task'
 import { MigrationItem, MigrationItemOptions, MigrationItemDetails } from '../@types/MainItem'
+import { INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS } from '../components/organisms/WizardOptions/WizardOptions'
 
 class MigrationSourceUtils {
   static sortTaskUpdates(updates: any[]) {
@@ -75,7 +76,8 @@ class MigrationSource {
   async recreateFullCopy(migration: MigrationItemOptions): Promise<MigrationItem> {
     const {
       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
 
     const payload: any = {
@@ -87,6 +89,9 @@ class MigrationSource {
         instances,
         storage_mappings,
         notes,
+        destination_minion_pool_id,
+        origin_minion_pool_id,
+        instance_osmorphing_minion_pool_mappings,
       },
     }
 
@@ -125,6 +130,7 @@ class MigrationSource {
     updatedNetworkMappings: NetworkMap[] | null,
     defaultSkipOsMorphing: boolean | null,
     replicationCount?: number | null,
+    migration: MigrationItemDetails
   }): Promise<MigrationItemDetails> {
     const getValue = (fieldName: string): string | null => {
       const updatedDestEnv = opts.updatedDestEnv && opts.updatedDestEnv[fieldName]
@@ -139,10 +145,6 @@ class MigrationSource {
     payload.migration = {
       origin_endpoint_id: opts.sourceEndpoint.id,
       destination_endpoint_id: opts.destEndpoint.id,
-      destination_environment: {
-        ...opts.destEnv,
-        ...destParser.getDestinationEnv(opts.updatedDestEnv),
-      },
       shutdown_instances: Boolean(opts.updatedDestEnv && opts.updatedDestEnv.shutdown_instances),
       replication_count: (opts.updatedDestEnv
         && opts.updatedDestEnv.replication_count) || opts.replicationCount || 2,
@@ -174,12 +176,45 @@ class MigrationSource {
             || 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({
@@ -212,7 +247,10 @@ class MigrationSource {
   }
 
   async migrateReplica(
-    replicaId: string, options: Field[], userScripts: InstanceScript[],
+    replicaId: string,
+    options: Field[],
+    userScripts: InstanceScript[],
+    minionPoolMappings: { [instance: string]: string },
   ): Promise<MigrationItem> {
     const payload: any = {
       migration: {
@@ -227,6 +265,12 @@ class MigrationSource {
       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({
       url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/migrations`,
       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 { Task, ProgressUpdate } from '../@types/Task'
 import type { Field } from '../@types/Field'
+import { INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS } from '../components/organisms/WizardOptions/WizardOptions'
 
 export const sortTasks = (
   tasks?: Task[], taskUpdatesSortFunction?: (updates: ProgressUpdate[]) => void,
@@ -65,7 +66,7 @@ export const sortTasks = (
   tasks.splice(0, tasks.length, ...sortedTasks)
 }
 
-class ReplicaSourceUtils {
+export class ReplicaSourceUtils {
   static filterDeletedExecutions(executions?: Execution[]) {
     if (!executions || !executions.length) {
       return []
@@ -75,7 +76,12 @@ class ReplicaSourceUtils {
   }
 
   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[]) {
@@ -131,6 +137,7 @@ class ReplicaSource {
     const response = await Api.send({
       url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/replicas/${replicaId}/executions/${executionId}`,
       skipLog: polling,
+      quietError: true,
     })
     const execution: ExecutionTasks = response.data.execution
     sortTasks(execution.tasks, ReplicaSourceUtils.sortTaskUpdates)
@@ -206,28 +213,57 @@ class ReplicaSource {
     return response.data.execution
   }
 
-  async update(
-    replica: ReplicaItem,
+  async update(options: {
+    replica: ReplicaItemDetails,
     destinationEndpoint: Endpoint,
     updateData: UpdateData,
     defaultStorage: string | null | undefined,
     storageConfigDefault: string,
-  ): Promise<Execution> {
+  }): Promise<Execution> {
+    const {
+      replica, destinationEndpoint, updateData, defaultStorage, storageConfigDefault,
+    } = options
+
     const parser = OptionsSchemaPlugin.for(destinationEndpoint.type)
     const payload: any = { replica: {} }
 
     if (updateData.network.length > 0) {
       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) {
-      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) {

+ 36 - 2
src/sources/WizardSource.ts

@@ -23,6 +23,7 @@ import type { StorageMap } from '../@types/Endpoint'
 import type { InstanceScript } from '../@types/Instance'
 import DefaultOptionsSchemaParser from '../plugins/endpoint/default/OptionsSchemaPlugin'
 import { TransferItem } from '../@types/MainItem'
+import { INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS } from '../components/organisms/WizardOptions/WizardOptions'
 
 class WizardSource {
   async create(
@@ -40,7 +41,6 @@ class WizardSource {
     payload[type] = {
       origin_endpoint_id: data.source ? data.source.id : 'null',
       destination_endpoint_id: data.target ? data.target.id : 'null',
-      destination_environment: destParser.getDestinationEnv(data.destOptions),
       network_map: destParser.getNetworkMap(data.networks),
       instances: data.selectedInstances ? data.selectedInstances.map(i => i.instance_name || i.name) : 'null',
       storage_mappings: destParser.getStorageMap(defaultStorage, storageMap),
@@ -52,9 +52,43 @@ class WizardSource {
     }
 
     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') {
       payload[type].shutdown_instances = Boolean(
         data.destOptions && data.destOptions.shutdown_instances,

+ 13 - 2
src/stores/MigrationStore.ts

@@ -75,6 +75,7 @@ class MigrationStore {
     const migrationResult = await MigrationSource.recreate({
       sourceEndpoint,
       destEndpoint,
+      migration,
       instanceNames: migration.instances,
       sourceEnv: migration.source_environment,
       updatedSourceEnv: updateData.source,
@@ -119,8 +120,18 @@ class MigrationStore {
     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(() => {
       this.migrations = [
         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 {
       const executionTasks = await ReplicaSource.getExecutionTasks({
-        replicaId, executionId: this.currentlyLoadingExecution, polling,
+        replicaId,
+        executionId: this.currentlyLoadingExecution,
+        polling,
       })
       runInAction(() => {
         this.executionsTasks = [
@@ -225,20 +227,14 @@ class ReplicaStore {
     }
   }
 
-  async update(
+  async update(options: {
     replica: ReplicaItemDetails,
     destinationEndpoint: Endpoint,
     updateData: UpdateData,
     defaultStorage: string | null | undefined,
     storageConfigDefault: string,
-  ) {
-    await ReplicaSource.update(
-      replica,
-      destinationEndpoint,
-      updateData,
-      defaultStorage,
-      storageConfigDefault,
-    )
+  }) {
+    await ReplicaSource.update(options)
   }
 
   testReplicaHasDisks(replica: ReplicaItemDetails | null) {

+ 16 - 3
src/stores/WizardStore.ts

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

Некоторые файлы не были показаны из-за большого количества измененных файлов