Преглед изворни кода

Merge pull request #454 from smiclea/export-endpoint

Add ability to export / import endpoints from JSON
Nashwan Azhari пре 6 година
родитељ
комит
362250fa8f

+ 1 - 0
package.json

@@ -58,6 +58,7 @@
   },
   "dependencies": {
     "@webpack-blocks/webpack2": "^0.4.0",
+    "adm-zip": "^0.4.13",
     "autobind-decorator": "^2.1.0",
     "axios": "^0.18.0",
     "babel-core": "^6.26.0",

+ 3 - 9
server/main.js

@@ -17,12 +17,12 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import express from 'express'
 import fs from 'fs'
 import path from 'path'
-import requireWithoutCache from 'require-without-cache'
 
-import packageJson from '../package.json'
+import router from './router'
 
 // Create our app
 const app = express()
+
 const PORT = process.env.PORT || 3000
 const isDev = process.argv.find(a => a === '--dev')
 const CORIOLIS_URL = process.env.CORIOLIS_URL || '/'
@@ -47,13 +47,7 @@ app.use(express.static('dist'))
 
 require('./proxy')(app)
 
-// $FlowIgnore
-app.get('/version', (req, res) => { res.send({ version: packageJson.version }) })
-
-// $FlowIgnore
-app.get('/config', (req, res) => {
-  res.send(requireWithoutCache('../config.js', require).config)
-})
+app.use('/api', router)
 
 if (isDev) {
   // $FlowIgnore

+ 64 - 0
server/router.js

@@ -0,0 +1,64 @@
+/*
+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 requireWithoutCache from 'require-without-cache'
+import express from 'express'
+import bodyParser from 'body-parser'
+import AdmZip from 'adm-zip'
+import stream from 'stream'
+
+import packageJson from '../package.json'
+
+import type { ZipContent } from '../src/types/ZipContent'
+
+const router = express.Router()
+
+router.use(bodyParser.json())
+
+// $FlowIgnore
+router.get('/version', (req, res) => {
+  res.json({ version: packageJson.version })
+})
+
+// $FlowIgnore
+router.get('/config', (req, res) => {
+  res.send(requireWithoutCache('../config.js', require).config)
+})
+
+// $FlowIgnore
+router.post('/download-zip', (req, res) => {
+  try {
+    let contents: ZipContent[] = req.body.contents
+    if (!contents || !contents.length || !contents[0].filename || typeof contents[0].content !== 'string') {
+      throw new Error()
+    }
+    let zip = new AdmZip()
+    contents.forEach(content => {
+      zip.addFile(content.filename, Buffer.alloc(content.content.length, content.content))
+    })
+    let zipBuffer = zip.toBuffer()
+    let readStream = new stream.PassThrough()
+    readStream.end(zipBuffer)
+    res.set('Content-Disposition', 'attachment; filename=contents.zip')
+    res.set('Content-Type', 'text/plain')
+    readStream.pipe(res)
+  } catch (err) {
+    console.error(err)
+    res.status(500).json({ error: { message: 'Invalid request body for download zip API' } })
+  }
+})
+
+export default router

+ 1 - 0
src/components/molecules/FieldInput/FieldInput.jsx

@@ -213,6 +213,7 @@ class FieldInput extends React.Component<Props> {
       selectedItem,
       items,
       disabledLoading: this.props.disabledLoading,
+      disabled: this.props.disabled,
       onChange: item => this.props.onChange && this.props.onChange(item.value),
     }
 

+ 124 - 2
src/components/organisms/ChooseProvider/ChooseProvider.jsx

@@ -18,11 +18,17 @@ import React from 'react'
 import { observer } from 'mobx-react'
 import styled from 'styled-components'
 
+import notificationStore from '../../../stores/NotificationStore'
+
 import EndpointLogos from '../../atoms/EndpointLogos'
 import Button from '../../atoms/Button'
 import StatusImage from '../../atoms/StatusImage'
 
 import StyleProps from '../../styleUtils/StyleProps'
+import Palette from '../../styleUtils/Palette'
+import ObjectUtils from '../../../utils/ObjectUtils'
+
+import type { Endpoint } from '../../../types/Endpoint'
 
 const Wrapper = styled.div`
   padding: 22px 0 32px 0;
@@ -32,7 +38,23 @@ const Providers = styled.div``
 const Logos = styled.div`
   display: flex;
   flex-wrap: wrap;
-  padding-bottom: 42px;
+`
+const Upload = styled.div`
+  border: 1px dashed ${props => props.highlight ? Palette.primary : 'white'};
+  margin: 0 32px 16px 32px;
+  padding: 16px;
+`
+const UploadMessage = styled.div`
+  color: ${Palette.grayscale[3]};
+`
+const UploadMessageButton = styled.span`
+  color: ${Palette.primary};
+  cursor: pointer;
+`
+const FakeFileInput = styled.input`
+  position: absolute;
+  opacity: 0;
+  top: -99999px;
 `
 const EndpointLogosStyled = styled(EndpointLogos)`
   transform: scale(0.67);
@@ -57,10 +79,97 @@ type Props = {
   providers: string[],
   onCancelClick: () => void,
   onProviderClick: (provider: string) => void,
+  onUploadEndpoint: (endpoint: Endpoint) => void,
   loading: boolean,
 }
+type State = {
+  highlightDropzone: boolean,
+}
 @observer
-class ChooseProvider extends React.Component<Props> {
+class ChooseProvider extends React.Component<Props, State> {
+  state = {
+    highlightDropzone: false,
+  }
+
+  fileInput: HTMLElement
+  dragDropListeners: { type: string, listener: (e: any) => any }[] = []
+
+  componentWillMount() {
+    setTimeout(() => { this.addDragAndDrop() }, 1000)
+  }
+
+  componentWillUnmount() {
+    this.removeDragDrop()
+  }
+
+  addDragAndDrop() {
+    this.dragDropListeners = [{
+      type: 'dragenter',
+      listener: e => {
+        this.setState({ highlightDropzone: true })
+        e.dataTransfer.dropEffect = 'copy'
+        e.preventDefault()
+      },
+    }, {
+      type: 'dragover',
+      listener: e => {
+        e.dataTransfer.dropEffect = 'copy'
+        e.preventDefault()
+      },
+    }, {
+      type: 'dragleave',
+      listener: e => {
+        if (!e.clientX && !e.clientY) {
+          this.setState({ highlightDropzone: false })
+        }
+      },
+    }, {
+      type: 'drop',
+      listener: async e => {
+        e.preventDefault()
+        this.setState({ highlightDropzone: false })
+        let text = await ObjectUtils.readFromFileList(e.dataTransfer.files)
+        this.processFileContent(text)
+      },
+    }]
+
+    this.dragDropListeners.forEach(l => {
+      window.addEventListener(l.type, l.listener)
+    })
+  }
+
+  removeDragDrop() {
+    this.dragDropListeners.forEach(l => {
+      window.removeEventListener(l.type, l.listener)
+    })
+    this.dragDropListeners = []
+  }
+
+  processFileContent(content: ?string) {
+    if (!content) {
+      return
+    }
+    try {
+      let endpoint: Endpoint = JSON.parse(content)
+      if (!endpoint.name || !endpoint.type || !this.props.providers.find(p => p === endpoint.type)) {
+        throw new Error()
+      }
+      delete endpoint.id
+      this.chooseEndpoint(endpoint)
+    } catch (err) {
+      notificationStore.alert('Invalid .endpoint file', 'error')
+    }
+  }
+
+  chooseEndpoint(endpoint: Endpoint) {
+    this.props.onUploadEndpoint(endpoint)
+  }
+
+  async handleFileUpload(files: FileList) {
+    let text = await ObjectUtils.readFromFileList(files)
+    this.processFileContent(text)
+  }
+
   renderLoading() {
     if (!this.props.loading) {
       return null
@@ -94,6 +203,19 @@ class ChooseProvider extends React.Component<Props> {
             )
           })}
         </Logos>
+        <Upload highlight={this.state.highlightDropzone}>
+          <UploadMessage>
+            You can
+            &nbsp;<UploadMessageButton onClick={() => { this.fileInput.click() }}>upload</UploadMessageButton>&nbsp;
+            or drop a .endpoint file.
+          </UploadMessage>
+        </Upload>
+        <FakeFileInput
+          type="file"
+          innerRef={r => { this.fileInput = r }}
+          accept=".endpoint"
+          onChange={e => { this.handleFileUpload(e.target.files) }}
+        />
         <Button secondary onClick={this.props.onCancelClick} data-test-id="cProvider-cancelButton">Cancel</Button>
       </Providers>
     )

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

@@ -111,6 +111,7 @@ type Props = {
   cancelButtonText: string,
   deleteOnCancel: boolean,
   endpoint: ?EndpointType,
+  isNewEndpoint?: boolean,
   onCancelClick: (opts?: { autoClose?: boolean }) => void,
   onResizeUpdate: (scrollableRef: HTMLElement, scrollOffset?: number) => void,
 }
@@ -168,7 +169,9 @@ class Endpoint extends React.Component<Props, State> {
 
     if (props.endpoint && endpointStore.connectionInfo) {
       this.setState({
+        isNew: this.props.isNewEndpoint ? (this.state.isNew === null || this.state.isNew) : this.state.isNew,
         endpoint: {
+          ...this.state.endpoint,
           ...ObjectUtils.flatten(props.endpoint || {}),
           ...ObjectUtils.flatten(endpointStore.connectionInfo || {}),
         },
@@ -277,15 +280,15 @@ class Endpoint extends React.Component<Props, State> {
   }
 
   async update() {
-    if (!this.state.endpoint) {
+    let stateEndpoint = this.state.endpoint
+    if (!stateEndpoint) {
       return
     }
-
-    await endpointStore.update(this.state.endpoint)
-    let endpoint = endpointStore.endpoints.find(e => this.state.endpoint && e.id === this.state.endpoint.id)
+    let endpoint = endpointStore.endpoints.find(e => e.id === stateEndpoint.id)
     if (!endpoint) {
-      throw new Error('endpoint not found')
+      throw new Error('Endpoint not found in store')
     }
+    await endpointStore.update(stateEndpoint)
 
     this.setState({ endpoint: ObjectUtils.flatten(endpoint) })
     notificationStore.alert('Validating endpoint ...')
@@ -387,6 +390,7 @@ class Endpoint extends React.Component<Props, State> {
           validating: this.state.validating,
           disabled: this.state.validating,
           cancelButtonText: this.props.cancelButtonText,
+          originalConnectionInfo: endpointStore.connectionInfo,
           getFieldValue: field => this.getFieldValue(field),
           highlightRequired: () => this.highlightRequired(),
           handleFieldChange: (field, value) => {

+ 3 - 15
src/components/organisms/Licence/Licence.jsx

@@ -27,6 +27,7 @@ import CopyValue from '../../atoms/CopyValue'
 
 import StyleProps from '../../styleUtils/StyleProps'
 import Palette from '../../styleUtils/Palette'
+import ObjectUtils from '../../../utils/ObjectUtils'
 
 import type { Licence } from '../../../types/Licence'
 
@@ -121,19 +122,6 @@ type State = {
   highlightDropzone: boolean,
 }
 
-const readFromFileList = async (fileList: FileList): Promise<?string> => {
-  if (!fileList.length) {
-    return null
-  }
-  let file = fileList[0]
-  let reader = new FileReader()
-  return new Promise((resolve, reject) => {
-    reader.onload = e => { resolve(e.target.result) }
-    reader.onerror = e => { reject(e) }
-    reader.readAsText(file)
-  })
-}
-
 @observer
 class LicenceC extends React.Component<Props, State> {
   state = {
@@ -185,7 +173,7 @@ class LicenceC extends React.Component<Props, State> {
       listener: async e => {
         e.preventDefault()
         this.setState({ highlightDropzone: false })
-        let text = await readFromFileList(e.dataTransfer.files)
+        let text = await ObjectUtils.readFromFileList(e.dataTransfer.files)
         if (text) {
           this.handleLicenceChange(text)
         }
@@ -232,7 +220,7 @@ class LicenceC extends React.Component<Props, State> {
   }
 
   async handleFileUpload(files: FileList) {
-    let text = await readFromFileList(files)
+    let text = await ObjectUtils.readFromFileList(files)
     if (text) {
       this.handleLicenceChange(text)
     }

+ 19 - 0
src/components/organisms/PageHeader/PageHeader.jsx

@@ -20,6 +20,8 @@ import { observer } from 'mobx-react'
 
 import type { User } from '../../../types/User'
 import type { Project } from '../../../types/Project'
+import type { Endpoint as EndpointType } from '../../../types/Endpoint'
+
 import Dropdown from '../../molecules/Dropdown'
 import NewItemDropdown from '../../molecules/NewItemDropdown'
 import NotificationDropdown from '../../molecules/NotificationDropdown'
@@ -33,6 +35,7 @@ import AboutModal from '../../pages/AboutModal'
 
 import projectStore from '../../../stores/ProjectStore'
 import userStore from '../../../stores/UserStore'
+import endpointStore from '../../../stores/EndpointStore'
 import notificationStore from '../../../stores/NotificationStore'
 import providerStore from '../../../stores/ProviderStore'
 import Palette from '../../styleUtils/Palette'
@@ -79,6 +82,7 @@ type State = {
   showProjectModal: boolean,
   showAbout: boolean,
   providerType: ?string,
+  uploadedEndpoint: ?EndpointType,
 }
 @observer
 class PageHeader extends React.Component<Props, State> {
@@ -88,6 +92,7 @@ class PageHeader extends React.Component<Props, State> {
     showUserModal: false,
     showProjectModal: false,
     providerType: null,
+    uploadedEndpoint: null,
     showAbout: false,
   }
 
@@ -172,10 +177,21 @@ class PageHeader extends React.Component<Props, State> {
     this.setState({
       showChooseProviderModal: false,
       showEndpointModal: true,
+      uploadedEndpoint: null,
       providerType,
     })
   }
 
+  handleUploadEndpoint(endpoint: EndpointType) {
+    endpointStore.setConnectionInfo(endpoint.connection_info)
+    this.setState({
+      showChooseProviderModal: false,
+      showEndpointModal: true,
+      providerType: endpoint.type,
+      uploadedEndpoint: endpoint,
+    })
+  }
+
   handleCloseEndpointModal() {
     if (this.props.onModalClose) {
       this.props.onModalClose()
@@ -272,6 +288,7 @@ class PageHeader extends React.Component<Props, State> {
             providers={providerStore.providerNames}
             loading={providerStore.providersLoading}
             onProviderClick={providerName => { this.handleProviderClick(providerName) }}
+            onUploadEndpoint={endpoint => { this.handleUploadEndpoint(endpoint) }}
           />
         </Modal>
         <Modal
@@ -283,6 +300,8 @@ class PageHeader extends React.Component<Props, State> {
             type={this.state.providerType}
             cancelButtonText="Back"
             onCancelClick={options => { this.handleBackEndpointModal(options) }}
+            endpoint={this.state.uploadedEndpoint}
+            isNewEndpoint={Boolean(this.state.uploadedEndpoint)}
           />
         </Modal>
         {this.state.showUserModal ? (

+ 24 - 18
src/components/pages/EndpointDetailsPage/EndpointDetailsPage.jsx

@@ -70,6 +70,10 @@ class EndpointDetailsPage extends React.Component<Props, State> {
     endpointUsage: { replicas: [], migrations: [] },
   }
 
+  get endpoint(): ?EndpointType {
+    return endpointStore.endpoints.find(e => e.id === this.props.match.params.id) || null
+  }
+
   componentDidMount() {
     document.title = 'Endpoint Details'
 
@@ -80,10 +84,6 @@ class EndpointDetailsPage extends React.Component<Props, State> {
     endpointStore.clearConnectionInfo()
   }
 
-  getEndpoint(): ?EndpointType {
-    return endpointStore.endpoints.find(e => e.id === this.props.match.params.id) || null
-  }
-
   getEndpointUsage(): { migrations: MainItem[], replicas: MainItem[] } {
     let endpointId = this.props.match.params.id
     let replicas = replicaStore.replicas.filter(
@@ -118,9 +118,8 @@ class EndpointDetailsPage extends React.Component<Props, State> {
 
   handleDeleteEndpointConfirmation() {
     this.setState({ showDeleteEndpointConfirmation: false })
-    let endpoint = this.getEndpoint()
-    if (endpoint) {
-      endpointStore.delete(endpoint)
+    if (this.endpoint) {
+      endpointStore.delete(this.endpoint)
     }
     this.props.history.push('/endpoints')
   }
@@ -130,17 +129,15 @@ class EndpointDetailsPage extends React.Component<Props, State> {
   }
 
   handleValidateClick() {
-    let endpoint = this.getEndpoint()
-    if (endpoint) {
-      endpointStore.validate(endpoint)
+    if (this.endpoint) {
+      endpointStore.validate(this.endpoint)
     }
     this.setState({ showValidationModal: true })
   }
 
   handleRetryValidation() {
-    let endpoint = this.getEndpoint()
-    if (endpoint) {
-      endpointStore.validate(endpoint)
+    if (this.endpoint) {
+      endpointStore.validate(this.endpoint)
     }
   }
 
@@ -170,7 +167,7 @@ class EndpointDetailsPage extends React.Component<Props, State> {
   }
 
   async handleDuplicate(projectId: string) {
-    let endpoint = this.getEndpoint()
+    let endpoint = this.endpoint
     if (!endpoint) {
       return
     }
@@ -187,6 +184,13 @@ class EndpointDetailsPage extends React.Component<Props, State> {
     this.props.history.push('/endpoints')
   }
 
+  handleExportToJsonClick() {
+    if (!this.endpoint) {
+      return
+    }
+    endpointStore.exportToJson(this.endpoint)
+  }
+
   async loadData() {
     projectStore.getProjects()
 
@@ -198,7 +202,7 @@ class EndpointDetailsPage extends React.Component<Props, State> {
 
   async loadEndpoints() {
     await endpointStore.getEndpoints()
-    let endpoint = this.getEndpoint()
+    let endpoint = this.endpoint
 
     if (endpoint && endpoint.connection_info && endpoint.connection_info.secret_ref) {
       endpointStore.getConnectionInfo(endpoint)
@@ -210,7 +214,7 @@ class EndpointDetailsPage extends React.Component<Props, State> {
   render() {
     let selectedProjectId = userStore.loggedUser ? userStore.loggedUser.project.id : ''
 
-    let endpoint = this.getEndpoint()
+    let endpoint = this.endpoint
     let dropdownActions = [{
       label: 'Validate',
       color: Palette.primary,
@@ -222,7 +226,9 @@ class EndpointDetailsPage extends React.Component<Props, State> {
     }, {
       label: 'Duplicate',
       action: () => { this.handleDuplicateClick() },
-
+    }, {
+      label: 'Download .endpoint file',
+      action: () => { this.handleExportToJsonClick() },
     }, {
       label: 'Delete Endpoint',
       color: Palette.alert,
@@ -291,7 +297,7 @@ class EndpointDetailsPage extends React.Component<Props, State> {
           onRequestClose={() => { this.handleCloseEndpointModal() }}
         >
           <Endpoint
-            endpoint={this.getEndpoint()}
+            endpoint={this.endpoint}
             onValidateClick={endpoint => this.handleEditValidateClick(endpoint)}
             onCancelClick={() => { this.handleCloseEndpointModal() }}
           />

+ 27 - 0
src/components/pages/EndpointsPage/EndpointsPage.jsx

@@ -55,6 +55,7 @@ type State = {
   showDeleteEndpointsModal: boolean,
   showDuplicateModal: boolean,
   duplicating: boolean,
+  uploadedEndpoint: ?EndpointType,
 }
 @observer
 class EndpointsPage extends React.Component<{ history: any }, State> {
@@ -68,6 +69,7 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
     duplicating: false,
     showDeleteEndpointsModal: false,
     selectedEndpoints: [],
+    uploadedEndpoint: null,
   }
 
   pollTimeout: TimeoutID
@@ -162,10 +164,21 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
     this.setState({
       showChooseProviderModal: false,
       showEndpointModal: true,
+      uploadedEndpoint: null,
       providerType,
     })
   }
 
+  handleUploadEndpoint(endpoint: EndpointType) {
+    endpointStore.setConnectionInfo(endpoint.connection_info)
+    this.setState({
+      showChooseProviderModal: false,
+      showEndpointModal: true,
+      providerType: endpoint.type,
+      uploadedEndpoint: endpoint,
+    })
+  }
+
   handleCloseEndpointModal() {
     this.setState({ showEndpointModal: false })
   }
@@ -180,6 +193,14 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
     })
   }
 
+  handleExportToJson() {
+    if (this.state.selectedEndpoints.length === 1) {
+      endpointStore.exportToJson(this.state.selectedEndpoints[0])
+    } else {
+      endpointStore.exportToZip(this.state.selectedEndpoints)
+    }
+  }
+
   handleDeleteAction() {
     let endpointsInUse = this.state.selectedEndpoints.filter(endpoint => {
       const endpointUsage = this.getEndpointUsage(endpoint.id)
@@ -226,6 +247,9 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
       label: 'Duplicate',
       action: () => { this.setState({ showDuplicateModal: true, modalIsOpen: true }) },
 
+    }, {
+      label: 'Download .endpoint files',
+      action: () => { this.handleExportToJson() },
     }, {
       label: 'Delete Endpoint',
       color: Palette.alert,
@@ -292,6 +316,7 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
             onCancelClick={() => { this.handleCloseChooseProviderModal() }}
             providers={providerStore.providerNames}
             loading={providerStore.providersLoading}
+            onUploadEndpoint={endpoint => { this.handleUploadEndpoint(endpoint) }}
             onProviderClick={providerName => { this.handleProviderClick(providerName) }}
           />
         </Modal>
@@ -303,6 +328,8 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
           <Endpoint
             type={this.state.providerType}
             onCancelClick={() => { this.handleCloseEndpointModal() }}
+            endpoint={this.state.uploadedEndpoint}
+            isNewEndpoint={Boolean(this.state.uploadedEndpoint)}
           />
         </Modal>
         <AlertModal

+ 0 - 1
src/plugins/endpoint/azure/ConnectionSchemaPlugin.js

@@ -57,7 +57,6 @@ const azureConnectionParse = schema => {
 
   let radioGroup = {
     name: 'login_type',
-    default: 'user_credentials',
     type: 'radio-group',
     items: [getOption('user_credentials'), getOption('service_principal_credentials')],
   }

+ 23 - 5
src/plugins/endpoint/azure/ContentPlugin.jsx

@@ -78,6 +78,7 @@ type Props = {
   disabled: boolean,
   cancelButtonText: string,
   validating: boolean,
+  originalConnectionInfo: ?any,
   onRef: (contentPlugin: any) => void,
   onResizeUpdate: (scrollOfset: number) => void,
   scrollableRef: (ref: HTMLElement) => void,
@@ -168,14 +169,31 @@ class ContentPlugin extends React.Component<Props, State> {
     this.setState({ showAdvancedOptions })
   }
 
+  getLoginTypeValue() {
+    let loginTypeField = this.props.connectionInfoSchema.find(f => f.name === 'login_type')
+    let value = this.props.getFieldValue(loginTypeField)
+    if (!value) {
+      value = 'user_credentials'
+      let conn = this.props.originalConnectionInfo
+      if (conn && conn.service_principal_credentials) {
+        value = 'service_principal_credentials'
+        let loginFieldType = this.props.connectionInfoSchema.find(f => f.name === 'login_type')
+        if (loginFieldType) {
+          this.props.handleFieldChange(loginFieldType, value)
+        }
+      }
+    }
+    return value
+  }
+
   getSelectedLoginTypeField() {
     let loginTypeField = this.props.connectionInfoSchema.find(f => f.name === 'login_type')
-    return loginTypeField && loginTypeField.items ? loginTypeField.items.find(f => f.name === this.props.getFieldValue(loginTypeField)) : null
+    return loginTypeField && loginTypeField.items ?
+      loginTypeField.items.find(f => f.name === this.getLoginTypeValue()) : null
   }
 
   isServicePrincipalLogin() {
-    let selectedLoginTypeField = this.getSelectedLoginTypeField()
-    return selectedLoginTypeField && selectedLoginTypeField.name === 'service_principal_credentials'
+    return this.getLoginTypeValue() === 'service_principal_credentials'
   }
 
   findInvalidFields = () => {
@@ -292,7 +310,7 @@ class ContentPlugin extends React.Component<Props, State> {
     if (!loginTypeField || !loginTypeField.items) {
       return null
     }
-    let loginTypeFieldItems = loginTypeField.items.find(f => f.name === this.props.getFieldValue(loginTypeField))
+    let loginTypeFieldItems = loginTypeField.items.find(f => f.name === this.getLoginTypeValue())
     if (!loginTypeFieldItems || !loginTypeFieldItems.fields || !cloudProfileField) {
       return null
     }
@@ -305,7 +323,7 @@ class ContentPlugin extends React.Component<Props, State> {
         <RadioGroup key="radio-group">
           {loginTypeField.items && loginTypeField.items.map(field =>
             this.renderField(field, {
-              value: this.props.getFieldValue(loginTypeField) === field.name,
+              value: this.getLoginTypeValue() === field.name,
               onChange: value => { if (value) this.props.handleFieldChange(loginTypeField, field.name) },
             })
           )}

+ 39 - 0
src/stores/EndpointStore.js

@@ -13,11 +13,18 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
 // @flow
+
 import { observable, runInAction, action } from 'mobx'
+
 import type { Endpoint, Validation, StorageBackend, Storage } from '../types/Endpoint'
+import type { ZipContent } from '../types/ZipContent'
+
+import apiCaller from '../utils/ApiCaller'
 import notificationStore from './NotificationStore'
 import EndpointSource from '../sources/EndpointSource'
 
+import DomUtils from '../utils/DomUtils'
+
 const updateEndpoint = (endpoint, endpoints) => endpoints.map(e => {
   if (e.id === endpoint.id) {
     return { ...endpoint }
@@ -137,6 +144,38 @@ class EndpointStore {
     }
   }
 
+  @action async exportToJson(endpoint: Endpoint): Promise<void> {
+    let connectionInfo = await EndpointSource.getConnectionInfo(endpoint)
+    endpoint.connection_info = connectionInfo
+    DomUtils.download(JSON.stringify(endpoint), `${endpoint.name}.endpoint`)
+  }
+
+  @action async exportToZip(endpoints: Endpoint[]): Promise<void> {
+    await Promise.all(endpoints.map(async endpoint => {
+      let connectionInfo = await EndpointSource.getConnectionInfo(endpoint)
+      endpoint.connection_info = connectionInfo
+    }))
+    let zipContents: ZipContent[] = endpoints.map(endpoint => ({
+      filename: `${endpoint.name}.endpoint`,
+      content: JSON.stringify(endpoint),
+    }))
+    let response = await apiCaller.send({
+      url: '/api/download-zip',
+      data: { contents: zipContents },
+      method: 'POST',
+      responseType: 'blob',
+    })
+    const url = window.URL.createObjectURL(new Blob([response.data]))
+    const link = document.createElement('a')
+    link.href = url
+    link.setAttribute('download', 'coriolis-endpoints.zip')
+    if (document.body) {
+      document.body.appendChild(link)
+    }
+    link.click()
+    link.remove()
+  }
+
   @action setConnectionInfo(connectionInfo: $PropertyType<Endpoint, 'connection_info'>) {
     this.connectionInfo = connectionInfo
     this.connectionInfoLoading = false

+ 1 - 1
src/stores/LicenceStore.js

@@ -32,7 +32,7 @@ class LicenceStore {
       return this.version
     }
 
-    let response = await apiCaller.get('/version')
+    let response = await apiCaller.get('/api/version')
     runInAction(() => {
       this.version = response.data.version
     })

+ 2 - 0
src/stores/ProviderStore.js

@@ -125,6 +125,8 @@ class ProviderStore {
     try {
       let fields: Field[] = await ProviderSource.getConnectionInfoSchema(providerName)
       runInAction(() => { this.connectionInfoSchema = fields })
+    } catch (err) {
+      throw err
     } finally {
       runInAction(() => { this.connectionSchemaLoading = false })
     }

+ 20 - 0
src/types/ZipContent.js

@@ -0,0 +1,20 @@
+/*
+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
+
+export type ZipContent = {
+  filename: string,
+  content: string,
+}

+ 3 - 9
src/utils/ApiLogger.js

@@ -16,6 +16,8 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import licenceStore from '../stores/LicenceStore'
 
+import DomUtils from './DomUtils'
+
 type LogType = 'REQUEST' | 'RESPONSE'
 
 type LogOptions = {
@@ -112,15 +114,7 @@ class ApiLogger {
       version: licenceStore.version || '-',
     }
 
-    let href: string = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(log))}`
-    let downloadAnchorNode = document.createElement('a')
-    downloadAnchorNode.setAttribute('href', href)
-    downloadAnchorNode.setAttribute('download', 'coriolis-log.json')
-    if (document.body) {
-      document.body.appendChild(downloadAnchorNode) // required for firefox
-    }
-    downloadAnchorNode.click()
-    downloadAnchorNode.remove()
+    DomUtils.download(JSON.stringify(log), 'coriolis-log.json')
   }
 }
 

+ 1 - 1
src/utils/Config.js

@@ -8,7 +8,7 @@ class ConfigLoader {
   config: Config
 
   async load() {
-    let res = await apiCaller.get('/config')
+    let res = await apiCaller.get('/api/config')
     this.config = res.data
   }
 }

+ 12 - 0
src/utils/DomUtils.js

@@ -109,6 +109,18 @@ class DomUtils {
   static get urlHashPrefix() {
     return window.env.ENV === 'development' ? '#/' : ''
   }
+
+  static download(text: string, fileName: string) {
+    let href: string = `data:text/json;charset=utf-8,${encodeURIComponent(text)}`
+    let downloadAnchorNode = document.createElement('a')
+    downloadAnchorNode.setAttribute('href', href)
+    downloadAnchorNode.setAttribute('download', fileName)
+    if (document.body) {
+      document.body.appendChild(downloadAnchorNode) // required for firefox
+    }
+    downloadAnchorNode.click()
+    downloadAnchorNode.remove()
+  }
 }
 
 export default DomUtils

+ 13 - 0
src/utils/ObjectUtils.js

@@ -78,6 +78,19 @@ class ObjectUtils {
       || fieldName.toLowerCase().indexOf('password') > -1
     return typeof value === 'string' && !isPassword ? value.trim() : value
   }
+
+  static readFromFileList(fileList: FileList): Promise<?string> {
+    if (!fileList.length) {
+      return Promise.resolve(null)
+    }
+    let file = fileList[0]
+    let reader = new FileReader()
+    return new Promise((resolve, reject) => {
+      reader.onload = e => { resolve(e.target.result) }
+      reader.onerror = e => { reject(e) }
+      reader.readAsText(file)
+    })
+  }
 }
 
 export default ObjectUtils

+ 5 - 0
yarn.lock

@@ -317,6 +317,11 @@ adal-node@^0.1.25:
     xmldom ">= 0.1.x"
     xpath.js "~1.0.5"
 
+adm-zip@^0.4.13:
+  version "0.4.13"
+  resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.13.tgz#597e2f8cc3672151e1307d3e95cddbc75672314a"
+  integrity sha512-fERNJX8sOXfel6qCBCMPvZLzENBEhZTzKqg6vrOW5pvoEaQuJhRU4ndTAh6lHOxn1I6jnz2NHra56ZODM751uw==
+
 agent-base@4, agent-base@^4.1.0:
   version "4.2.1"
   resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9"