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

Add Coriolis Minion Pools operations support

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

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

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

This Replica / Migration Minion Pool support is available throughout
the UI where their associated operations are available, like editing
replicas and migrations and creating a migration from replica.
Sergiu Miclea 5 лет назад
Родитель
Сommit
84a91b8587
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)
   }

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