Forráskód Böngészése

Merge pull request #488 from smiclea/user-scripts

Integrate migration custom user scripts API
Nashwan Azhari 6 éve
szülő
commit
5839bd3a3d

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

@@ -36,6 +36,7 @@ type Props = {
   marginLeft?: ?number,
   marginBottom?: ?number,
   className?: string,
+  style?: any,
   warning?: boolean,
   filled?: boolean,
 }
@@ -50,6 +51,7 @@ class InfoIcon extends React.Component<Props> {
         className={this.props.className}
         warning={this.props.warning}
         filled={this.props.filled}
+        style={this.props.style}
       />
     )
   }

+ 114 - 23
src/components/organisms/ReplicaMigrationOptions/ReplicaMigrationOptions.jsx

@@ -20,24 +20,43 @@ import styled from 'styled-components'
 
 import Button from '../../atoms/Button'
 import FieldInput from '../../molecules/FieldInput'
+import ToggleButtonBar from '../../atoms/ToggleButtonBar'
+import WizardScripts from '../../organisms/WizardScripts'
 
 import LabelDictionary from '../../../utils/LabelDictionary'
 import KeyboardManager from '../../../utils/KeyboardManager'
+import StyleProps from '../../styleUtils/StyleProps'
+
 import replicaMigrationImage from './images/replica-migration.svg'
+
 import type { Field } from '../../../types/Field'
+import type { Instance, InstanceScript } from '../../../types/Instance'
 
 const Wrapper = styled.div`
   display: flex;
   flex-direction: column;
   align-items: center;
   padding: 0 32px 32px 32px;
+  min-height: 0;
 `
 const Image = styled.div`
-  width: 288px;
-  height: 96px;
+  ${StyleProps.exactWidth('288px')}
+  ${StyleProps.exactHeight('96px')}
   background: url('${replicaMigrationImage}') center no-repeat;
   margin: 80px 0;
 `
