Quellcode durchsuchen

Add OCI provider support

Includes:
- OCI endpoint destination options calls (both OCI and Openstack make
`destionation-options` API calls by default)
- OCI logos
- OCI connection info schema plugin (use `TextArea` component for
private key field)
Sergiu Miclea vor 8 Jahren
Ursprung
Commit
7c55f0990e

+ 1 - 1
src/components/atoms/CopyButton/index.jsx

@@ -24,7 +24,7 @@ import copyImage from './images/copy.svg'
 
 const Wrapper = styled.span`
   opacity: 0;
-  width: 16px;
+  min-width: 16px;
   height: 16px;
   display: inline-block;
   background: url('${copyImage}') no-repeat;

Datei-Diff unterdrückt, da er zu groß ist
+ 7 - 0
src/components/atoms/EndpointLogos/images/oci-128-disabled.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 7 - 0
src/components/atoms/EndpointLogos/images/oci-128.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 7 - 0
src/components/atoms/EndpointLogos/images/oci-32.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 7 - 0
src/components/atoms/EndpointLogos/images/oci-42.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 7 - 0
src/components/atoms/EndpointLogos/images/oci-64.svg


+ 14 - 2
src/components/atoms/EndpointLogos/index.jsx

@@ -26,6 +26,7 @@ import opc32Image from './images/opc-32.svg'
 import openstack32Image from './images/openstack-32.svg'
 import oraclevm32Image from './images/oraclevm-32.svg'
 import vmware32Image from './images/vmware-32.svg'
+import oci32Image from './images/oci-32.svg'
 
 import aws42Image from './images/aws-42.svg'
 import azure42Image from './images/azure-42.svg'
@@ -33,6 +34,7 @@ import opc42Image from './images/opc-42.svg'
 import openstack42Image from './images/openstack-42.svg'
 import oraclevm42Image from './images/oraclevm-42.svg'
 import vmware42Image from './images/vmware-42.svg'
+import oci42Image from './images/oci-42.svg'
 
 import aws64Image from './images/aws-64.svg'
 import azure64Image from './images/azure-64.svg'
@@ -40,6 +42,7 @@ import opc64Image from './images/opc-64.svg'
 import openstack64Image from './images/openstack-64.svg'
 import oraclevm64Image from './images/oraclevm-64.svg'
 import vmware64Image from './images/vmware-64.svg'
+import oci64Image from './images/oci-64.svg'
 
 import aws128Image from './images/aws-128.svg'
 import azure128Image from './images/azure-128.svg'
@@ -47,6 +50,7 @@ import opc128Image from './images/opc-128.svg'
 import openstack128Image from './images/openstack-128.svg'
 import oraclevm128Image from './images/oraclevm-128.svg'
 import vmware128Image from './images/vmware-128.svg'
+import oci128Image from './images/oci-128.svg'
 
 import aws128DisabledImage from './images/aws-128-disabled.svg'
 import azure128DisabledImage from './images/azure-128-disabled.svg'
@@ -54,6 +58,7 @@ import opc128DisabledImage from './images/opc-128-disabled.svg'
 import openstack128DisabledImage from './images/openstack-128-disabled.svg'
 import oraclevm128DisabledImage from './images/oraclevm-128-disabled.svg'
 import vmware128DisabledImage from './images/vmware-128-disabled.svg'
+import oci128DisabledImage from './images/oci-128-disabled.svg'
 
 const endpointImages = {
   azure: [
@@ -98,6 +103,13 @@ const endpointImages = {
     { h: 128, image: aws128Image },
     { h: 128, image: aws128DisabledImage, disabled: true },
   ],
+  oci: [
+    { h: 32, image: oci32Image },
+    { h: 42, image: oci42Image },
+    { h: 64, image: oci64Image },
+    { h: 128, image: oci128Image },
+    { h: 128, image: oci128DisabledImage, disabled: true },
+  ],
 }
 const Wrapper = styled.div``
 const Logo = styled.div`
@@ -127,7 +139,7 @@ class EndpointLogos extends React.Component<Props> {
     height: 64,
   }
 
