소스 검색

Add ability to export / import endpoints from JSON

Endpoints can be exported in bulk from the endpoints list or
individually from their details page.

An endpoint can be imported from a JSON file by choosing New Endpoint
from the dropdown input.
Sergiu Miclea 6 년 전
부모
커밋
ebc9eb2d57

+ 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) {
+        throw new Error('Invalid endpoint')
+      }
+      delete endpoint.id
+      this.chooseEndpoint(endpoint)
+    } catch (err) {
+      notificationStore.alert('The endpoint could not be parsed', '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 an endpoint JSON file.
+          </UploadMessage>
+        </Upload>
+        <FakeFileInput
+          type="file"
+          innerRef={r => { this.fileInput = r }}
+          accept=".json"
+          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: 'Export to JSON',
+      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() }}
           />

+ 25 - 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,12 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
     })
   }
 
+  handleExportToJson() {
+    this.state.selectedEndpoints.forEach(endpoint => {
+      endpointStore.exportToJson(endpoint)
+    })
+  }
+
   handleDeleteAction() {
     let endpointsInUse = this.state.selectedEndpoints.filter(endpoint => {
       const endpointUsage = this.getEndpointUsage(endpoint.id)
@@ -226,6 +245,9 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
       label: 'Duplicate',
       action: () => { this.setState({ showDuplicateModal: true, modalIsOpen: true }) },
 
+    }, {
+      label: 'Export to JSON',
+      action: () => { this.handleExportToJson() },
     }, {
       label: 'Delete Endpoint',
       color: Palette.alert,
@@ -292,6 +314,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 +326,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) },
             })
           )}

+ 11 - 0
src/stores/EndpointStore.js

@@ -13,11 +13,16 @@ 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 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 +142,12 @@ 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}.json`)
+  }
+
   @action setConnectionInfo(connectionInfo: $PropertyType<Endpoint, 'connection_info'>) {
     this.connectionInfo = connectionInfo
     this.connectionInfoLoading = false

+ 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 })
     }

+ 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')
   }
 }
 

+ 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