+const OptionsBody = styled.div`
+  display: flex;
+  flex-direction: column;
+`
+const ScriptsBody = styled.div`
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  overflow: auto;
+  min-height: 0;
+  margin-bottom: 32px;
+`
 const Form = styled.div`
   display: flex;
   flex-wrap: wrap;
@@ -57,11 +76,16 @@ const FieldInputStyled = styled(FieldInput)`
 `
 
 type Props = {
+  instances: Instance[],
+  loadingInstances: boolean,
   onCancelClick: () => void,
-  onMigrateClick: (fields: Field[]) => void,
+  onMigrateClick: (fields: Field[], uploadedScripts: InstanceScript[]) => void,
+  onResizeUpdate?: (scrollableRef: HTMLElement, scrollOffset?: number) => void,
 }
 type State = {
   fields: Field[],
+  selectedBarButton: string,
+  uploadedScripts: InstanceScript[],
 }
 let defaultFields: Field[] = [
   {
@@ -82,16 +106,32 @@ let defaultFields: Field[] = [
 class ReplicaMigrationOptions extends React.Component<Props, State> {
   state = {
     fields: [...defaultFields],
+    selectedBarButton: 'options',
+    uploadedScripts: [],
   }
 
+  scrollableRef: HTMLElement
+
   componentDidMount() {
-    KeyboardManager.onEnter('migration-options', () => { this.props.onMigrateClick(this.state.fields) }, 2)
+    KeyboardManager.onEnter('migration-options', () => { this.migrate() }, 2)
+  }
+
+  componentDidUpdate(prevProps: Props, prevState: State) {
+    if (prevState.selectedBarButton !== this.state.selectedBarButton) {
+      if (this.props.onResizeUpdate) {
+        this.props.onResizeUpdate(this.scrollableRef, 0)
+      }
+    }
   }
 
   componentWillUnmount() {
     KeyboardManager.removeKeyDown('migration-options')
   }
 
+  migrate() {
+    this.props.onMigrateClick(this.state.fields, this.state.uploadedScripts)
+  }
+
   handleValueChange(field: Field, value: boolean) {
     let fields = this.state.fields.map(f => {
       let newField = { ...f }
@@ -104,31 +144,82 @@ class ReplicaMigrationOptions extends React.Component<Props, State> {
     this.setState({ fields })
   }
 
+  handleCanceScript(global: ?string, instanceName: ?string) {
+    this.setState({
+      uploadedScripts: this.state.uploadedScripts.filter(s => global ? s.global !== global : s.instanceName !== instanceName),
+    })
+  }
+
+  handleScriptUpload(script: InstanceScript) {
+    this.setState({
+      uploadedScripts: [
+        ...this.state.uploadedScripts,
+        script,
+      ],
+    })
+  }
+
+  renderOptions() {
+    return (
+      <Form>
+        {this.state.fields.map(field => {
+          return (
+            <FieldInputStyled
+              width={200}
+              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)}
+            />
+          )
+        })}
+      </Form>
+    )
+  }
+
+  renderScripts() {
+    return (
+      <WizardScripts
+        instances={this.props.instances}
+        loadingInstances={this.props.loadingInstances}
+        onScriptUpload={s => { this.handleScriptUpload(s) }}
+        onCancelScript={(g, i) => { this.handleCanceScript(g, i) }}
+        uploadedScripts={this.state.uploadedScripts}
+        scrollableRef={r => { this.scrollableRef = r }}
+        layout="modal"
+      />
+    )
+  }
+
+  renderBody() {
+    let Body = this.state.selectedBarButton === 'options' ? OptionsBody : ScriptsBody
+
+    return (
+      <Body>
+        <ToggleButtonBar
+          items={[{ label: 'Options', value: 'options' }, { label: 'User Scripts', value: 'script' }]}
+          selectedValue={this.state.selectedBarButton}
+          onChange={item => { this.setState({ selectedBarButton: item.value }) }}
+          style={{ marginBottom: '32px' }}
+        />
+        {this.state.selectedBarButton === 'options' ? this.renderOptions() : this.renderScripts()}
+      </Body>
+    )
+  }
+
   render() {
     return (
       <Wrapper>
         <Image />
-        <Form>
-          {this.state.fields.map(field => {
-            return (
-              <FieldInputStyled
-                width={200}
-                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)}
-              />
-            )
-          })}
-        </Form>
+        {this.renderBody()}
         <Buttons>
           <Button secondary onClick={this.props.onCancelClick} data-test-id="rmOptions-cancelButton">Cancel</Button>
-          <Button onClick={() => { this.props.onMigrateClick(this.state.fields) }} data-test-id="rmOptions-execButton">Migrate</Button>
+          <Button onClick={() => { this.migrate() }} data-test-id="rmOptions-execButton">Migrate</Button>
         </Buttons>
       </Wrapper>
     )

+ 7 - 1
src/components/organisms/ReplicaMigrationOptions/test.jsx

@@ -20,7 +20,13 @@ import sinon from 'sinon'
 import TW from '../../../utils/TestWrapper'
 import ReplicaMigrationOptions from '.'
 
-const wrap = props => new TW(shallow(<ReplicaMigrationOptions onMigrateClick={() => { }} {...props} />), 'rmOptions')
+const wrap = props => new TW(shallow(
+  <ReplicaMigrationOptions
+    instances={[]}
+    onMigrateClick={() => { }}
+    loadingInstances={false}
+    {...props}
+  />), 'rmOptions')
 
 describe('ReplicaMigrationOptions Component', () => {
   it('dispatches cancel click', () => {

+ 2 - 3
src/components/organisms/WizardInstances/WizardInstances.jsx

@@ -89,9 +89,8 @@ const LoadingText = styled.div`
   margin-top: 38px;
   font-size: 18px;
 `
-const Image = styled.div`
-  width: 48px;
-  height: 48px;
+export const Image = styled.div`
+  ${StyleProps.exactSize('48px')}
   background: url('${instanceImage}') center no-repeat;
 `
 const Label = styled.div`

+ 16 - 1
src/components/organisms/WizardPageContent/WizardPageContent.jsx

@@ -28,6 +28,7 @@ import WizardInstances from '../WizardInstances'
 import WizardNetworks from '../WizardNetworks'
 import WizardStorage from '../WizardStorage'
 import WizardOptions from '../WizardOptions'
+import WizardScripts from '../WizardScripts'
 import Schedule from '../Schedule'
 import WizardSummary from '../WizardSummary'
 
@@ -38,7 +39,7 @@ import configLoader from '../../../utils/Config'
 
 import type { WizardData, WizardPage } from '../../../types/WizardData'
 import type { Endpoint, StorageBackend, StorageMap } from '../../../types/Endpoint'
-import type { Instance, Nic, Disk } from '../../../types/Instance'
+import type { Instance, Nic, Disk, InstanceScript } from '../../../types/Instance'
 import type { Field } from '../../../types/Field'
 import type { Network, SecurityGroup } from '../../../types/Network'
 import type { Schedule as ScheduleType } from '../../../types/Schedule'
@@ -174,6 +175,7 @@ type Props = {
   hasStorageMap: boolean,
   hasSourceOptions: boolean,
   pages: WizardPage[],
+  uploadedUserScripts: InstanceScript[],
   onTypeChange: (isReplicaChecked: ?boolean) => void,
   onBackClick: () => void,
   onNextClick: () => void,
@@ -194,6 +196,8 @@ type Props = {
   onContentRef: (ref: any) => void,
   onReloadOptionsClick: () => void,
   onReloadNetworksClick: () => void,
+  onUserScriptUpload: (instanceScript: InstanceScript) => void,
+  onCancelUploadedScript: (global: ?string, instanceName: ?string) => void,
 }
 type TimezoneValue = 'local' | 'utc'
 type State = {
@@ -461,6 +465,16 @@ class WizardPageContent extends React.Component<Props, State> {
           />
         )
         break
+      case 'scripts':
+        body = (
+          <WizardScripts
+            instances={this.props.instanceStore.instancesDetails}
+            onScriptUpload={this.props.onUserScriptUpload}
+            onCancelScript={this.props.onCancelUploadedScript}
+            uploadedScripts={this.props.uploadedUserScripts}
+          />
+        )
+        break
       case 'schedule':
         body = (
           <Schedule
@@ -484,6 +498,7 @@ class WizardPageContent extends React.Component<Props, State> {
             instancesDetails={this.props.instanceStore.instancesDetails}
             sourceSchema={this.props.providerStore.sourceSchema}
             destinationSchema={this.props.providerStore.destinationSchema}
+            uploadedUserScripts={this.props.uploadedUserScripts}
           />
         )
         break

+ 285 - 0
src/components/organisms/WizardScripts/WizardScripts.jsx

@@ -0,0 +1,285 @@
+/*
+Copyright (C) 2019  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+
+import InfoIcon from '../../atoms/InfoIcon'
+import { Close as InputClose } from '../../atoms/TextInput'
+import { Image as InstanceImage } from '../WizardInstances'
+import StatusIcon from '../../atoms/StatusIcon'
+
+import StyleProps from '../../styleUtils/StyleProps'
+import Palette from '../../styleUtils/Palette'
+import ObjectUtils from '../../../utils/ObjectUtils'
+
+import scriptItemImage from './images/script-item.svg'
+
+import type { Instance, InstanceScript } from '../../../types/Instance'
+
+const Wrapper = styled.div`
+  width: 100%;
+  display: flex;
+  overflow: auto;
+  flex-direction: column;
+  min-height: 0;
+`
+const Group = styled.div`
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  margin-bottom: 32px;
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+`
+const Heading = styled.div`
+  margin-bottom: 16px;
+  font-size: ${props => props.layout === 'modal' ? '16px' : '24px'};
+  font-weight: ${props => props.layout === 'modal' ? StyleProps.fontWeights.medium : StyleProps.fontWeights.light};
+  display: flex;
+`
+const InfoIconStyled = styled(InfoIcon)`
+  margin-top: ${props => props.layout === 'modal' ? '1px' : '5px'};
+  margin-left: 8px;
+`
+const Scripts = styled.div`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+`
+const Script = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  flex-shrink: 0;
+  border-top: 1px solid ${Palette.grayscale[1]};
+  padding: 8px 0;
+
+  &:last-child {
+    border-bottom: 1px solid ${Palette.grayscale[1]};
+  }
+`
+const Name = styled.div`
+  display: flex;
+  align-items: center;
+`
+const OsImage = styled.div`
+  ${StyleProps.exactSize('48px')}
+  background: url('${scriptItemImage}') center no-repeat;
+`
+const NameLabel = styled.div`
+  display: flex;
+  flex-direction: column;
+  margin-left: 16px;
+`
+const NameLabelTitle = styled.div`
+  font-size: 16px;
+  word-break: break-word;
+`
+const NameLabelSubtitle = styled.div`
+  font-size: 12px;
+  color: ${Palette.grayscale[5]};
+  margin-top: 1px;
+  word-break: break-word;
+`
+const Link = styled.div`
+  color: ${Palette.primary};
+  flex-shrink: 0;
+  margin: 0 8px 0 16px;
+  cursor: pointer;
+  :hover {
+    text-decoration: underline;
+  }
+`
+const UploadedScript = styled.div`
+  display: flex;
+  position: relative;
+`
+const UploadedScriptFileName = styled.div`
+  max-width: 124px;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  margin-right: 32px;
+  white-space: nowrap;
+`
+const InputCloseStyled = styled(InputClose)`
+  top: 0px;
+`
+const FakeFileInput = styled.input`
+  position: absolute;
+  opacity: 0;
+  top: -99999px;
+`
+
+type Props = {
+  instances: Instance[],
+  uploadedScripts: InstanceScript[],
+  layout?: 'modal' | 'page',
+  loadingInstances?: boolean,
+  onScriptUpload: (instanceScript: InstanceScript) => void,
+  onCancelScript: (global: ?string, instanceName: ?string) => void,
+  onScrollableRef?: (ref: HTMLElement) => void,
+}
+type FileInputRefs = {
+  [string]: {
+    inputRef: HTMLInputElement,
+  }
+}
+@observer
+class WizardScripts extends React.Component<Props> {
+  fileInputRefs: FileInputRefs = {}
+
+  async handleFileUpload(files: FileList, global: ?string, instanceName: ?string) {
+    if (!files.length) {
+      return
+    }
+    let fileName = files[0].name
+    let scriptContent = await ObjectUtils.readFromFileList(files)
+    this.props.onScriptUpload({
+      instanceName,
+      global,
+      fileName,
+      scriptContent: scriptContent || '',
+    })
+  }
+
+  renderScriptItem(
+    global: ?string,
+    instanceName: ?string,
+    title: string,
+    subtitle?: string
+  ) {
+    let uploadedScript = this.props.uploadedScripts.find(
+      s => s.instanceName ? s.instanceName === instanceName : s.global ? s.global === global : false)
+
+    return (
+      <Script key={title}>
+        <Name>
+          {global ? <OsImage /> : <InstanceImage />}
+          <NameLabel>
+            <NameLabelTitle>{title}</NameLabelTitle>
+            {subtitle ? <NameLabelSubtitle>{subtitle}</NameLabelSubtitle> : null}
+          </NameLabel>
+        </Name>
+        {uploadedScript ? (
+          <UploadedScript>
+            <UploadedScriptFileName title={uploadedScript.fileName}>{uploadedScript.fileName}</UploadedScriptFileName>
+            <InputCloseStyled
+              show
+              onClick={() => {
+                this.props.onCancelScript(global, instanceName)
+                let ref = this.fileInputRefs[title]
+                if (ref) {
+                  ref.inputRef.value = ''
+                }
+              }
+              }
+            />
+          </UploadedScript>
+        ) :
+          (
+            <Link onClick={() => {
+              let ref = this.fileInputRefs[title]
+              if (ref) {
+                ref.inputRef.click()
+              }
+            }}
+            >Choose File...</Link>
+          )}
+        <FakeFileInput
+          type="file"
+          innerRef={r => { this.fileInputRefs[title] = { inputRef: r } }}
+          onChange={e => { this.handleFileUpload(e.target.files, global, instanceName) }}
+        />
+      </Script>
+    )
+  }
+
+  renderScriptGroup(group: 'global' | 'instance') {
+    if (group === 'global') {
+      return (
+        <Group>
+          <Heading
+            layout={this.props.layout}
+          >
+            Global Scripts
+            <InfoIconStyled
+              layout={this.props.layout}
+              text="Specify user scripts that will run during OS morphing for a particular OS type"
+            />
+          </Heading>
+          <Scripts>
+            {this.renderScriptItem('windows', null, 'Windows Script File')}
+            {this.renderScriptItem('linux', null, 'Linux Script File')}
+          </Scripts>
+        </Group>
+      )
+    }
+
+    if (this.props.instances.length === 0 && !this.props.loadingInstances) {
+      return null
+    }
+
+    return (
+      <Group layout={this.props.layout}>
+        <Heading
+          layout={this.props.layout}
+        >
+          Instance Scripts
+          {!this.props.loadingInstances ? (
+            <InfoIconStyled
+              layout={this.props.layout}
+              text="Specify user scripts that will run during OS morphing for a particular instance"
+            />
+          ) : null}
+          {this.props.loadingInstances ? (
+            <StatusIcon style={{ marginTop: '1px', marginLeft: '8px' }} status="RUNNING" />
+          ) : null}
+        </Heading>
+        <Scripts>
+          {this.props.instances.map(instance => {
+            let title = instance.instance_name || instance.name
+            let osLabel = instance.os_type ? instance.os_type === 'windows' ? 'Windows' : instance.os_type === 'linux' ? 'Linux' : instance.os_type : ''
+            let osType = osLabel ? `${osLabel} OS | ` : ''
+            let subtitle = `${osType}${instance.num_cpu} vCPU | ${instance.memory_mb} MB RAM`
+
+            return this.renderScriptItem(null, title, title, subtitle)
+          })}
+        </Scripts>
+      </Group>
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper innerRef={r => {
+        if (this.props.onScrollableRef) {
+          this.props.onScrollableRef(r)
+        }
+      }}
+      >
+        {this.renderScriptGroup('global')}
+        {this.renderScriptGroup('instance')}
+      </Wrapper>
+    )
+  }
+}
+
+export default WizardScripts