-  renderLogo(size: {w: number, h: number}) {
+  renderLogo(size: { w: number, h: number }) {
     let imageInfo = null
 
     if (this.props.endpoint && endpointImages[this.props.endpoint]) {
@@ -149,7 +161,7 @@ class EndpointLogos extends React.Component<Props> {
     )
   }
 
-  renderGenericLogo(size: {w: number, h: number}) {
+  renderGenericLogo(size: { w: number, h: number }) {
     return (
       <Generic
         data-test-id="endpointLogos-genericLogo"

+ 19 - 0
src/components/molecules/EndpointField/index.jsx

@@ -24,6 +24,7 @@ import RadioInput from '../../atoms/RadioInput'
 import InfoIcon from '../../atoms/InfoIcon'
 import Dropdown from '../../molecules/Dropdown'
 import DropdownInput from '../../molecules/DropdownInput'
+import TextArea from '../../atoms/TextArea'
 import type { Field as FieldType } from '../../../types/Field'
 
 import LabelDictionary from '../../../utils/LabelDictionary'
@@ -59,6 +60,7 @@ type Props = {
   disabled: boolean,
   enum?: string[],
   items?: FieldType[],
+  useTextArea?: boolean,
 }
 @observer
 class Field extends React.Component<Props> {
@@ -89,6 +91,20 @@ class Field extends React.Component<Props> {
     )
   }
 
+  renderTextArea() {
+    return (
+      <TextArea
+        style={{ width: '100%' }}
+        required={this.props.required}
+        highlight={this.props.highlight}
+        value={this.props.value}
+        onChange={e => { if (this.props.onChange) this.props.onChange(e.target.value) }}
+        placeholder={LabelDictionary.get(this.props.name)}
+        disabled={this.props.disabled}
+      />
+    )
+  }
+
   renderEnumDropdown() {
     if (!this.props.enum) {
       return null
@@ -186,6 +202,9 @@ class Field extends React.Component<Props> {
         if (this.props.enum) {
           return this.renderEnumDropdown()
         }
+        if (this.props.useTextArea) {
+          return this.renderTextArea()
+        }
         return this.renderTextInput()
       case 'integer':
         if (this.props.minimum || this.props.maximum) {

+ 4 - 2
src/components/molecules/Table/index.jsx

@@ -86,7 +86,9 @@ const Row = styled.div`
 const RowData = styled.div`
   ${props => TableData(props)}
   color: ${Palette.grayscale[4]};
-  ${props => props.customStyle}
+  ${props => props.customStyle};
+  overflow: hidden;
+  text-overflow: ellipsis;
 `
 const NoItems = styled.div`
   text-align: center;
@@ -144,7 +146,7 @@ class Table extends React.Component<Props> {
       return null
     }
 
-    let dataWidth = `${100 / this.props.items.length}%`
+    let dataWidth = `${100 / this.props.header.length}%`
     return (
       <Body customStyle={this.props.bodyStyle}>
         {this.props.items.map((row, i) => {

+ 1 - 1
src/components/molecules/WizardOptionsField/index.jsx

@@ -110,7 +110,7 @@ class WizardOptionsField extends React.Component<Props> {
   renderEnumDropdown() {
     let items = this.props.enum.map(e => {
       return {
-        label: typeof e === 'string' ? LabelDictionary.get(e) : e.name,
+        label: typeof e === 'string' ? e : e.name,
         value: typeof e === 'string' ? e : e.id,
       }
     })

+ 5 - 5
src/components/organisms/Endpoint/index.jsx

@@ -372,6 +372,11 @@ class Endpoint extends React.Component<Props, State> {
     }
     return (
       <Content>
+        {/* Fix browsers autofilling password fields */}
+        <div style={{ position: 'absolute', left: '-10000px' }}>
+          <input name="username" type="text" />
+          <input name="password" type="password" />
+        </div>
         {this.renderEndpointStatus()}
         {React.createElement(ContentPlugin[endpointType] || ContentPlugin.default, {
           connectionInfoSchema: providerStore.connectionInfoSchema,
@@ -394,11 +399,6 @@ class Endpoint extends React.Component<Props, State> {
         {this.renderButtons()}
         <Tooltip />
         {Tooltip.rebuild()}
-        {/* Fix browsers autofilling password fields */}
-        <div style={{ position: 'absolute', left: '-10000px' }}>
-          <input type="text" />
-          <input type="password" />
-        </div>
       </Content>
     )
   }

+ 6 - 2
src/components/organisms/MainDetails/index.jsx

@@ -98,6 +98,9 @@ const PropertyRow = styled.div`
 const PropertyText = css``
 const PropertyName = styled.div`
   ${PropertyText}
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 50%;
 `
 const PropertyValue = styled.div`
   ${PropertyText}
@@ -105,6 +108,7 @@ const PropertyValue = styled.div`
   text-align: right;
   overflow: hidden;
   text-overflow: ellipsis;
+  max-width: 50%;
 `
 
 type Props = {
@@ -266,7 +270,7 @@ class MainDetails extends React.Component<Props> {
           return (
             <PropertyRow key={prop.label}>
               <PropertyName>{prop.label}</PropertyName>
-              <PropertyValue>{prop.value}</PropertyValue>
+              <PropertyValue><CopyValue value={prop.value} /></PropertyValue>
             </PropertyRow>
           )
         })}
@@ -335,7 +339,7 @@ class MainDetails extends React.Component<Props> {
         <Column width="17.5%">
           <Arrow />
         </Column>
-        <Column width="auto" style={{ flexGrow: 1 }}>
+        <Column width="48%" style={{ flexGrow: 1 }}>
           <Row>
             <Field>
               <Label>Target</Label>

+ 24 - 6
src/components/pages/EndpointsPage/index.jsx

@@ -38,6 +38,7 @@ import endpointStore from '../../../stores/EndpointStore'
 import migrationStore from '../../../stores/MigrationStore'
 import replicaStore from '../../../stores/ReplicaStore'
 import providerStore from '../../../stores/ProviderStore'
+import notificationStore from '../../../stores/NotificationStore'
 import LabelDictionary from '../../../utils/LabelDictionary'
 import { requestPollTimeout } from '../../../config.js'
 import EndpointDuplicateOptions from '../../organisms/EndpointDuplicateOptions'
@@ -87,7 +88,7 @@ class EndpointsPage extends React.Component<{}, State> {
     projectStore.getProjects()
 
     this.stopPolling = false
-    this.pollData()
+    this.pollData(true)
   }
 
   componentWillUnmount() {
@@ -172,7 +173,11 @@ class EndpointsPage extends React.Component<{}, State> {
     let items = this.state.confirmationItems || []
     Promise.all(items.map(endpoint => {
       return EndpointSource.getConnectionInfo(endpoint).then(connectionInfo => {
-        endpoints.push({ ...endpoint, connection_info: connectionInfo })
+        endpoints.push({
+          ...endpoint,
+          connection_info: connectionInfo,
+          name: `${endpoint.name}${!switchProject ? ' (copy)' : ''}`,
+        })
       })
     })).then(() => {
       if (switchProject) {
@@ -184,10 +189,23 @@ class EndpointsPage extends React.Component<{}, State> {
     }).then(() => {
       return Promise.all(endpoints.map(endpoint => {
         return EndpointSource.add(endpoint, true)
-      }))
+      }).map(p => p.catch(e => e))).then(results => {
+        let internalServerErrors = results.filter(r => r.status && r.status === 500)
+        if (internalServerErrors.length > 0) {
+          notificationStore.notify(`There was a problem duplicating ${internalServerErrors.length} endpoint${internalServerErrors.length > 1 ? 's' : ''}`, 'error')
+        }
+        let forbiddenErrors = results.filter(r => r.status && r.status === 403)
+        if (forbiddenErrors.length > 0 && forbiddenErrors[0].data && forbiddenErrors[0].data.description) {
+          notificationStore.notify(String(forbiddenErrors[0].data.description), 'error')
+        }
+      })
+    }).catch(e => {
+      if (e.data && e.data.description) {
+        notificationStore.notify(e.data.description, 'error')
+      }
     }).then(() => {
+      this.pollData(true)
       this.setState({ showDuplicateModal: false, duplicating: false })
-      this.pollData()
     })
   }
 
@@ -238,12 +256,12 @@ class EndpointsPage extends React.Component<{}, State> {
     })
   }
 
-  pollData() {
+  pollData(showLoading?: boolean = false) {
     if (this.state.modalIsOpen || this.stopPolling) {
       return
     }
 
-    Promise.all([endpointStore.getEndpoints(), migrationStore.getMigrations(), replicaStore.getReplicas()]).then(() => {
+    Promise.all([endpointStore.getEndpoints({ showLoading }), migrationStore.getMigrations(), replicaStore.getReplicas()]).then(() => {
       this.pollTimeout = setTimeout(() => { this.pollData() }, requestPollTimeout)
     })
   }

+ 17 - 1
src/components/pages/WizardPage/index.jsx

@@ -34,7 +34,8 @@ import notificationStore from '../../../stores/NotificationStore'
 import scheduleStore from '../../../stores/ScheduleStore'
 import replicaStore from '../../../stores/ReplicaStore'
 import KeyboardManager from '../../../utils/KeyboardManager'
-import { wizardConfig, executionOptions } from '../../../config'
+import O from '../../../utils/ObjectUtils'
+import { wizardConfig, executionOptions, providersWithExtraOptions } from '../../../config'
 import type { MainItem } from '../../../types/MainItem'
 import type { Endpoint as EndpointType } from '../../../types/Endpoint'
 import type { Instance, Nic } from '../../../types/Instance'
@@ -238,6 +239,21 @@ class WizardPage extends React.Component<Props, State> {
   handleOptionsChange(field: Field, value: any) {
     wizardStore.updateData({ networks: null })
     wizardStore.updateOptions({ field, value })
+
+    let provider = wizardStore.data.target && wizardStore.data.target.type
+    let providerWithExtraOptions = providersWithExtraOptions.find(p => typeof p !== 'string' && p.name === provider)
+    if (provider && providerWithExtraOptions && typeof providerWithExtraOptions !== 'string' && providerWithExtraOptions.envRequestFields) {
+      let validFields = providerWithExtraOptions.envRequestFields.filter(fn => wizardStore.data.options ? O.isValid(wizardStore.data.options[fn]) : false)
+      if (
+        validFields.length === providerWithExtraOptions.envRequestFields.length &&
+        wizardStore.data.options &&
+        wizardStore.data.target &&
+        validFields.find(fn => fn === field.name)
+      ) {
+        providerStore.getDestinationOptions(wizardStore.data.target.id, provider, wizardStore.data.options)
+      }
+    }
+
     wizardStore.setPermalink(wizardStore.data)
   }
 

+ 10 - 2
src/config.js

@@ -81,7 +81,15 @@ export const wizardConfig = {
   instancesItemsPerPage: 6,
 }
 
-// Providers for which `destination-options` API call will be made
-export const providersWithExtraOptions = []
+// Providers for which `destination-options` API call(s) will be made
+// If item is an object and has `envRequestFields` array,
+// subsequent requests to `destination-options` will be made with those fields, if they are set
+export const providersWithExtraOptions = [
+  'openstack',
+  {
+    name: 'oci',
+    envRequestFields: ['compartment', 'availability_domain'],
+  },
+]
 
 export const basename = process.env.PUBLIC_PATH

+ 4 - 4
src/plugins/endpoint/default/ContentPlugin.jsx

@@ -31,8 +31,8 @@ export const Fields = styled.div`
   flex-direction: column;
   overflow: auto;
 `
-export const FieldStyled = styled(EndpointField)`
-  min-width: 224px;
+export const FieldStyled = styled(EndpointField) `
+  min-width: ${props => props.useTextArea ? '100%' : '224px'};
   max-width: 224px;
   margin-bottom: 16px;
 `
@@ -89,14 +89,14 @@ class ContentPlugin extends React.Component<Props> {
           onChange={value => { this.props.handleFieldChange(field, value) }}
         />
       )
-      if (i % 2 !== 0) {
+      if (i % 2 !== 0 && !field.useTextArea && !this.props.connectionInfoSchema[i - 1].useTextArea) {
         rows.push((
           <Row key={field.name}>
             {lastField}
             {currentField}
           </Row>
         ))
-      } else if (i === this.props.connectionInfoSchema.length - 1) {
+      } else if (field.useTextArea || i === this.props.connectionInfoSchema.length - 1) {
         rows.push((
           <Row key={field.name}>
             {currentField}

+ 2 - 0
src/plugins/endpoint/index.js

@@ -20,11 +20,13 @@ import DefaultContentPlugin from './default/ContentPlugin'
 import AzureContentPlugin from './azure/ContentPlugin'
 import OpenstackContentPlugin from './openstack/ContentPlugin'
 import OpenstackSchemaPlugin from './openstack/SchemaPlugin'
+import OciSchemaPlugin from './oci/SchemaPlugin'
 
 export const SchemaPlugin = {
   default: DefaultSchemaPlugin,
   azure: AzureSchemaPlugin,
   openstack: OpenstackSchemaPlugin,
+  oci: OciSchemaPlugin,
 }
 
 export const ContentPlugin = {

+ 37 - 0
src/plugins/endpoint/oci/SchemaPlugin.js

@@ -0,0 +1,37 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import type { Schema } from '../../../types/Schema'
+import type { Field } from '../../../types/Field'
+
+import DefaultConnectionSchemaParser from '../default/SchemaPlugin'
+
+export default class ConnectionSchemaParser {
+  static parseSchemaToFields(schema: Schema): Field[] {
+    let fields = DefaultConnectionSchemaParser.parseSchemaToFields(schema)
+    let privateKeyField = fields.find(f => f.name === 'private_key_data')
+    if (privateKeyField) {
+      privateKeyField.useTextArea = true
+    }
+
+    return fields
+  }
+
+  static parseFieldsToPayload(data: { [string]: mixed }, schema: Schema) {
+    let payload = DefaultConnectionSchemaParser.parseFieldsToPayload(data, schema)
+    return payload
+  }
+}

+ 5 - 1
src/sources/EndpointSource.js

@@ -86,9 +86,13 @@ class EdnpointSource {
     let index = endpoint.connection_info.secret_ref && endpoint.connection_info.secret_ref.lastIndexOf('/')
     let uuid = index && endpoint.connection_info.secret_ref && endpoint.connection_info.secret_ref.substr(index + 1)
 
+    if (!uuid) {
+      return Promise.resolve(endpoint.connection_info)
+    }
+
     return new Promise((resolve, reject) => {
       Api.send({
-        url: `${servicesUrl.barbican}/v1/secrets/${uuid || ''}/payload`,
+        url: `${servicesUrl.barbican}/v1/secrets/${uuid || 'undefined'}/payload`,
         responseType: 'text',
         headers: { Accept: 'text/plain' },
       }).then((response) => {

+ 6 - 2
src/sources/ProviderSource.js

@@ -59,11 +59,15 @@ class ProviderSource {
     })
   }
 
-  static getDestinationOptions(endpointId: string): Promise<DestinationOption[]> {
+  static getDestinationOptions(endpointId: string, envData: ?{ [string]: mixed }): Promise<DestinationOption[]> {
     return new Promise((resolve, reject) => {
       let projectId = cookie.get('projectId')
+      let envString = ''
+      if (envData) {
+        envString = `?env=${btoa(JSON.stringify(envData))}`
+      }
 
-      Api.get(`${servicesUrl.coriolis}/${projectId || 'null'}/endpoints/${endpointId}/destination-options`).then(response => {
+      Api.get(`${servicesUrl.coriolis}/${projectId || 'null'}/endpoints/${endpointId}/destination-options${envString}`).then(response => {
         let options = response.data.destination_options
         resolve(options)
       }).catch(() => { reject() })

+ 1 - 1
src/stores/EndpointStore.js

@@ -38,7 +38,7 @@ class EndpointStore {
   @observable connectionsInfoLoading = false
 
   @action getEndpoints(options?: { showLoading: boolean }) {
-    if ((options && options.showLoading) || this.endpoints.length === 0) {
+    if (options && options.showLoading) {
       this.loading = true
     }
 

+ 18 - 4
src/stores/ProviderStore.js

@@ -32,6 +32,8 @@ class ProviderStore {
   @observable destinationOptions: DestinationOption[] = []
   @observable destinationOptionsLoading: boolean = false
 
+  lastOptionsSchemaType: string = ''
+
   @action getConnectionInfoSchema(providerName: string): Promise<void> {
     this.connectionSchemaLoading = true
 
@@ -61,6 +63,7 @@ class ProviderStore {
 
   @action loadOptionsSchema(providerName: string, schemaType: string): Promise<void> {
     this.optionsSchemaLoading = true
+    this.lastOptionsSchemaType = schemaType
 
     return ProviderSource.loadOptionsSchema(providerName, schemaType).then((fields: Field[]) => {
       this.optionsSchemaLoading = false
@@ -70,13 +73,14 @@ class ProviderStore {
     })
   }
 
-  @action getDestinationOptions(endpointId: string, provider: string): Promise<void> {
-    if (!providersWithExtraOptions.find(p => p === provider)) {
+  @action getDestinationOptions(endpointId: string, provider: string, envData?: { [string]: mixed }): Promise<void> {
+    let providerWithExtraOptions = providersWithExtraOptions.find(p => typeof p === 'string' ? p === provider : p.name === provider)
+    if (!providerWithExtraOptions) {
       return Promise.resolve()
     }
 
     this.destinationOptionsLoading = true
-    return ProviderSource.getDestinationOptions(endpointId).then(options => {
+    return ProviderSource.getDestinationOptions(endpointId, envData).then(options => {
       this.optionsSchema.forEach(field => {
         let fieldValues = options.find(f => f.name === field.name)
         if (fieldValues) {
@@ -89,7 +93,17 @@ class ProviderStore {
       })
       this.destinationOptions = options
       this.destinationOptionsLoading = false
-    }).catch(() => { this.destinationOptionsLoading = false })
+    }).catch(() => {
+      if (envData) {
+        return this.loadOptionsSchema(provider, this.lastOptionsSchemaType).then(() => {
+          return this.getDestinationOptions(endpointId, provider)
+        })
+      }
+      return this.loadOptionsSchema(provider, this.lastOptionsSchemaType)
+    }).then(() => {
+      this.destinationOptions = []
+      this.destinationOptionsLoading = false
+    })
   }
 }
 

+ 1 - 0
src/types/Field.js

@@ -28,4 +28,5 @@ export type Field = {
   parent?: string,
   properties?: Field[],
   required?: boolean,
+  useTextArea?: boolean,
 }

+ 6 - 5
src/utils/ApiCaller.js

@@ -24,14 +24,15 @@ type Cancelable = {
   cancel: () => void,
 }
 
-type RequestOptions = {|
+type RequestOptions = {
   url: string,
   method?: string,
   cancelId?: string,
-  headers?: {[string]: string},
+  headers?: { [string]: string },
   data?: any,
   responseType?: 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream',
-|}
+  quietError?: boolean,
+}
 
 let cancelables: Cancelable[] = []
 const CancelToken = axios.CancelToken
@@ -71,7 +72,7 @@ class ApiCaller {
       }
 
       if (options.cancelId) {
-        let cancel = () => {}
+        let cancel = () => { }
         axiosOptions.cancelToken = new CancelToken(c => {
           cancel = c
         })
@@ -89,7 +90,7 @@ class ApiCaller {
         if (error.response) {
           // The request was made and the server responded with a status code
           // that falls out of the range of 2xx
-          if (error.response.status !== 401 || window.location.hash !== loginUrl) {
+          if ((error.response.status !== 401 || window.location.hash !== loginUrl) && !options.quietError && error.response.data.error) {
             notificationStore.notify(error.response.data.error.message, 'error')
           }
 

+ 1 - 0
src/utils/LabelDictionary.js

@@ -95,6 +95,7 @@ class LabelDictionary {
     opc: 'Oracle Cloud',
     azure: 'Azure',
     vmware_vsphere: 'VMware',
+    oci: 'OCI',
     separate_vm: 'Separate Migration/VM?',
     use_replica: 'Use replica',
     windows_migr_image: { label: 'Windows Migration Image', description: 'The Windows Migration Image information found on the Azure page' },

+ 4 - 0
src/utils/ObjectUtils.js

@@ -54,6 +54,10 @@ class ObjectUtils {
 
     return result
   }
+
+  static isValid(value: any): boolean {
+    return value !== null && value !== undefined
+  }
 }
 
 export default ObjectUtils

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.