+ 19 - 0
src/components/organisms/WizardScripts/images/script-item.svg

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g transform="translate(-255.000000, -709.000000)">
+            <g transform="translate(224.000000, 608.000000)">
+                <g transform="translate(31.000000, 101.000000)">
+                    <path d="M10.2555408,-4.14336123e-16 L37.7444592,4.14336123e-16 C41.3105342,-2.4074122e-16 42.6036791,0.371302445 43.9073828,1.06853082 C45.2110865,1.76575919 46.2342408,2.78891348 46.9314692,4.09261719 C47.6286976,5.39632089 48,6.68946584 48,10.2555408 L48,37.7444592 C48,41.3105342 47.6286976,42.6036791 46.9314692,43.9073828 C46.2342408,45.2110865 45.2110865,46.2342408 43.9073828,46.9314692 C42.6036791,47.6286976 41.3105342,48 37.7444592,48 L10.2555408,48 C6.68946584,48 5.39632089,47.6286976 4.09261719,46.9314692 C2.78891348,46.2342408 1.76575919,45.2110865 1.06853082,43.9073828 C0.371302445,42.6036791 1.60494146e-16,41.3105342 -2.76224082e-16,37.7444592 L2.76224082e-16,10.2555408 C-1.60494146e-16,6.68946584 0.371302445,5.39632089 1.06853082,4.09261719 C1.76575919,2.78891348 2.78891348,1.76575919 4.09261719,1.06853082 C5.39632089,0.371302445 6.68946584,2.4074122e-16 10.2555408,-4.14336123e-16 Z" fill="#D8DBE2"></path>
+                    <g transform="translate(10.000000, 12.000000)" fill="#0044CA" fill-rule="nonzero">
+                        <g id="icon-(1)">
+                            <path d="M24.948871,0.176806452 L1.9196129,0.176806452 C0.859645161,0.176806452 0,1.03470968 0,2.09467742 L0,21.2890645 C0,22.3481613 0.859645161,23.2086774 1.9196129,23.2086774 L24.948871,23.2086774 C26.0070968,23.2086774 26.8702258,22.3481613 26.8702258,21.2890645 L26.8702258,2.09467742 C26.8702258,1.03470968 26.0062258,0.176806452 24.948871,0.176806452 Z M6.23787097,1.6173871 C6.76654839,1.6173871 7.19767742,2.04503226 7.19767742,2.57806452 C7.19767742,3.10674194 6.76741935,3.5343871 6.23787097,3.5343871 C5.70570968,3.5343871 5.27632258,3.1076129 5.27632258,2.57806452 C5.27719355,2.04590323 5.70570968,1.6173871 6.23787097,1.6173871 Z M3.35758065,1.6173871 C3.88712903,1.6173871 4.3173871,2.04503226 4.3173871,2.57806452 C4.3173871,3.10674194 3.888,3.5343871 3.35758065,3.5343871 C2.82716129,3.5343871 2.39690323,3.1076129 2.39690323,2.57806452 C2.39777419,2.04590323 2.82716129,1.6173871 3.35758065,1.6173871 Z M24.948871,21.2899355 L1.9196129,21.2899355 L1.9196129,4.995 L24.948871,4.995 L24.948871,21.2899355 Z" id="Shape"></path>
+                            <polygon points="10.7573226 8.9396129 4.59435484 6.12116129 4.59435484 7.48074194 9.35941935 9.51880645 9.35941935 9.54406452 4.59435484 11.5786452 4.59435484 12.9373548 10.7573226 10.1189032"></polygon>
+                            <rect x="13.6585161" y="8.883" width="3.35758065" height="1.29425806"></rect>
+                        </g>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

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

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

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

@@ -29,7 +29,7 @@ import { migrationFields } from '../../../constants'
 import type { Schedule } from '../../../types/Schedule'
 import type { WizardData } from '../../../types/WizardData'
 import type { StorageMap, StorageBackend } from '../../../types/Endpoint'
-import type { Instance, Disk } from '../../../types/Instance'
+import type { Instance, Disk, InstanceScript } from '../../../types/Instance'
 import type { Field } from '../../../types/Field'
 
 import fieldHelper from '../../../types/Field'
@@ -99,6 +99,14 @@ const Row = styled.div`
     border-bottom: 1px solid ${Palette.grayscale[1]};
   }
 `
+const ScriptFileName = styled.div`
+  max-width: 124px;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  margin-left: 16px;
+  white-space: nowrap;
+  flex-shrink: 0;
+`
 const InstanceRowTitle = styled.div`
   margin-bottom: 4px;
 `
@@ -163,6 +171,7 @@ type Props = {
   instancesDetails: Instance[],
   sourceSchema: Field[],
   destinationSchema: Field[],
+  uploadedUserScripts: InstanceScript[],
 }
 @observer
 class WizardSummary extends React.Component<Props> {
@@ -440,6 +449,36 @@ class WizardSummary extends React.Component<Props> {
     )
   }
 
+  renderUserScripts() {
+    if (this.props.uploadedUserScripts.length === 0) {
+      return null
+    }
+
+    return (
+      <Section>
+        <SectionTitle>Uploaded User Scripts</SectionTitle>
+        <Table>
+          {this.props.uploadedUserScripts.map(s => (
+            <Row
+              key={s.instanceName || s.global}
+              style={{
+                flexDirection: 'row',
+                justifyContent: 'space-between',
+                flexShrink: 0,
+                alignItems: 'center',
+              }}
+            >
+              <InstanceRowTitle>{
+                s.global ? s.global === 'windows' ? 'Global Windows Script' : 'Global Linux Script' : s.instanceName
+              }</InstanceRowTitle>
+              <ScriptFileName title={s.fileName}>{s.fileName}</ScriptFileName>
+            </Row>
+          ))}
+        </Table>
+      </Section>
+    )
+  }
+
   renderOverviewSection() {
     let data = this.props.data
     let type = this.props.wizardType.charAt(0).toUpperCase() + this.props.wizardType.substr(1)
@@ -495,6 +534,7 @@ class WizardSummary extends React.Component<Props> {
           {this.renderOverviewSection()}
           {this.renderInstancesSection()}
           {this.renderNetworksSection()}
+          {this.renderUserScripts()}
         </Column>
         <Column>
           {this.renderSourceOptionsSection()}

+ 15 - 10
src/components/pages/MigrationDetailsPage/MigrationDetailsPage.jsx

@@ -40,6 +40,7 @@ import migrationImage from './images/migration.svg'
 import Palette from '../../styleUtils/Palette'
 
 import type { Field } from '../../../types/Field'
+import type { InstanceScript } from '../../../types/Instance'
 
 const Wrapper = styled.div``
 
@@ -52,6 +53,7 @@ type State = {
   showCancelConfirmation: boolean,
   showEditModal: boolean,
   showFromReplicaModal: boolean,
+  pausePolling: boolean,
 }
 @observer
 class MigrationDetailsPage extends React.Component<Props, State> {
@@ -60,6 +62,7 @@ class MigrationDetailsPage extends React.Component<Props, State> {
     showCancelConfirmation: false,
     showEditModal: false,
     showFromReplicaModal: false,
+    pausePolling: false,
   }
 
   stopPolling: ?boolean
@@ -168,14 +171,14 @@ class MigrationDetailsPage extends React.Component<Props, State> {
   handleRecreateClick() {
     let replicaId = migrationStore.migrationDetails && migrationStore.migrationDetails.replica_id
     if (!replicaId) {
-      this.setState({ showEditModal: true })
+      this.setState({ showEditModal: true, pausePolling: true })
       return
     }
-    this.setState({ showFromReplicaModal: true })
+    this.setState({ showFromReplicaModal: true, pausePolling: true })
   }
 
   handleCloseFromReplicaModal() {
-    this.setState({ showFromReplicaModal: false })
+    this.setState({ showFromReplicaModal: false, pausePolling: false })
   }
 
   handleCloseCancelConfirmation() {
@@ -191,23 +194,23 @@ class MigrationDetailsPage extends React.Component<Props, State> {
     notificationStore.alert('Canceled', 'success')
   }
 
-  async recreateFromReplica(options: Field[]) {
+  async recreateFromReplica(options: Field[], userScripts: InstanceScript[]) {
     let replicaId = migrationStore.migrationDetails && migrationStore.migrationDetails.replica_id
     if (!replicaId) {
       return
     }
 
-    this.migrate(replicaId, options)
+    this.migrate(replicaId, options, userScripts)
     this.handleCloseFromReplicaModal()
   }
 
-  async migrate(replicaId: string, options: Field[]) {
-    let migration = await migrationStore.migrateReplica(replicaId, options)
+  async migrate(replicaId: string, options: Field[], userScripts: InstanceScript[]) {
+    let migration = await migrationStore.migrateReplica(replicaId, options, userScripts)
     this.props.history.push(`/migration/tasks/${migration.id}`)
   }
 
   async pollData() {
-    if (this.state.showEditModal || this.stopPolling) {
+    if (this.state.pausePolling || this.stopPolling) {
       return
     }
     await migrationStore.getMigration(this.props.match.params.id, { showLoading: false, skipLog: true })
@@ -219,7 +222,7 @@ class MigrationDetailsPage extends React.Component<Props, State> {
   }
 
   closeEditModal() {
-    this.setState({ showEditModal: false }, () => {
+    this.setState({ showEditModal: false, pausePolling: false }, () => {
       this.pollData()
     })
   }
@@ -327,7 +330,9 @@ class MigrationDetailsPage extends React.Component<Props, State> {
           >
             <ReplicaMigrationOptions
               onCancelClick={() => { this.handleCloseFromReplicaModal() }}
-              onMigrateClick={options => { this.recreateFromReplica(options) }}
+              onMigrateClick={(o, s) => { this.recreateFromReplica(o, s) }}
+              instances={instanceStore.instancesDetails}
+              loadingInstances={instanceStore.loadingInstancesDetails}
             />
           </Modal>
         ) : null}

+ 30 - 19
src/components/pages/ReplicaDetailsPage/ReplicaDetailsPage.jsx

@@ -29,6 +29,7 @@ import EditReplica from '../../organisms/EditReplica'
 import ReplicaMigrationOptions from '../../organisms/ReplicaMigrationOptions'
 
 import type { MainItem } from '../../../types/MainItem'
+import type { InstanceScript } from '../../../types/Instance'
 import type { Execution } from '../../../types/Execution'
 import type { Schedule } from '../../../types/Schedule'
 import type { Field } from '../../../types/Field'
@@ -66,6 +67,7 @@ type State = {
   confirmationItem: ?MainItem | ?Execution,
   showCancelConfirmation: boolean,
   isEditable: boolean,
+  pausePolling: boolean,
 }
 @observer
 class ReplicaDetailsPage extends React.Component<Props, State> {
@@ -79,6 +81,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     confirmationItem: null,
     showCancelConfirmation: false,
     isEditable: false,
+    pausePolling: false,
   }
 
   stopPolling: ?boolean
@@ -263,15 +266,15 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
   }
 
   handleCloseMigrationModal() {
-    this.setState({ showMigrationModal: false })
+    this.setState({ showMigrationModal: false, pausePolling: false })
   }
 
   handleCreateMigrationClick() {
-    this.setState({ showMigrationModal: true })
+    this.setState({ showMigrationModal: true, pausePolling: true })
   }
 
   handleReplicaEditClick() {
-    this.setState({ showEditModal: true })
+    this.setState({ showEditModal: true, pausePolling: true })
   }
 
   handleAddScheduleClick(schedule: Schedule) {
@@ -319,13 +322,17 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     this.setState({ showCancelConfirmation: false })
   }
 
-  migrateReplica(options: Field[]) {
-    this.migrate(options)
+  migrateReplica(options: Field[], uploadedScripts: InstanceScript[]) {
+    this.migrate(options, uploadedScripts)
     this.handleCloseMigrationModal()
   }
 
-  async migrate(options: Field[]) {
-    let migration = await migrationStore.migrateReplica(replicaStore.replicaDetails ? replicaStore.replicaDetails.id : '', options)
+  async migrate(options: Field[], uploadedScripts: InstanceScript[]) {
+    let migration = await migrationStore.migrateReplica(
+      replicaStore.replicaDetails ? replicaStore.replicaDetails.id : '',
+      options,
+      uploadedScripts
+    )
     notificationStore.alert('Migration successfully created from replica.', 'success', {
       action: {
         label: 'View Migration Status',
@@ -343,7 +350,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
   }
 
   async pollData(showLoading: boolean) {
-    if (this.state.showEditModal || this.stopPolling) {
+    if (this.state.pausePolling || this.stopPolling) {
       return
     }
 
@@ -356,7 +363,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
   }
 
   closeEditModal() {
-    this.setState({ showEditModal: false }, () => {
+    this.setState({ showEditModal: false, pausePolling: false }, () => {
       this.pollData(false)
     })
   }
@@ -478,16 +485,20 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
             onExecuteClick={fields => { this.executeReplica(fields) }}
           />
         </Modal>
-        <Modal
-          isOpen={this.state.showMigrationModal}
-          title="Create Migration from Replica"
-          onRequestClose={() => { this.handleCloseMigrationModal() }}
-        >
-          <ReplicaMigrationOptions
-            onCancelClick={() => { this.handleCloseMigrationModal() }}
-            onMigrateClick={options => { this.migrateReplica(options) }}
-          />
-        </Modal>
+        {this.state.showMigrationModal ? (
+          <Modal
+            isOpen
+            title="Create Migration from Replica"
+            onRequestClose={() => { this.handleCloseMigrationModal() }}
+          >
+            <ReplicaMigrationOptions
+              loadingInstances={instanceStore.loadingInstancesDetails}
+              instances={instanceStore.instancesDetails}
+              onCancelClick={() => { this.handleCloseMigrationModal() }}
+              onMigrateClick={(o, s) => { this.migrateReplica(o, s) }}
+            />
+          </Modal>
+        ) : null}
         <AlertModal
           isOpen={this.state.showDeleteExecutionConfirmation}
           title="Delete Execution?"

+ 28 - 9
src/components/pages/ReplicasPage/ReplicasPage.jsx

@@ -31,6 +31,7 @@ import ReplicaMigrationOptions from '../../organisms/ReplicaMigrationOptions'
 import type { MainItem } from '../../../types/MainItem'
 import type { Action as DropdownAction } from '../../molecules/ActionDropdown'
 import type { Field } from '../../../types/Field'
+import type { InstanceScript } from '../../../types/Instance'
 
 import replicaItemImage from './images/replica.svg'
 import replicaLargeImage from './images/replica-large.svg'
@@ -39,6 +40,7 @@ import projectStore from '../../../stores/ProjectStore'
 import replicaStore from '../../../stores/ReplicaStore'
 import migrationStore from '../../../stores/MigrationStore'
 import scheduleStore from '../../../stores/ScheduleStore'
+import instanceStore from '../../../stores/InstanceStore'
 import endpointStore from '../../../stores/EndpointStore'
 import notificationStore from '../../../stores/NotificationStore'
 
@@ -142,14 +144,17 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
     this.setState({ showExecutionOptionsModal: false })
   }
 
-  migrateSelectedReplicas(fields: Field[]) {
+  migrateSelectedReplicas(fields: Field[], uploadedScripts: InstanceScript[]) {
     notificationStore.alert('Creating migrations from selected replicas')
-    this.migrate(fields)
-    this.setState({ showCreateMigrationsModal: false })
+    this.migrate(fields, uploadedScripts)
+    this.setState({ showCreateMigrationsModal: false, modalIsOpen: false })
   }
 
-  async migrate(fields: Field[]) {
-    await Promise.all(this.state.selectedReplicas.map(replica => migrationStore.migrateReplica(replica.id, fields)))
+  async migrate(fields: Field[], uploadedScripts: InstanceScript[]) {
+    await Promise.all(this.state.selectedReplicas.map(replica =>
+      migrationStore.migrateReplica(replica.id, fields,
+        uploadedScripts.filter(s => !s.instanceName || replica.instances.find(i => i === s.instanceName))
+      )))
     notificationStore.alert('Migrations successfully created from replicas.', 'success')
     this.props.history.push('/migrations')
   }
@@ -215,6 +220,16 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
     })
   }
 
+  handleShowCreateMigrationsModal() {
+    instanceStore.loadInstancesDetailsBulk(replicaStore.replicas.map(r => ({
+      endpointId: r.origin_endpoint_id,
+      instanceNames: r.instances,
+      env: r.source_environment,
+    })))
+
+    this.setState({ showCreateMigrationsModal: true, modalIsOpen: true })
+  }
+
   async pollData() {
     if (this.state.modalIsOpen || this.stopPolling) {
       return
@@ -294,7 +309,7 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
     }, {
       label: 'Create Migrations',
       color: Palette.primary,
-      action: () => { this.setState({ showCreateMigrationsModal: true }) },
+      action: () => { this.handleShowCreateMigrationsModal() },
     }, {
       label: 'Delete Disks',
       action: () => { this.setState({ showDeleteDisksModal: true }) },
@@ -388,11 +403,15 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
           <Modal
             isOpen
             title="Create Migrations from Selected Replicas"
-            onRequestClose={() => { this.setState({ showCreateMigrationsModal: false }) }}
+            onRequestClose={() => { this.setState({ showCreateMigrationsModal: false, modalIsOpen: false }) }}
           >
             <ReplicaMigrationOptions
-              onCancelClick={() => { this.setState({ showCreateMigrationsModal: false }) }}
-              onMigrateClick={options => { this.migrateSelectedReplicas(options) }}
+              instances={instanceStore.instancesDetails}
+              loadingInstances={instanceStore.loadingInstancesDetails}
+              onCancelClick={() => {
+                this.setState({ showCreateMigrationsModal: false, modalIsOpen: false })
+              }}
+              onMigrateClick={(options, s) => { this.migrateSelectedReplicas(options, s) }}
             />
           </Modal>
         ) : null}

+ 25 - 3
src/components/pages/WizardPage/WizardPage.jsx

@@ -40,7 +40,7 @@ import configLoader from '../../../utils/Config'
 
 import type { MainItem } from '../../../types/MainItem'
 import type { Endpoint as EndpointType, StorageBackend } from '../../../types/Endpoint'
-import type { Instance, Nic, Disk } from '../../../types/Instance'
+import type { Instance, Nic, Disk, InstanceScript } from '../../../types/Instance'
 import type { Field } from '../../../types/Field'
 import type { Network, SecurityGroup } from '../../../types/Network'
 import type { Schedule } from '../../../types/Schedule'
@@ -289,6 +289,7 @@ class WizardPage extends React.Component<Props, State> {
 
   handleInstanceClick(instance: Instance) {
     wizardStore.updateData({ networks: null })
+    wizardStore.clearUploadedUserScripts()
     wizardStore.clearStorageMap()
     wizardStore.toggleInstanceSelection(instance)
     wizardStore.updateUrlState()
@@ -481,7 +482,12 @@ class WizardPage extends React.Component<Props, State> {
   async createMultiple() {
     let typeLabel = this.state.type.charAt(0).toUpperCase() + this.state.type.substr(1)
     notificationStore.alert(`Creating ${typeLabel}s ...`)
-    await wizardStore.createMultiple(this.state.type, wizardStore.data, wizardStore.storageMap)
+    await wizardStore.createMultiple(
+      this.state.type,
+      wizardStore.data,
+      wizardStore.storageMap,
+      wizardStore.uploadedUserScripts,
+    )
     let items = wizardStore.createdItems
     if (!items) {
       notificationStore.alert(`${typeLabel}s couldn't be created`, 'error')
@@ -495,7 +501,12 @@ class WizardPage extends React.Component<Props, State> {
     let typeLabel = this.state.type.charAt(0).toUpperCase() + this.state.type.substr(1)
     notificationStore.alert(`Creating ${typeLabel} ...`)
     try {
-      await wizardStore.create(this.state.type, wizardStore.data, wizardStore.storageMap)
+      await wizardStore.create(
+        this.state.type,
+        wizardStore.data,
+        wizardStore.storageMap,
+        wizardStore.uploadedUserScripts,
+      )
       let item = wizardStore.createdItem
       if (!item) {
         notificationStore.alert(`${typeLabel} couldn't be created`, 'error')
@@ -560,6 +571,14 @@ class WizardPage extends React.Component<Props, State> {
     replicaStore.execute(replica.id, executeNowOptions)
   }
 
+  handleCancelUploadedScript(global: ?string, instanceName: ?string) {
+    wizardStore.cancelUploadedScript(global, instanceName)
+  }
+
+  handleUserScriptUpload(instanceScript: InstanceScript) {
+    wizardStore.uploadUserScript(instanceScript)
+  }
+
   render() {
     return (
       <Wrapper>
@@ -602,6 +621,9 @@ class WizardPage extends React.Component<Props, State> {
             onContentRef={ref => { this.contentRef = ref }}
             onReloadOptionsClick={() => { this.handleReloadOptionsClick() }}
             onReloadNetworksClick={() => { this.loadNetworks(false) }}
+            uploadedUserScripts={wizardStore.uploadedUserScripts}
+            onCancelUploadedScript={(g, i) => { this.handleCancelUploadedScript(g, i) }}
+            onUserScriptUpload={s => { this.handleUserScriptUpload(s) }}
           />}
         />
         <Modal

+ 1 - 0
src/constants.js

@@ -106,6 +106,7 @@ export const wizardPages = [
   { id: 'dest-options', title: 'Target options', breadcrumb: 'Target Options' },
   { id: 'networks', title: 'Networks', breadcrumb: 'Networks' },
   { id: 'storage', title: 'Storage Mapping', breadcrumb: 'Storage' },
+  { id: 'scripts', title: 'User Scripts', breadcrumb: 'Scripts', excludeFrom: 'replica' },
   { id: 'schedule', title: 'Schedule', breadcrumb: 'Schedule', excludeFrom: 'migration' },
   { id: 'summary', title: 'Summary', breadcrumb: 'Summary' },
 ]

+ 20 - 0
src/plugins/endpoint/default/OptionsSchemaPlugin.js

@@ -22,6 +22,7 @@ import type { Field } from '../../../types/Field'
 import type { OptionValues, StorageMap } from '../../../types/Endpoint'
 import type { SchemaProperties, SchemaDefinitions } from '../../../types/Schema'
 import type { NetworkMap } from '../../../types/Network'
+import type { InstanceScript } from '../../../types/Instance'
 import { executionOptions, migrationFields } from '../../../constants'
 
 const migrationImageOsTypes = ['windows', 'linux']
@@ -200,5 +201,24 @@ export default class OptionsSchemaParser {
     })
     return payload
   }
+
+  static getUserScripts(uploadedUserScripts: InstanceScript[]) {
+    let payload = {}
+    let globalScripts = uploadedUserScripts.filter(s => s.global)
+    if (globalScripts.length) {
+      payload.global = {}
+      globalScripts.forEach(script => {
+        payload.global[script.global || ''] = script.scriptContent
+      })
+    }
+    let instanceScripts = uploadedUserScripts.filter(s => s.instanceName)
+    if (instanceScripts.length) {
+      payload.instances = {}
+      instanceScripts.forEach(script => {
+        payload.instances[script.instanceName || ''] = script.scriptContent
+      })
+    }
+    return payload
+  }
 }
 

+ 7 - 2
src/sources/MigrationSource.js

@@ -21,6 +21,7 @@ import { sortTasks } from './ReplicaSource'
 
 import Api from '../utils/ApiCaller'
 import type { MainItem } from '../types/MainItem'
+import type { InstanceScript } from '../types/Instance'
 import type { Field } from '../types/Field'
 import type { NetworkMap } from '../types/Network'
 import type { Endpoint, StorageMap } from '../types/Endpoint'
@@ -157,8 +158,8 @@ class MigrationSource {
     return migrationId
   }
 
-  async migrateReplica(replicaId: string, options: Field[]): Promise<MainItem> {
-    let payload = {
+  async migrateReplica(replicaId: string, options: Field[], userScripts: InstanceScript[]): Promise<MainItem> {
+    let payload: any = {
       migration: {
         replica_id: replicaId,
       },
@@ -167,6 +168,10 @@ class MigrationSource {
       payload.migration[o.name] = o.value || o.default || false
     })
 
+    if (userScripts.length) {
+      payload.migration.user_scripts = OptionsSchemaPlugin.default.getUserScripts(userScripts)
+    }
+
     let response = await Api.send({
       url: `${servicesUrl.coriolis}/${Api.projectId}/migrations`,
       method: 'POST',

+ 17 - 3
src/sources/WizardSource.js

@@ -22,9 +22,15 @@ import { servicesUrl } from '../constants'
 import type { WizardData } from '../types/WizardData'
 import type { StorageMap } from '../types/Endpoint'
 import type { MainItem } from '../types/MainItem'
+import type { InstanceScript } from '../types/Instance'
 
 class WizardSource {
-  async create(type: string, data: WizardData, storageMap: StorageMap[]): Promise<MainItem> {
+  async create(
+    type: string,
+    data: WizardData,
+    storageMap: StorageMap[],
+    uploadedUserScripts: InstanceScript[]
+  ): Promise<MainItem> {
     const sourceParser = data.source ? OptionsSchemaPlugin[data.source.type] || OptionsSchemaPlugin.default : OptionsSchemaPlugin.default
     const destParser = data.target ? OptionsSchemaPlugin[data.target.type] || OptionsSchemaPlugin.default : OptionsSchemaPlugin.default
     let payload = {}
@@ -50,6 +56,9 @@ class WizardSource {
     if (type === 'migration') {
       payload[type].shutdown_instances = Boolean(data.destOptions && data.destOptions.shutdown_instances)
       payload[type].replication_count = (data.destOptions && data.destOptions.replication_count) || 2
+      if (uploadedUserScripts.length) {
+        payload[type].user_scripts = destParser.getUserScripts(uploadedUserScripts)
+      }
     }
 
     let response = await Api.send({
@@ -60,7 +69,12 @@ class WizardSource {
     return response.data[type]
   }
 
-  async createMultiple(type: string, data: WizardData, storageMap: StorageMap[]): Promise<MainItem[]> {
+  async createMultiple(
+    type: string,
+    data: WizardData,
+    storageMap: StorageMap[],
+    uploadedUserScripts: InstanceScript[]
+  ): Promise<MainItem[]> {
     if (!data.selectedInstances) {
       throw new Error('No selected instances')
     }
@@ -68,7 +82,7 @@ class WizardSource {
       let newData = { ...data }
       newData.selectedInstances = [instance]
       try {
-        let mainItem: MainItem = await this.create(type, newData, storageMap)
+        let mainItem: MainItem = await this.create(type, newData, storageMap, uploadedUserScripts)
         return mainItem
       } catch (err) {
         notificationStore.alert(`Error while creating ${type} for instance ${instance.name}`, 'error')

+ 29 - 0
src/stores/InstanceStore.js

@@ -215,6 +215,35 @@ class InstanceStore {
     this.instancesPerPage = instancesPerPage
   }
 
+  @action async loadInstancesDetailsBulk(
+    instanceInfos: {
+      endpointId: string,
+      instanceNames: string[],
+      env?: ?any,
+    }[]
+  ) {
+    this.reqId = !this.reqId ? 1 : this.reqId + 1
+    this.instancesDetails = []
+    this.loadingInstancesDetails = true
+    InstanceSource.cancelInstancesDetailsRequests(this.reqId - 1)
+    try {
+      await Promise.all(instanceInfos.map(async i => {
+        await Promise.all(i.instanceNames.map(async name => {
+          let instanceDetails = await InstanceSource.loadInstanceDetails(i.endpointId, name, this.reqId, false,
+            i.env, true)
+          runInAction(() => {
+            this.instancesDetails = this.instancesDetails.filter(i => (i.name || i.instance_name || '') !== name)
+            this.instancesDetails.push(instanceDetails.instance)
+            this.instancesDetails.sort(n => (n.name || n.instance_name || '')
+              .localeCompare(n.name || n.instance_name || ''))
+          })
+        }))
+      }))
+    } finally {
+      this.loadingInstancesDetails = false
+    }
+  }
+
   @action async loadInstancesDetails(opts: {
     endpointId: string,
     instancesInfo: Instance[],

+ 3 - 2
src/stores/MigrationStore.js

@@ -19,6 +19,7 @@ import { observable, action, runInAction } from 'mobx'
 import type { MainItem, UpdateData } from '../types/MainItem'
 import type { Field } from '../types/Field'
 import type { Endpoint } from '../types/Endpoint'
+import type { InstanceScript } from '../types/Instance'
 import MigrationSource from '../sources/MigrationSource'
 
 class MigrationStore {
@@ -101,8 +102,8 @@ class MigrationStore {
     runInAction(() => { this.migrations = this.migrations.filter(r => r.id !== migrationId) })
   }
 
-  @action async migrateReplica(replicaId: string, options: Field[]) {
-    let migration = await MigrationSource.migrateReplica(replicaId, options)
+  @action async migrateReplica(replicaId: string, options: Field[], userScripts: InstanceScript[]) {
+    let migration = await MigrationSource.migrateReplica(replicaId, options, userScripts)
     runInAction(() => {
       this.migrations = [
         migration,

+ 31 - 5
src/stores/WizardStore.js

@@ -18,7 +18,7 @@ import { observable, action, runInAction } from 'mobx'
 
 import type { WizardData, WizardPage } from '../types/WizardData'
 import type { MainItem } from '../types/MainItem'
-import type { Instance } from '../types/Instance'
+import type { Instance, InstanceScript } from '../types/Instance'
 import type { Field } from '../types/Field'
 import type { NetworkMap } from '../types/Network'
 import type { StorageMap } from '../types/Endpoint'
@@ -60,6 +60,7 @@ class WizardStore {
   @observable creatingItem: boolean = false
   @observable createdItems: ?MainItem[] = null
   @observable creatingItems: boolean = false
+  @observable uploadedUserScripts: InstanceScript[] = []
 
   @action updateData(data: WizardData) {
     this.data = { ...this.data, ...data }
@@ -147,11 +148,16 @@ class WizardStore {
     this.schedules = this.schedules.filter(s => s.id !== scheduleId)
   }
 
-  @action async create(type: string, data: WizardData, storageMap: StorageMap[]): Promise<void> {
+  @action async create(
+    type: string,
+    data: WizardData,
+    storageMap: StorageMap[],
+    uploadedUserScripts: InstanceScript[]
+  ): Promise<void> {
     this.creatingItem = true
 
     try {
-      let item: MainItem = await source.create(type, data, storageMap)
+      let item: MainItem = await source.create(type, data, storageMap, uploadedUserScripts)
       runInAction(() => { this.createdItem = item })
     } catch (err) {
       throw err
@@ -160,11 +166,16 @@ class WizardStore {
     }
   }
 
-  @action async createMultiple(type: string, data: WizardData, storageMap: StorageMap[]): Promise<void> {
+  @action async createMultiple(
+    type: string,
+    data: WizardData,
+    storageMap: StorageMap[],
+    uploadedUserScripts: InstanceScript[]
+  ): Promise<void> {
     this.creatingItems = true
 
     try {
-      let items: MainItem[] = await source.createMultiple(type, data, storageMap)
+      let items: MainItem[] = await source.createMultiple(type, data, storageMap, uploadedUserScripts)
       runInAction(() => { this.createdItems = items })
     } finally {
       runInAction(() => { this.creatingItems = false })
@@ -184,6 +195,21 @@ class WizardStore {
     this.schedules = state.schedules
     this.storageMap = state.storageMap
   }
+
+  @action cancelUploadedScript(global: ?string, instanceName: ?string) {
+    this.uploadedUserScripts = this.uploadedUserScripts.filter(s => global ? s.global !== global : s.instanceName !== instanceName)
+  }
+
+  @action uploadUserScript(instanceScript: InstanceScript) {
+    this.uploadedUserScripts = [
+      ...this.uploadedUserScripts,
+      instanceScript,
+    ]
+  }
+
+  @action clearUploadedUserScripts() {
+    this.uploadedUserScripts = []
+  }
 }
 
 export default new WizardStore()

+ 7 - 0
src/types/Instance.js

@@ -44,3 +44,10 @@ export type Instance = {
     disks: Disk[],
   },
 }
+
+export type InstanceScript = {
+  global?: ?string,
+  instanceName?: ?string,
+  scriptContent: string,
+  fileName: string,
+}