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

Add 'Planning' page with Azure Migrate support

Sergiu Miclea 8 лет назад
Родитель
Сommit
2dc2dd39d5
64 измененных файлов с 3759 добавлено и 204 удалено
  1. 1 1
      .eslintignore
  2. 4 0
      package.json
  3. 2 0
      server/main.js
  4. 54 0
      server/proxy.js
  5. 12 0
      src/components/App.jsx
  6. 1 0
      src/components/atoms/Checkbox/index.jsx
  7. 19 0
      src/components/atoms/InfoIcon/images/warning.svg
  8. 12 3
      src/components/atoms/InfoIcon/index.jsx
  9. 20 0
      src/components/atoms/SearchButton/images/filter.js
  10. 8 3
      src/components/atoms/SearchButton/index.jsx
  11. 68 3
      src/components/atoms/StatusImage/index.jsx
  12. 27 0
      src/components/atoms/StatusImage/story.jsx
  13. 14 0
      src/components/atoms/TextInput/images/close.svg
  14. 36 5
      src/components/atoms/TextInput/index.jsx
  15. 16 0
      src/components/atoms/TextInput/story.jsx
  16. 134 0
      src/components/molecules/AssessedVmListItem/index.jsx
  17. 20 0
      src/components/molecules/AssessmentListItem/images/assessment.svg
  18. 20 0
      src/components/molecules/AssessmentListItem/images/azure-migrate.svg
  19. 146 0
      src/components/molecules/AssessmentListItem/index.jsx
  20. 7 6
      src/components/molecules/DetailsNavigation/index.jsx
  21. 15 0
      src/components/molecules/DropdownFilter/images/filter.js
  22. 166 0
      src/components/molecules/DropdownFilter/index.jsx
  23. 44 0
      src/components/molecules/DropdownFilter/story.jsx
  24. 210 42
      src/components/molecules/DropdownLink/index.jsx
  25. 4 0
      src/components/molecules/DropdownLink/story.jsx
  26. 1 1
      src/components/molecules/EndpointListItem/index.jsx
  27. 27 8
      src/components/molecules/MainListFilter/index.jsx
  28. 1 1
      src/components/molecules/MainListItem/index.jsx
  29. 36 21
      src/components/molecules/SearchInput/index.jsx
  30. 18 1
      src/components/molecules/SearchInput/story.jsx
  31. 6 3
      src/components/molecules/SideMenu/index.jsx
  32. 91 30
      src/components/molecules/Table/index.jsx
  33. 11 2
      src/components/molecules/Table/story.jsx
  34. 13 0
      src/components/organisms/AssessmentDetailsContent/images/arrow.svg
  35. 22 0
      src/components/organisms/AssessmentDetailsContent/images/azure-migrate.svg
  36. 498 0
      src/components/organisms/AssessmentDetailsContent/index.jsx
  37. 17 0
      src/components/organisms/AssessmentMigrationOptions/images/assessment.svg
  38. 162 0
      src/components/organisms/AssessmentMigrationOptions/index.jsx
  39. 18 4
      src/components/organisms/DetailsContentHeader/index.jsx
  40. 62 0
      src/components/organisms/DropdownFilterGroup/index.jsx
  41. 9 6
      src/components/organisms/FilterList/index.jsx
  42. 19 5
      src/components/organisms/MainList/index.jsx
  43. 4 16
      src/components/organisms/Navigation/index.jsx
  44. 2 1
      src/components/organisms/WizardInstances/index.jsx
  45. 20 0
      src/components/pages/AssessmentDetailsPage/images/assessment.svg
  46. 413 0
      src/components/pages/AssessmentDetailsPage/index.jsx
  47. 292 0
      src/components/pages/AssessmentsPage/index.jsx
  48. 4 2
      src/components/templates/MainTemplate/index.jsx
  49. 7 0
      src/config.js
  50. 104 0
      src/sources/AssessmentSource.js
  51. 169 0
      src/sources/AzureSource.js
  52. 32 0
      src/sources/EndpointSource.js
  53. 31 9
      src/sources/InstanceSource.js
  54. 71 0
      src/stores/AssessmentStore.js
  55. 136 0
      src/stores/AzureStore.js
  56. 12 0
      src/stores/EndpointStore.js
  57. 48 17
      src/stores/InstanceStore.js
  58. 75 0
      src/types/Assessment.js
  59. 1 0
      src/types/Endpoint.js
  60. 21 0
      src/utils/ApiCaller.js
  61. 82 0
      src/utils/AzureApiCaller.js
  62. 2 1
      src/utils/LabelDictionary.js
  63. 3 3
      src/utils/Wait.js
  64. 159 10
      yarn.lock

+ 1 - 1
.eslintignore

@@ -1,3 +1,3 @@
 # flow-typed
 flow-typed/npm/*
-!flow-typed/npm/module_vx.x.x.js
+!flow-typed/npm/module_vx.x.x.js

+ 4 - 0
package.json

@@ -54,6 +54,7 @@
   },
   "dependencies": {
     "@webpack-blocks/webpack2": "^0.4.0",
+    "ajax-request": "^1.2.3",
     "babel-core": "^6.26.0",
     "babel-loader": "^7.1.2",
     "babel-plugin-styled-components": "^1.2.1",
@@ -65,6 +66,7 @@
     "babel-preset-react": "^6.24.1",
     "babel-preset-stage-1": "^6.24.1",
     "babel-register": "^6.26.0",
+    "body-parser": "^1.18.2",
     "copyfiles": "^1.2.0",
     "cross-env": "^5.0.5",
     "express": "^4.16.1",
@@ -77,6 +79,7 @@
     "mobx": "^3.6.1",
     "mobx-react": "^4.4.2",
     "moment": "^2.18.1",
+    "ms-rest-azure": "^2.4.5",
     "path": "^0.12.7",
     "raw-loader": "^0.5.1",
     "react": "^16.0.0",
@@ -89,6 +92,7 @@
     "react-notification-system": "^0.2.15",
     "react-router-dom": "^4.2.2",
     "react-tooltip": "^3.4.0",
+    "request": "^2.83.0",
     "rimraf": "^2.6.2",
     "styled-components": "2.2.0",
     "styled-tools": "^0.2.2",

+ 2 - 0
server/main.js

@@ -32,6 +32,8 @@ if (isDev) {
 
 app.use(express.static('dist'))
 
+require('./proxy')(app)
+
 app.use((req, res) => {
   res.redirect(`${req.baseUrl}/#${req.url}`)
 })

+ 54 - 0
server/proxy.js

@@ -0,0 +1,54 @@
+/*
+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/>.
+*/
+
+import MsRest from 'ms-rest-azure'
+import bodyParser from 'body-parser'
+import request from 'request'
+
+const forwardHeaders = ['authorization']
+
+module.exports = app => {
+  const jsonParser = bodyParser.json()
+
+  app.post('/azure-login', jsonParser, (req, res) => {
+    MsRest.loginWithUsernamePassword(req.body.username, req.body.password, (err, credentials) => {
+      if (err) {
+        res.status(400).send(err)
+      } else {
+        res.send(credentials)
+      }
+    })
+  })
+
+  app.get('/proxy/*', (req, res) => {
+    let url = req.url.substr('/proxy/'.length)
+    let headers = {}
+    forwardHeaders.forEach(headerName => {
+      if (req.headers[headerName] !== null && req.headers[headerName] !== undefined) {
+        headers[headerName] = req.headers[headerName]
+      }
+    })
+
+    request({
+      url,
+      headers,
+    }, (err, resp, body) => {
+      if (!err) {
+        res.send(body)
+      } else {
+        res.status(500).send(err)
+      }
+    })
+  })
+}

+ 12 - 0
src/components/App.jsx

@@ -30,7 +30,10 @@ import EndpointsPage from './pages/EndpointsPage'
 import EndpointDetailsPage from './pages/EndpointDetailsPage'
 import WizardPage from './pages/WizardPage'
 import UserStore from '../stores/UserStore'
+import AssessmentsPage from './pages/AssessmentsPage'
+import AssessmentDetailsPage from './pages/AssessmentDetailsPage'
 
+import { navigationMenu } from '../config'
 import Palette from './styleUtils/Palette'
 import StyleProps from './styleUtils/StyleProps'
 
@@ -54,6 +57,13 @@ class App extends React.Component<{}> {
   }
 
   render() {
+    let renderPlanningPage = () => {
+      if (navigationMenu.find(m => m.value === 'planning' && !m.disabled)) {
+        return <Route path="/planning" component={AssessmentsPage} />
+      }
+      return null
+    }
+
     return (
       <Wrapper>
         <Switch>
@@ -67,6 +77,8 @@ class App extends React.Component<{}> {
           <Route path="/migration/:page/:id" component={MigrationDetailsPage} />
           <Route path="/endpoints" component={EndpointsPage} />
           <Route path="/endpoint/:id" component={EndpointDetailsPage} />
+          {renderPlanningPage()}
+          <Route path="/assessment/:info" component={AssessmentDetailsPage} />
           <Route path="/wizard/:type" component={WizardPage} />
           <Route component={NotFoundPage} />
         </Switch>

+ 1 - 0
src/components/atoms/Checkbox/index.jsx

@@ -70,6 +70,7 @@ class Checkbox extends React.Component<Props> {
         className={this.props.className}
         onClick={() => { this.handleClick() }}
         checked={this.props.checked}
+        disabled={this.props.disabled}
       >
         <CheckmarkImage />
       </Wrapper>

+ 19 - 0
src/components/atoms/InfoIcon/images/warning.svg

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 48.1 (47250) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon/Warning Copy</title>
+    <desc>Created with Sketch.</desc>
+    <defs>
+        <circle id="path-1" cx="8" cy="8" r="8"></circle>
+    </defs>
+    <g id="Azure-Migrate/VM-List-–-w/-EP" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(0.000000, -56.000000)">
+        <g id="Icon/Warning-2" transform="translate(0.000000, 56.000000)">
+            <g id="Oval-2">
+                <use fill="#FFFFFF" fill-rule="evenodd" xlink:href="#path-1"></use>
+                <circle stroke="#FFDC00" stroke-width="1.5" cx="8" cy="8" r="7.25"></circle>
+            </g>
+            <path d="M8,8 L8,3.5" id="Line-Copy" stroke="#FFDC00" stroke-width="1.5" stroke-linecap="square"></path>
+            <path d="M8,13 C8.82842712,13 9.5,12.3284271 9.5,11.5 C9.5,10.6715729 8.82842712,10 8,10 C7.17157288,10 6.5,10.6715729 6.5,11.5 C6.5,12.3284271 7.17157288,13 8,13 Z" id="Oval-3" fill="#FFDC00" fill-rule="evenodd"></path>
+        </g>
+    </g>
+</svg>

+ 12 - 3
src/components/atoms/InfoIcon/index.jsx

@@ -18,23 +18,32 @@ import React from 'react'
 import styled from 'styled-components'
 
 import questionImage from './images/question.svg'
+import warningImage from './images/warning.svg'
 
 const Wrapper = styled.div`
   width: 16px;
   height: 16px;
-  background: url('${questionImage}') center no-repeat;
+  background: url('${props => props.warning ? warningImage : questionImage}') center no-repeat;
   display: inline-block;
   margin-bottom: -4px;
   margin-left: ${props => props.marginLeft ? `${props.marginLeft}px` : '4px'};
 `
 type Props = {
   text: string,
-  marginLeft: number,
+  marginLeft?: number,
+  className?: string,
+  marginLeft?: number,
+  warning?: boolean,
 }
 class InfoIcon extends React.Component<Props> {
   render() {
     return (
-      <Wrapper data-tip={this.props.text} marginLeft={this.props.marginLeft} />
+      <Wrapper
+        data-tip={this.props.text}
+        marginLeft={this.props.marginLeft}
+        className={this.props.className}
+        warning={this.props.warning}
+      />
     )
   }
 }

+ 20 - 0
src/components/atoms/SearchButton/images/filter.js

@@ -0,0 +1,20 @@
+const filter = color => `
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 48.2 (47327) - http://www.bohemiancoding.com/sketch -->
+    <title>Search</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="AM-Long-List" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(-327.000000, -562.000000)" stroke-linecap="round" stroke-linejoin="round">
+        <g id="Nav/Actions/Menu-1-Copy-2" transform="translate(307.000000, 533.000000)" stroke="${color}" stroke-width="1.5">
+            <g id="Forms/Search-Field" transform="translate(12.000000, 21.000000)">
+                <g id="Icon/Filter/Light" transform="translate(8.000000, 8.000000)">
+                    <path d="M6,6.51282051 L1.11363636,1 L15.1136364,1 L10,6.76923077 L10,12.4274421 L7.66447639,15.034188 L6,15.034188 L6,6.51282051 Z" id="Combined-Shape"></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>
+`
+
+export default filter

+ 8 - 3
src/components/atoms/SearchButton/index.jsx

@@ -20,9 +20,11 @@ import styled from 'styled-components'
 import Palette from '../../styleUtils/Palette'
 
 import searchImage from './images/search.js'
+import filterImage from './images/filter.js'
 
-const Wrapper = styled.div`display: flex;`
-
+const Wrapper = styled.div`
+display: inline-block;
+`
 const Icon = styled.div`
   width: 16px;
   height: 16px;
@@ -35,13 +37,16 @@ const Icon = styled.div`
 type Props = {
   className: string,
   primary: boolean,
+  useFilterIcon: boolean,
 }
 class SearchButton extends React.Component<Props> {
   render() {
     return (
       <Wrapper className={this.props.className} {...this.props}>
         <Icon dangerouslySetInnerHTML={{
-          __html: searchImage(this.props.primary ? Palette.primary : Palette.grayscale[4]),
+          __html: this.props.useFilterIcon ?
+            filterImage(Palette.grayscale[3]) :
+            searchImage(this.props.primary ? Palette.primary : Palette.grayscale[4]),
         }}
         />
       </Wrapper>

+ 68 - 3
src/components/atoms/StatusImage/index.jsx

@@ -17,6 +17,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import React from 'react'
 import styled, { css } from 'styled-components'
 import StyleProps from '../../styleUtils/StyleProps'
+import Palette from '../../styleUtils/Palette'
 
 import errorImage from './images/error.svg'
 import successImage from './images/success.svg'
@@ -25,6 +26,7 @@ import loadingImage from './images/loading.svg'
 type Props = {
   status?: string,
   loading?: boolean,
+  loadingProgress?: number,
 }
 
 const statuses = () => {
@@ -45,14 +47,30 @@ const statuses = () => {
         100% {transform: rotate(360deg);}
       }
     `,
+    PROGRESS: css``,
   }
 }
 const Wrapper = styled.div`
+  position: relative;
   ${StyleProps.exactSize('96px')}
   background-repeat: no-repeat;
   background-position: center;
-  ${ // $FlowIssue
-  (props: Props) => statuses()[props.status]}
+  ${(props: Props) => statuses()[props.status || 'RUNNING']}
+`
+const SvgWrapper = styled.svg`
+  ${StyleProps.exactSize('100%')}
+  transform: rotate(-90deg);
+`
+const ProgressText = styled.div`
+  color: ${Palette.primary};
+  font-size: 18px;
+  top: 36px;
+  position: absolute;
+  width: 100%;
+  text-align: center;
+`
+const CircleProgressBar = styled.circle`
+  transition: stroke-dashoffset ${StyleProps.animations.swift};
 `
 
 class StatusImage extends React.Component<Props> {
@@ -60,13 +78,60 @@ class StatusImage extends React.Component<Props> {
     status: 'RUNNING',
   }
 
+  renderProgressImage(status: string) {
+    if (status !== 'PROGRESS') {
+      return null
+    }
+
+    return (
+      <SvgWrapper id="svg" width="96" height="96" viewPort="0 0 96 96" version="1.1" xmlns="http://www.w3.org/2000/svg">
+        <g strokeWidth="2">
+          <circle
+            r="47"
+            cx="48"
+            cy="48"
+            fill="transparent"
+            stroke={Palette.grayscale[2]}
+          />
+          <CircleProgressBar
+            r="47"
+            cx="48"
+            cy="48"
+            fill="transparent"
+            stroke={Palette.primary}
+            strokeDasharray="300 1000"
+            strokeDashoffset={300 - ((this.props.loadingProgress || 0) * 3)}
+          />
+        </g>
+      </SvgWrapper>
+    )
+  }
+
+  renderProgressText(status: string) {
+    if (status !== 'PROGRESS') {
+      return null
+    }
+
+    return <ProgressText>{this.props.loadingProgress ? this.props.loadingProgress.toFixed(0) : 0}%</ProgressText>
+  }
+
   render() {
     let status = this.props.status
     if (this.props.loading) {
       status = 'RUNNING'
+      if (this.props.loadingProgress !== undefined && this.props.loadingProgress > -1) {
+        status = 'PROGRESS'
+      }
     }
+
     return (
-      <Wrapper status={status} {...this.props} />
+      <Wrapper
+        {...this.props}
+        status={status}
+      >
+        {this.renderProgressImage(status || '')}
+        {this.renderProgressText(status || '')}
+      </Wrapper>
     )
   }
 }

+ 27 - 0
src/components/atoms/StatusImage/story.jsx

@@ -12,10 +12,31 @@ 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 { storiesOf } from '@storybook/react'
 import StatusImage from '.'
 
+type State = {
+  loadingProgress: number,
+}
+class LoadingProgress extends React.Component<{}, State> {
+  state = {
+    loadingProgress: 50,
+  }
+
+  componentDidMount() {
+    setInterval(() => {
+      this.setState({ loadingProgress: this.state.loadingProgress === 50 ? 75 : 50 })
+    }, 1000)
+  }
+
+  render() {
+    return <StatusImage loading loadingProgress={this.state.loadingProgress} />
+  }
+}
+
 storiesOf('StatusImage', module)
   .add('completed', () => (
     <StatusImage status="COMPLETED" />
@@ -23,6 +44,12 @@ storiesOf('StatusImage', module)
   .add('running', () => (
     <StatusImage status="RUNNING" />
   ))
+  .add('loading progress', () => (
+    <StatusImage loading loadingProgress={45} />
+  ))
+  .add('loading progress animated', () => (
+    <LoadingProgress />
+  ))
   .add('error', () => (
     <StatusImage status="ERROR" />
   ))

+ 14 - 0
src/components/atoms/TextInput/images/close.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 48.2 (47327) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon/Close</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="AM-Long-List-Filtered-Edit" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(-537.000000, -562.000000)">
+        <g id="Icon/Close" transform="translate(537.000000, 562.000000)">
+            <rect id="Rectangle-1" fill="#A4AAB5" fill-rule="evenodd" x="0" y="0" width="16" height="16" rx="4"></rect>
+            <path d="M12,4 L4,12" id="Line" stroke="#FFFFFF" stroke-width="1.5" stroke-linecap="round"></path>
+            <path d="M12,12 L4,4" id="Line-Copy" stroke="#FFFFFF" stroke-width="1.5" stroke-linecap="round"></path>
+        </g>
+    </g>
+</svg>

+ 36 - 5
src/components/atoms/TextInput/index.jsx

@@ -20,6 +20,7 @@ import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
 
 import starImage from './images/star.svg'
+import closeImage from './images/close.svg'
 
 const Wrapper = styled.div`
   position: relative;
@@ -35,23 +36,24 @@ const getInputWidth = props => {
 
   return `${StyleProps.inputSizes.regular.width}px`
 }
+const borderColor = (props, defaultColor = Palette.grayscale[3]) => props.highlight ? Palette.alert : defaultColor
 const Input = styled.input`
   width: ${props => getInputWidth(props)};
   height: ${StyleProps.inputSizes.regular.height}px;
   line-height: ${StyleProps.inputSizes.regular.height}px;
   border-radius: ${StyleProps.borderRadius};
   background-color: #FFF;
-  border: 1px solid ${props => props.highlight ? Palette.alert : Palette.grayscale[3]};
+  border: 1px solid ${props => borderColor(props)};
   color: ${Palette.black};
   padding: 0 ${props => props.customRequired ? '29px' : '8px'} 0 16px;
   font-size: inherit;
   transition: all ${StyleProps.animations.swift};
   box-sizing: border-box;
   &:hover {
-    border-color: ${props => props.highlight ? Palette.alert : Palette.primary};
+    border-color: ${props => borderColor(props, props.disablePrimary ? null : Palette.primary)};
   }
   &:focus {
-    border-color: ${props => props.highlight ? Palette.alert : Palette.primary};
+    border-color: ${props => borderColor(props, props.disablePrimary ? null : Palette.primary)};
     outline: none;
   }
   &:disabled {
@@ -72,6 +74,16 @@ const Required = styled.div`
   height: 8px;
   background: url('${starImage}') center no-repeat;
 `
+const Close = styled.div`
+  display: ${props => props.show ? 'block' : 'none'};
+  width: 16px;
+  height: 16px;
+  background: url('${closeImage}') center no-repeat;
+  position: absolute;
+  top: 8px;
+  right: 8px;
+  cursor: pointer;
+`
 
 type Props = {
   _ref?: (ref: HTMLElement) => void,
@@ -83,13 +95,32 @@ type Props = {
   placeholder?: string,
   type?: string,
   value?: string,
+  showClose?: boolean,
+  onCloseClick?: () => void,
 }
 const TextInput = (props: Props) => {
-  const { _ref, required } = props
+  const { _ref, required, value, onChange, showClose, onCloseClick } = props
+  let input
   return (
     <Wrapper>
-      <Input innerRef={_ref} type="text" customRequired={required} {...props} />
+      <Input
+        innerRef={ref => { input = ref; if (_ref) _ref(ref) }}
+        type="text"
+        customRequired={required}
+        value={value}
+        onChange={onChange}
+        {...props}
+      />
       <Required show={required} />
+      <Close
+        show={showClose && value}
+        onClick={() => {
+          input.focus()
+          // $FlowIgnore
+          if (onChange) onChange({ target: { value: '' } })
+          if (onCloseClick) onCloseClick()
+        }}
+      />
     </Wrapper>
   )
 }

+ 16 - 0
src/components/atoms/TextInput/story.jsx

@@ -20,6 +20,19 @@ import TextInput from '.'
 const Wrapper = styled.div`
   display: inline-block;
 `
+class StatefulInput extends React.Component {
+  constructor() {
+    super()
+
+    this.state = {
+      value: '',
+    }
+  }
+
+  render() {
+    return <TextInput {...this.props} value={this.state.value} onChange={e => { this.setState({ value: e.target.value }) }} />
+  }
+}
 
 storiesOf('TextInput', module)
   .add('default', () => (
@@ -34,3 +47,6 @@ storiesOf('TextInput', module)
   .add('large', () => (
     <Wrapper><TextInput large /></Wrapper>
   ))
+  .add('with close', () => (
+    <Wrapper><StatefulInput showClose /></Wrapper>
+  ))

+ 134 - 0
src/components/molecules/AssessedVmListItem/index.jsx

@@ -0,0 +1,134 @@
+/*
+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 React from 'react'
+import styled from 'styled-components'
+
+import Checkbox from '../../atoms/Checkbox'
+import InfoIcon from '../../atoms/InfoIcon'
+import DropdownLink from '../../molecules/DropdownLink'
+import type { VmItem, VmSize } from '../../../types/Assessment'
+
+import Palette from '../../styleUtils/Palette'
+import StyleProps from '../../styleUtils/StyleProps'
+
+const Wrapper = styled.div`
+  position: relative;
+`
+const Content = styled.div`
+  display: flex;
+  margin-left: -16px;
+
+  & > div {
+    margin-left: 16px;
+  }
+
+  opacity: ${props => props.disabled ? 0.6 : 1};
+`
+const DisplayName = styled.div`
+  display: flex;
+  ${props => StyleProps.exactWidth(props.width)}
+`
+const DisplayNameLabel = styled.div`
+  margin-left: 8px;
+`
+const Value = styled.div`
+  color: ${Palette.grayscale[4]};
+  ${props => props.width ? StyleProps.exactWidth(props.width) : ''}
+`
+const InfoIconStyled = styled(InfoIcon) `
+  position: absolute;
+  left: -36px;
+  top: 0px;
+`
+
+type Props = {
+  item: VmItem,
+  columnsWidths: string[],
+  selected: boolean,
+  onSelectedChange: (item: VmItem, isChecked: boolean) => void,
+  disabled: boolean,
+  loadingVmSizes: boolean,
+  vmSizes: VmSize[],
+  onVmSizeChange: (size: VmSize) => void,
+  selectedVmSize: ?VmSize,
+  recommendedVmSize: string,
+}
+class AssessedVmListItem extends React.Component<Props> {
+  getColumnWidth(index: number) {
+    let width = parseInt(this.props.columnsWidths[index], 10)
+    return `${width - 16}px`
+  }
+
+  renderInfoIcon() {
+    if (!this.props.disabled) {
+      return null
+    }
+
+    return <InfoIconStyled warning text="We could not detect this VM on the source endpoint. Either the VM is missing or the selected endpoint is not the same as in the Azure Migrare Assesment." />
+  }
+
+  render() {
+    let disks = this.props.item.properties.disks
+    let standardCount = 0
+    let premiumCount = 0
+    Object.keys(disks).forEach(diskKey => {
+      if (disks[diskKey].recommendedDiskType === 'Standard') {
+        standardCount += 1
+      }
+      if (disks[diskKey].recommendedDiskType === 'Premium') {
+        premiumCount += 1
+      }
+    })
+
+    return (
+      <Wrapper>
+        {this.renderInfoIcon()}
+        <Content disabled={this.props.disabled}>
+          <DisplayName width={this.getColumnWidth(0)}>
+            <Checkbox
+              checked={this.props.selected}
+              onChange={checked => { this.props.onSelectedChange(this.props.item, checked) }}
+              disabled={this.props.disabled}
+            />
+            <DisplayNameLabel>{`${this.props.item.properties.datacenterContainer}/${this.props.item.properties.displayName}`}</DisplayNameLabel>
+          </DisplayName>
+          <Value width={this.getColumnWidth(1)}>
+            {this.props.item.properties.operatingSystem}
+          </Value>
+          <Value width={this.getColumnWidth(2)}>
+            {standardCount} Standard, {premiumCount} Premium
+          </Value>
+          <Value>
+            <DropdownLink
+              searchable
+              width={this.props.columnsWidths[3]}
+              noItemsLabel="Loading..."
+              items={this.props.loadingVmSizes ? [] : this.props.vmSizes.map(s => ({ value: s.name, label: s.name, size: s }))}
+              selectedItem={this.props.selectedVmSize ? this.props.selectedVmSize.name : ''}
+              listWidth="200px"
+              onChange={item => { this.props.onVmSizeChange(item.size) }}
+              disabled={this.props.disabled}
+              highlightedItem={this.props.recommendedVmSize}
+            />
+          </Value>
+        </Content>
+      </Wrapper>
+    )
+  }
+}
+
+export default AssessedVmListItem

+ 20 - 0
src/components/molecules/AssessmentListItem/images/assessment.svg

@@ -0,0 +1,20 @@
+<?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">
+    <!-- Generator: Sketch 48.1 (47250) - http://www.bohemiancoding.com/sketch -->
+    <title>AzureMigrate</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="AM-0" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(-400.000000, -200.000000)">
+        <g id="Azure-Migrate/Icon48" transform="translate(400.000000, 200.000000)">
+            <path d="M24,48 C37.254834,48 48,37.254834 48,24 C48,10.745166 37.254834,0 24,0 C10.745166,0 0,10.745166 0,24 C0,37.254834 10.745166,48 24,48 Z" id="Pat-Benetar" fill="#C8CCD7"></path>
+            <g id="Group-2" stroke-width="1" transform="translate(7.000000, 11.000000)" stroke="#0044CA" stroke-linecap="round">
+                <g id="Group" stroke-width="1.5">
+                    <polyline id="Rectangle-Copy" stroke-linejoin="round" transform="translate(15.583333, 14.400000) rotate(-405.000000) translate(-15.583333, -14.400000) " points="19.2563602 10.6664762 19.2563602 18.1335238 11.9103064 18.1335238"></polyline>
+                    <polyline id="Rectangle-Copy" stroke-linejoin="round" transform="translate(22.194444, 14.400000) rotate(-405.000000) translate(-22.194444, -14.400000) " points="25.8674713 10.6664762 25.8674713 18.1335238 18.5214176 18.1335238"></polyline>
+                    <polyline id="Rectangle-Copy" stroke-linejoin="round" transform="translate(8.972222, 14.400000) rotate(-405.000000) translate(-8.972222, -14.400000) " points="12.6452491 10.6664762 12.6452491 18.1335238 5.29919533 18.1335238"></polyline>
+                    <path d="M13.6,0 C9.20788,0 5.69092,3.71625102 5.50392,8.36338293 C2.32288,9.29135481 0,12.36108 0,16.0002424 C0,20.3964728 3.3694,24 7.48,24 L27.88,24 C31.2426,24 34,21.0509985 34,17.4547438 C34,14.6184661 32.232,12.2127208 29.85608,11.363292 C29.71192,7.09942123 26.46016,3.63625345 22.44,3.63625345 C21.73348,3.63625345 21.07592,3.82242962 20.42108,4.02315081 C18.95976,1.64504106 16.46756,0 13.6,0 Z" id="Path-Copy" transform="translate(17.000000, 12.000000) scale(-1, 1) translate(-17.000000, -12.000000) "></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 20 - 0
src/components/molecules/AssessmentListItem/images/azure-migrate.svg

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="48px" height="31px" viewBox="0 0 48 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 48.1 (47250) - http://www.bohemiancoding.com/sketch -->
+    <title>Group 6 Copy 2</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="AM-0" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(-778.000000, -210.000000)">
+        <g id="Group-6-Copy-2" transform="translate(778.000000, 210.000000)">
+            <g id="Group-5">
+                <path d="M41.1428571,25.6610799 L41.1428571,25.8333333 L9.42857143,25.8333333 L9.42857143,19.8055556 L12.2649615,19.8055556 C11.0112733,17.8117364 10.2857143,15.4493255 10.2857143,12.9166667 C10.2857143,5.78298865 16.0420532,0 23.1428571,0 C29.0801086,0 34.0774087,4.04304133 35.5557249,9.53815127 C36.718957,8.94525657 38.0349221,8.61111111 39.4285714,8.61111111 C44.1624407,8.61111111 48,12.4664369 48,17.2222222 C48,21.3882053 45.0552691,24.8632254 41.1428571,25.6610799 Z" id="Combined-Shape-Copy-5" fill="#616870"></path>
+                <path d="M5.14285714,30.9836864 C2.26609384,30.763834 0,28.3491684 0,25.4027778 C0,22.3115173 2.49441354,19.8055556 5.57142857,19.8055556 C5.77612888,19.8055556 5.97825079,19.8166461 6.17723144,19.8382617 C6.06064143,19.1271392 6,18.3970731 6,17.6527778 C6,10.2813105 11.9482169,4.30555556 19.2857143,4.30555556 C25.3772266,4.30555556 30.5112269,8.42413185 32.0791898,14.0406827 C33.3683783,13.3243978 34.8510789,12.9166667 36.4285714,12.9166667 C41.3991342,12.9166667 45.4285714,16.9647587 45.4285714,21.9583333 C45.4285714,26.5133595 42.0758068,30.2816802 37.7142857,30.9084352 L37.7142857,31 L5.14285714,31 L5.14285714,30.9836864 Z" id="Combined-Shape-Copy-4" fill="#A4AAB5"></path>
+                <g id="Group-3" transform="translate(13.714286, 6.027778)" fill="#FFFFFF" fill-rule="nonzero">
+                    <path d="M-0.00923979018,24.9722222 L8.44556964,16.5174128 L2.26149019,10.3333333 L7.1102224,10.3333333 L13.3055258,16.5286367 L4.86194026,24.9722222 L-0.00923979018,24.9722222 Z" id="Combined-Shape"></path>
+                    <path d="M15.4312364,22.3888889 L21.3027125,16.5174128 L16.8408553,12.0555556 L21.6895875,12.0555556 L26.1626686,16.5286367 L20.3024164,22.3888889 L15.4312364,22.3888889 Z" id="Combined-Shape-Copy"></path>
+                    <path d="M10.2883793,10.3333333 L16.1598554,4.46185723 L11.6979981,0 L16.5467303,0 L21.0198115,4.47308115 L15.1595593,10.3333333 L10.2883793,10.3333333 Z" id="Combined-Shape-Copy-2"></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 146 - 0
src/components/molecules/AssessmentListItem/index.jsx

@@ -0,0 +1,146 @@
+/*
+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 React from 'react'
+import styled from 'styled-components'
+import moment from 'moment'
+
+import StatusPill from '../../atoms/StatusPill'
+
+import Palette from '../../styleUtils/Palette'
+import StyleProps from '../../styleUtils/StyleProps'
+import type { Assessment } from '../../../types/Assessment'
+
+import assessmentImage from './images/assessment.svg'
+import azureMigrateImage from './images/azure-migrate.svg'
+
+const Content = styled.div`
+  display: flex;
+  align-items: center;
+  border-top: 1px solid ${Palette.grayscale[1]};
+  padding: 8px 16px;
+  cursor: pointer;
+  flex-grow: 1;
+  transition: all ${StyleProps.animations.swift};
+  min-width: 785px;
+
+  &:hover {
+    background: ${Palette.grayscale[1]};
+  }
+`
+const Wrapper = styled.div`
+  display: flex;
+  align-items: center;
+
+  &:last-child ${Content} {
+    border-bottom: 1px solid ${Palette.grayscale[1]};
+  }
+`
+const Image = styled.div`
+  min-width: 48px;
+  height: 48px;
+  background: url('${props => props.image}') no-repeat center;
+  margin-right: 16px;
+`
+const Title = styled.div`
+  flex-grow: 1;
+  overflow: hidden;
+  margin-right: 48px;
+  min-width: 100px;
+`
+const TitleLabel = styled.div`
+  font-size: 16px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`
+const AssessmentType = styled.div`
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-right: 46px;
+`
+const AssessmentImage = styled.div`
+  width: 48px;
+  height: 32px;
+  background: url('${azureMigrateImage}') center no-repeat;
+  margin-right: 12px;
+`
+const AssessmentLabel = styled.div`
+  font-size: 15px;
+  color: ${Palette.grayscale[4]};
+  width: 64px;
+`
+const Project = styled.div`
+  min-width: 96px;
+  margin-right: 48px;
+`
+const Updated = styled.div`
+  min-width: 175px;
+`
+const ItemLabel = styled.div`
+  color: ${Palette.grayscale[4]};
+`
+const ItemValue = styled.div`
+  color: ${Palette.primary};
+`
+
+type Props = {
+  item: Assessment,
+  onClick: () => void,
+}
+class AssessmentListItem extends React.Component<Props> {
+  render() {
+    let status = this.props.item.properties.status.toUpperCase()
+    let label = status
+    if (status === 'CREATED' || status === 'RUNNING') {
+      status = 'RUNNING'
+      label = 'CREATING'
+    } else if (status === 'COMPLETED') {
+      label = 'READY'
+    }
+
+    return (
+      <Wrapper>
+        <Content onClick={this.props.onClick}>
+          <Image image={assessmentImage} />
+          <Title>
+            <TitleLabel>{this.props.item.name}</TitleLabel>
+            <StatusPill status={status} label={label} />
+          </Title>
+          <AssessmentType>
+            <AssessmentImage />
+            <AssessmentLabel>Azure Migrate</AssessmentLabel>
+          </AssessmentType>
+          <Project>
+            <ItemLabel>Project</ItemLabel>
+            <ItemValue>
+              {this.props.item.project.name}
+            </ItemValue>
+          </Project>
+          <Updated>
+            <ItemLabel>Updated</ItemLabel>
+            <ItemValue>
+              {moment(this.props.item.properties.updatedTimestamp).format('DD MMMM YYYY, HH:mm')}
+            </ItemValue>
+          </Updated>
+        </Content>
+      </Wrapper>
+    )
+  }
+}
+
+export default AssessmentListItem

+ 7 - 6
src/components/molecules/DetailsNavigation/index.jsx

@@ -32,12 +32,13 @@ const Item = styled.a`
   margin-bottom: 13px;
   text-decoration: none;
 `
-
+type ItemType = { label: string, value: string }
 type Props = {
-  items: { label: string, value: string }[],
-  selectedValue: string,
-  itemId: string,
-  itemType: string,
+  items: ItemType[],
+  selectedValue?: string,
+  itemId?: string,
+  itemType?: string,
+  customHref?: (item: ItemType) => ?string,
 }
 class DetailsNavigation extends React.Component<Props> {
   renderItems() {
@@ -46,7 +47,7 @@ class DetailsNavigation extends React.Component<Props> {
         <Item
           selected={item.value === this.props.selectedValue}
           key={item.value || item.label}
-          href={`/#/${this.props.itemType}${(item.value && '/') || ''}${item.value}/${this.props.itemId}`}
+          href={this.props.customHref ? this.props.customHref(item) : `/#/${this.props.itemType || ''}${(item.value && '/') || ''}${item.value}/${this.props.itemId || ''}`}
         >{item.label}</Item>
       ))
     )

+ 15 - 0
src/components/molecules/DropdownFilter/images/filter.js

@@ -0,0 +1,15 @@
+const filter = color => `
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="9px" height="9px" viewBox="0 0 9 9" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 48.2 (47327) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon/Search/Light</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="AM-Long-List" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(-508.000000, -523.000000)" stroke-linecap="round" stroke-linejoin="round">
+        <g id="Icon/Filter/Small/Light" transform="translate(504.000000, 519.000000)" fill="${color}" stroke="${color}" stroke-width="0.75">
+            <path d="M7.44318182,7.74969549 L5,5 L12,5 L9.44318182,7.87758831 L9.44318182,10.6998021 L8.27542001,12 L7.44318182,12 L7.44318182,7.74969549 Z" id="Combined-Shape"></path>
+        </g>
+    </g>
+</svg>
+`
+export default filter

+ 166 - 0
src/components/molecules/DropdownFilter/index.jsx

@@ -0,0 +1,166 @@
+/*
+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 React from 'react'
+import styled from 'styled-components'
+
+import SearchInput from '../SearchInput'
+
+import Palette from '../../styleUtils/Palette'
+
+import filterImage from './images/filter'
+
+const border = '1px solid rgba(216, 219, 226, 0.4)'
+
+const Wrapper = styled.div`
+  position: relative;
+  margin-top: -1px;
+`
+const Button = styled.div`
+  width: 16px;
+  height: 16px;
+  cursor: pointer;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+`
+const List = styled.div`
+  position: absolute;
+  top: 24px;
+  right: -7px;
+  z-index: 20;
+  padding: 8px;
+  background: ${Palette.grayscale[1]};
+  border-radius: 4px;
+  border: ${border};
+  box-shadow: 0 0 4px 0 rgba(32, 34, 52, 0.13);
+`
+const Tip = styled.div`
+  position: absolute;
+  top: -6px;
+  right: 8px;
+  width: 10px;
+  height: 10px;
+  background: ${Palette.grayscale[1]};
+  border-top: ${border}};
+  border-left: ${border};
+  border-bottom: 1px solid transparent;
+  border-right: 1px solid transparent;
+  transform: rotate(45deg);
+`
+const ListItems = styled.div`
+  width: 199px;
+  height: 32px;
+`
+
+type Props = {
+  searchPlaceholder?: string,
+  searchValue?: string,
+  onSearchChange?: (value: string) => void,
+}
+type State = {
+  showDropdownList: boolean
+}
+class DropdownFilter extends React.Component<Props, State> {
+  static defaultProps: $Shape<Props> = {
+    searchPlaceholder: 'Filter',
+  }
+
+  itemMouseDown: boolean
+
+  constructor() {
+    super()
+
+    // $FlowIssue
+    this.handlePageClick = this.handlePageClick.bind(this)
+
+    this.state = {
+      showDropdownList: false,
+    }
+  }
+
+  componentDidMount() {
+    window.addEventListener('mousedown', this.handlePageClick, false)
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener('mousedown', this.handlePageClick, false)
+  }
+
+  handlePageClick() {
+    if (!this.itemMouseDown) {
+      this.setState({ showDropdownList: false })
+    }
+  }
+
+  handleButtonClick() {
+    this.setState({ showDropdownList: !this.state.showDropdownList })
+  }
+
+  handleCloseClick() {
+    this.setState({ showDropdownList: false })
+  }
+
+  renderList() {
+    if (!this.state.showDropdownList) {
+      return null
+    }
+
+    return (
+      <List
+        onMouseDown={() => { this.itemMouseDown = true }}
+        onMouseUp={() => { this.itemMouseDown = false }}
+      >
+        <Tip />
+        <ListItems>
+          <SearchInput
+            width="100%"
+            alwaysOpen
+            placeholder={this.props.searchPlaceholder}
+            value={this.props.searchValue}
+            onChange={this.props.onSearchChange}
+            useFilterIcon
+            focusOnMount
+            disablePrimary
+            onCloseClick={() => { this.handleCloseClick() }}
+          />
+        </ListItems>
+      </List>
+    )
+  }
+
+  renderButton() {
+    return (
+      <Button
+        onMouseDown={() => { this.itemMouseDown = true }}
+        onMouseUp={() => { this.itemMouseDown = false }}
+        onClick={() => { this.handleButtonClick() }}
+        dangerouslySetInnerHTML={{ __html: filterImage(this.props.searchValue ? Palette.primary : Palette.grayscale[5]) }}
+      />
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        {this.renderButton()}
+        {this.renderList()}
+      </Wrapper>
+    )
+  }
+}
+
+export default DropdownFilter

+ 44 - 0
src/components/molecules/DropdownFilter/story.jsx

@@ -0,0 +1,44 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import DropdownFilter from '.'
+
+class Wrapper extends React.Component {
+  constructor() {
+    super()
+
+    this.state = {
+      value: '',
+    }
+  }
+
+  render() {
+    return (
+      <DropdownFilter
+        {...this.props}
+        searchValue={this.state.value}
+        searchOnChange={value => { this.setState({ value }) }}
+      />
+    )
+  }
+}
+
+storiesOf('DropdownFilter', module)
+  .add('default', () => (
+    <div style={{ marginLeft: '300px' }}>
+      <Wrapper />
+    </div>
+  ))

+ 210 - 42
src/components/molecules/DropdownLink/index.jsx

@@ -15,9 +15,13 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 // @flow
 
 import React from 'react'
-import styled from 'styled-components'
+import styled, { css } from 'styled-components'
+import ReactDOM from 'react-dom'
+
+import SearchInput from '../../molecules/SearchInput'
 
 import Palette from '../../styleUtils/Palette'
+import StyleProps from '../../styleUtils/StyleProps'
 
 import arrowImage from './images/arrow.svg'
 import checkmarkImage from './images/checkmark.svg'
@@ -26,36 +30,41 @@ const Wrapper = styled.div`
   display: inline-block;
   position: relative;
 `
+const SearchInputWrapper = styled.div``
 const LinkButton = styled.div`
   display: flex;
   align-items: center;
-  cursor: pointer;
+  cursor: ${props => props.disabled ? 'default' : 'pointer'};
 `
 const List = styled.div`
   position: absolute;
-  top: 28px;
-  right: -7px;
   z-index: 20;
   padding: 8px;
   background: ${Palette.grayscale[1]};
   border-radius: 4px;
   border: 1px solid ${Palette.grayscale[0]};
-  width: 110px;
-
-  &:after {
-    content: ' ';
-    position: absolute;
-    top: -6px;
-    right: 8px;
-    width: 10px;
-    height: 10px;
-    background: ${Palette.grayscale[1]};
-    border-top: 1px solid ${Palette.grayscale[0]};
-    border-left: 1px solid ${Palette.grayscale[0]};
-    border-bottom: 1px solid transparent;
-    border-right: 1px solid transparent;
-    transform: rotate(45deg);
-  }
+  ${props => props.width ? StyleProps.exactWidth(props.width) : css`
+    min-width: 132px;
+    max-width: 160px;
+  `}
+`
+const Tip = styled.div`
+  position: absolute;
+  top: -6px;
+  right: 8px;
+  width: 10px;
+  height: 10px;
+  background: ${Palette.grayscale[1]};
+  border-top: 1px solid ${Palette.grayscale[0]};
+  border-left: 1px solid ${Palette.grayscale[0]};
+  border-bottom: 1px solid transparent;
+  border-right: 1px solid transparent;
+  transform: rotate(45deg);
+`
+const ListItems = styled.div`
+  max-height: 400px;
+  overflow: auto;
+  ${props => props.searchable ? 'margin-top: 8px;' : ''}
 `
 const ListItem = styled.div`
   padding-top: 13px;
@@ -67,18 +76,22 @@ const ListItem = styled.div`
     padding-top: 0;
   }
 `
-const ListItemLabel = styled.div``
+const ListItemLabel = styled.div`
+  word-break: break-all;
+  word-break: break-word;
+  ${props => props.highlighted ? `font-weight: ${StyleProps.fontWeights.medium};` : ''}
+`
 const Checkmark = styled.div`
-  width: 16px;
+  ${StyleProps.exactWidth('16px')}
   height: 16px;
   background: ${props => props.show ? `url('${checkmarkImage}') center no-repeat` : 'transparent'};
   margin-right: 8px;
 `
 const Label = styled.div`
-  display: flex;
-  justify-content: center;
-  align-items: center;
   color: ${Palette.primary};
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
 `
 const Arrow = styled.div`
   width: 16px;
@@ -87,25 +100,52 @@ const Arrow = styled.div`
   margin-left: 4px;
   margin-top: -1px;
 `
+const EmptySearch = styled.div`
+  margin-top: 8px;
+`
 
 type ItemType = {
   label: string,
-  value: string
+  value: string,
+  [string]: any,
 }
 type Props = {
-  selectedItem: string,
+  selectedItem?: string,
   items: ItemType[],
-  onChange: (item: ItemType) => void
+  onChange?: (item: ItemType) => void,
+  highlightedItem?: string,
+  className?: string,
+  width?: string,
+  selectItemLabel?: string,
+  noItemsLabel?: string,
+  listWidth?: string,
+  searchable?: boolean,
+  disabled?: boolean,
+}
+type State = {
+  showDropdownList: boolean,
+  searchText: string,
 }
-type State = {showDropdownList: boolean}
 class DropdownLink extends React.Component<Props, State> {
+  static defaultProps: $Shape<Props> = {
+    selectItemLabel: 'Select',
+    noItemsLabel: 'No items',
+  }
+
   itemMouseDown: boolean
+  labelRef: HTMLElement
+  listItemsRef: HTMLElement
+  listRef: HTMLElement
+  arrowRef: HTMLElement
+  tipRef: HTMLElement
+  searchInputWrapperRef: HTMLElement
 
   constructor() {
     super()
 
     this.state = {
       showDropdownList: false,
+      searchText: '',
     }
 
     const self: any = this
@@ -114,12 +154,41 @@ class DropdownLink extends React.Component<Props, State> {
 
   componentDidMount() {
     window.addEventListener('mousedown', this.handlePageClick, false)
+    this.setLabelWidth()
+  }
+
+  componentDidUpdate() {
+    this.setLabelWidth()
+    this.updateListPosition()
   }
 
   componentWillUnmount() {
     window.removeEventListener('mousedown', this.handlePageClick, false)
   }
 
+  setLabelWidth() {
+    this.labelRef.style.width = ''
+    let width = parseInt(this.props.width, 10)
+    if (!width) {
+      return
+    }
+
+    width -= 28
+    let labelWidth = this.labelRef.offsetWidth
+    if (labelWidth < width) {
+      return
+    }
+
+    this.labelRef.style.width = `${width}px`
+  }
+
+  getFilteredItems() {
+    return this.props.items.filter(item =>
+      item.value.toLowerCase().indexOf(this.state.searchText.toLowerCase()) > -1 ||
+      item.label.toLowerCase().indexOf(this.state.searchText.toLowerCase()) > -1
+    )
+  }
+
   handlePageClick() {
     if (!this.itemMouseDown) {
       this.setState({ showDropdownList: false })
@@ -127,7 +196,13 @@ class DropdownLink extends React.Component<Props, State> {
   }
 
   handleButtonClick() {
-    this.setState({ showDropdownList: !this.state.showDropdownList })
+    if (this.props.disabled) {
+      return
+    }
+
+    this.setState({ showDropdownList: !this.state.showDropdownList }, () => {
+      this.scrollIntoView()
+    })
   }
 
   handleItemClick(item: ItemType) {
@@ -138,14 +213,77 @@ class DropdownLink extends React.Component<Props, State> {
     }
   }
 
-  renderList() {
-    if (!this.props.items || this.props.items.length === 0 || !this.state.showDropdownList) {
+  handleSearchTextChange(searchText: string) {
+    this.setState({ searchText })
+  }
+
+  scrollIntoView() {
+    if (!this.listRef || !this.listItemsRef) {
+      return
+    }
+
+    let itemIndex = this.props.items.findIndex(i => i.value === this.props.selectedItem)
+    if (itemIndex === -1 || !this.listItemsRef.children[itemIndex]) {
+      return
+    }
+
+    // $FlowIssue
+    this.listItemsRef.children[itemIndex].parentNode.scrollTop = this.listItemsRef.children[itemIndex].offsetTop - this.listItemsRef.children[itemIndex].parentNode.offsetTop
+  }
+
+  updateListPosition() {
+    if (!this.state.showDropdownList || !this.listRef || !this.arrowRef || !this.tipRef) {
+      return
+    }
+
+    let listWidth = this.listRef.offsetWidth
+    let arrowWidth = this.arrowRef.offsetWidth
+    let arrowHeight = this.arrowRef.offsetHeight
+    let tipHeight = this.tipRef.offsetHeight
+    const tipOffset = 7
+    let arrowOffset = this.arrowRef.getBoundingClientRect()
+    this.listRef.style.top = `${arrowOffset.top + window.pageYOffset + arrowHeight + tipHeight}px`
+    this.listRef.style.left = `${arrowOffset.left + tipOffset + (arrowWidth - listWidth)}px`
+  }
+
+  renderSearch() {
+    if (!this.props.searchable) {
+      return null
+    }
+
+    return (
+      <SearchInputWrapper
+        innerRef={ref => { this.searchInputWrapperRef = ref }}
+        onMouseDown={() => { this.itemMouseDown = true }}
+        onMouseUp={() => { this.itemMouseDown = false }}
+      >
+        <SearchInput
+          alwaysOpen
+          width="100%"
+          onChange={text => { this.handleSearchTextChange(text) }}
+          value={this.state.searchText}
+        />
+      </SearchInputWrapper>
+    )
+  }
+
+  renderEmptySearch() {
+    if (!this.state.searchText || this.getFilteredItems().length > 0) {
+      return null
+    }
+
+    return <EmptySearch>No items found</EmptySearch>
+  }
+
+  renderListItems() {
+    if (this.state.searchText && this.getFilteredItems().length === 0) {
       return null
     }
 
     return (
-      <List>
-        {this.props.items.map((item) => {
+      <ListItems innerRef={ref => { this.listItemsRef = ref }} searchable={this.props.searchable}>
+        {this.getFilteredItems().map((item) => {
+          let highlighted = item.value !== this.props.selectedItem ? item.value === this.props.highlightedItem : false
           let listItem = (
             <ListItem
               key={item.label}
@@ -155,28 +293,58 @@ class DropdownLink extends React.Component<Props, State> {
               selected={item.value === this.props.selectedItem}
             >
               <Checkmark show={item.value === this.props.selectedItem} />
-              <ListItemLabel>{item.label}</ListItemLabel>
+              <ListItemLabel highlighted={highlighted}>{item.label}</ListItemLabel>
             </ListItem>
           )
 
           return listItem
         })}
-      </List>
+      </ListItems>
     )
   }
 
+  renderList() {
+    if (!this.props.items || this.props.items.length === 0 || !this.state.showDropdownList) {
+      return null
+    }
+
+    let body: any = document.body
+    return ReactDOM.createPortal((
+      <List innerRef={list => { this.listRef = list }} width={this.props.listWidth}>
+        <Tip innerRef={ref => { this.tipRef = ref }} />
+        {this.renderSearch()}
+        {this.renderEmptySearch()}
+        {this.renderListItems()}
+      </List>
+    ), body)
+  }
+
   render() {
-    let selectedItem = this.props.items.find(i => i.value === this.props.selectedItem)
+    let renderLabel = () => {
+      if (this.props.items && this.props.items.length && this.props.selectedItem) {
+        let item = this.props.items.find(i => i.value === this.props.selectedItem)
+        if (item && item.label) {
+          return item.label
+        }
+      }
+      if (!this.props.items || this.props.items.length === 0) {
+        return this.props.noItemsLabel
+      }
+      return this.props.selectItemLabel
+    }
 
     return (
-      <Wrapper>
+      <Wrapper
+        className={this.props.className}
+        onMouseDown={() => { this.itemMouseDown = true }}
+        onMouseUp={() => { this.itemMouseDown = false }}
+      >
         <LinkButton
-          onMouseDown={() => { this.itemMouseDown = true }}
-          onMouseUp={() => { this.itemMouseDown = false }}
           onClick={() => this.handleButtonClick()}
+          disabled={this.props.disabled}
         >
-          <Label>{selectedItem ? selectedItem.label : ''}</Label>
-          <Arrow />
+          <Label innerRef={label => { this.labelRef = label }}>{renderLabel()}</Label>
+          <Arrow innerRef={arrow => { this.arrowRef = arrow }} />
         </LinkButton>
         {this.renderList()}
       </Wrapper>

+ 4 - 0
src/components/molecules/DropdownLink/story.jsx

@@ -40,6 +40,7 @@ class Wrapper extends React.Component {
           items={this.state.items}
           selectedItem={this.state.selectedItem}
           onChange={item => { this.handleItemChange(item.value) }}
+          {...this.props}
         />
       </div>
     )
@@ -50,3 +51,6 @@ storiesOf('DropdownLink', module)
   .add('default', () => (
     <Wrapper />
   ))
+  .add('searchable', () => (
+    <Wrapper searchable />
+  ))

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

@@ -34,7 +34,7 @@ const CheckboxStyled = styled(Checkbox) `
 const Content = styled.div`
   display: flex;
   align-items: center;
-  margin-left: 32px;
+  margin-left: 16px;
   border-top: 1px solid ${Palette.grayscale[1]};
   padding: 8px 16px;
   cursor: pointer;

+ 27 - 8
src/components/molecules/MainListFilter/index.jsx

@@ -14,7 +14,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 // @flow
 
-import React from 'react'
+import * as React from 'react'
 import styled from 'styled-components'
 
 import Checkbox from '../../atoms/Checkbox'
@@ -41,7 +41,7 @@ const Main = styled.div`
 `
 const FilterGroup = styled.div`
   display: flex;
-  margin: 0 16px 0 32px;
+  margin: 0 16px 0 ${props => props.noMargin ? '0' : '32px'};
   border-right: 1px solid ${Palette.grayscale[4]};
 `
 const FilterItem = styled.div`
@@ -80,16 +80,26 @@ type Props = {
   onSearchChange: (value: string) => void,
   onSelectAllChange: (checked: boolean) => void,
   onActionChange: (action: string) => void,
-  actions: DictItem[],
+  actions?: DictItem[],
   selectedValue: string,
   selectionInfo: { total: number, selected: number, label: string },
   selectAllSelected: ?boolean,
   items: DictItem[],
+  customFilterComponent?: React.Node,
+  searchValue?: string,
 }
 class MainListFilter extends React.Component<Props> {
   renderFilterGroup() {
+    let renderCustomComponent = () => {
+      if (this.props.customFilterComponent) {
+        return this.props.customFilterComponent
+      }
+      return null
+    }
+
     return (
-      <FilterGroup>
+      <FilterGroup noMargin={!this.props.actions || this.props.actions.length === 0}>
+        {renderCustomComponent()}
         {this.props.items.map(item => {
           return (
             <FilterItem
@@ -125,16 +135,25 @@ class MainListFilter extends React.Component<Props> {
   }
 
   render() {
-    return (
-      <Wrapper>
-        <Main>
+    let renderCheckbox = () => {
+      if (this.props.actions && this.props.actions.length > 0) {
+        return (
           <Checkbox
             onChange={checked => { this.props.onSelectAllChange(checked) }}
             checked={!!this.props.selectAllSelected}
           />
+        )
+      }
+      return null
+    }
+
+    return (
+      <Wrapper>
+        <Main>
+          {renderCheckbox()}
           {this.renderFilterGroup()}
           <ReloadButton style={{ marginRight: '16px' }} onClick={this.props.onReloadButtonClick} />
-          <SearchInput onChange={this.props.onSearchChange} />
+          <SearchInput onChange={this.props.onSearchChange} value={this.props.searchValue} />
         </Main>
         {this.renderSelectionInfo()}
       </Wrapper>

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

@@ -36,7 +36,7 @@ const CheckboxStyled = styled(Checkbox) `
 const Content = styled.div`
   display: flex;
   align-items: center;
-  margin-left: 32px;
+  margin-left: 16px;
   border-top: 1px solid ${Palette.grayscale[1]};
   padding: 8px 16px;
   cursor: pointer;

+ 36 - 21
src/components/molecules/SearchInput/index.jsx

@@ -24,49 +24,56 @@ import StatusIcon from '../../atoms/StatusIcon'
 import StyleProps from '../../styleUtils/StyleProps'
 
 const Input = styled(TextInput) `
-  position: absolute;
-  top: -8px;
-  left: -8px;
   padding-left: 32px;
-  ${props => props.loading ? 'padding-right: 32px;' : ''}
+  ${props => props.loading || (props.showClose && props.value) ? 'padding-right: 32px;' : ''}
   width: 50px;
   opacity: 0;
   transition: all ${StyleProps.animations.swift};
 `
-const InputAnimation = css`
+const InputAnimation = props => css`
   ${Input} {
-    width: ${StyleProps.inputSizes.regular.width}px;
+    width: ${props.width};
     opacity: 1;
   }
 `
 const Wrapper = styled.div`
   position: relative;
-  ${props => props.open ? InputAnimation : ''}
+  width: ${props => props.open ? props.width : '50px'};
+  ${props => props.open ? InputAnimation(props) : ''}
 `
 const SearchButtonStyled = styled(SearchButton)`
-  position: relative;
+  position: absolute;
+  top: 8px;
+  left: 8px;
 `
 const StatusIconStyled = styled(StatusIcon)`
   position: absolute;
-  left: 144px;
-  top: 0;
+  right: 8px;
+  top: 8px;
 `
 
 type Props = {
-  onChange: (value: string) => void,
+  onChange?: (value: string) => void,
+  onCloseClick?: () => void,
   alwaysOpen?: boolean,
   loading?: boolean,
-  placeholder: string,
+  focusOnMount?: boolean,
+  disablePrimary?: boolean,
+  useFilterIcon?: boolean,
+  placeholder?: string,
+  width?: string,
+  value?: string,
+  className?: string,
 }
 type State = {
   open: boolean,
   hover?: boolean,
   focus?: boolean,
-  value: string,
 }
 class SearchInput extends React.Component<Props, State> {
-  static defaultProps = {
+  static defaultProps: $Shape<Props> = {
     placeholder: 'Search',
+    width: `${StyleProps.inputSizes.regular.width}px`,
   }
 
   input: HTMLElement
@@ -86,6 +93,8 @@ class SearchInput extends React.Component<Props, State> {
 
   componentDidMount() {
     window.addEventListener('mousedown', this.handlePageClick, false)
+
+    this.props.focusOnMount && this.input.focus()
   }
 
   componentWillUnmount() {
@@ -122,31 +131,37 @@ class SearchInput extends React.Component<Props, State> {
   render() {
     return (
       <Wrapper
-        open={this.state.open || this.props.alwaysOpen || this.state.value !== ''}
+        open={this.state.open || this.props.alwaysOpen || this.props.value !== ''}
         onMouseDown={() => { this.itemMouseDown = true }}
         onMouseUp={() => { this.itemMouseDown = false }}
         onMouseEnter={() => { this.handleMouseEnter() }}
         onMouseLeave={() => { this.handleMouseLeave() }}
+        width={this.props.width}
+        className={this.props.className}
       >
         <Input
           _ref={input => { this.input = input }}
           placeholder={this.props.placeholder}
-          onChange={e => {
-            this.setState({ value: e.target.value })
-            this.props.onChange(e.target.value)
-          }}
-          value={this.state.value}
+          onChange={e => { if (this.props.onChange) this.props.onChange(e.target.value) }}
           onFocus={() => { this.handleFocus() }}
           onBlur={() => { this.handleBlur() }}
           loading={this.props.loading}
+          value={this.props.value}
+          disablePrimary={this.props.disablePrimary}
+          showClose={
+            !this.props.loading &&
+            (this.state.open || this.props.alwaysOpen || this.props.value !== '')
+          }
+          onCloseClick={() => { if (this.props.onCloseClick) this.props.onCloseClick() }}
         />
         <SearchButtonStyled
           primary={
             this.state.open ||
             (this.props.alwaysOpen && (this.state.hover || this.state.focus)) ||
-            (this.state.value !== '' && (this.state.hover || this.state.focus))
+            (this.props.value !== '' && (this.state.hover || this.state.focus))
           }
           onClick={() => { this.handleSearchButtonClick() }}
+          useFilterIcon={this.props.useFilterIcon}
         />
         {this.props.loading ? <StatusIconStyled status="RUNNING" /> : null}
       </Wrapper>

+ 18 - 1
src/components/molecules/SearchInput/story.jsx

@@ -16,7 +16,24 @@ import React from 'react'
 import { storiesOf } from '@storybook/react'
 import SearchInput from '.'
 
+class Wrapper extends React.Component {
+  constructor() {
+    super()
+
+    this.state = {
+      value: '',
+    }
+  }
+
+  render() {
+    return <SearchInput {...this.props} value={this.state.value} onChange={value => { this.setState({ value }) }} />
+  }
+}
+
 storiesOf('SearchInput', module)
   .add('default', () => (
-    <SearchInput />
+    <Wrapper />
+  ))
+  .add('always open', () => (
+    <Wrapper alwaysOpen />
   ))

+ 6 - 3
src/components/molecules/SideMenu/index.jsx

@@ -20,6 +20,7 @@ import styled, { css } from 'styled-components'
 import StyleProps from '../../styleUtils/StyleProps'
 import Palette from '../../styleUtils/Palette'
 
+import { navigationMenu } from '../../../config'
 import hamburgerImage from './images/hamburger'
 import backgroundImage from './images/star-bg.jpg'
 
@@ -109,9 +110,11 @@ class SideMenu extends React.Component<Props, State> {
           dangerouslySetInnerHTML={{ __html: hamburgerImage() }}
         />
         <Menu open={this.state.open}>
-          <MenuItem href="/#/replicas">Replicas</MenuItem>
-          <MenuItem href="/#/migrations">Migrations</MenuItem>
-          <MenuItem href="/#/endpoints">Cloud Endpoints</MenuItem>
+          {navigationMenu.filter(i => !i.disabled).map(item => {
+            return (
+              <MenuItem key={item.value} href={`/#/${item.value}`}>{item.label}</MenuItem>
+            )
+          })}
         </Menu>
       </Wrapper>
     )

+ 91 - 30
src/components/molecules/Table/index.jsx

@@ -15,7 +15,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 // @flow
 
 import * as React from 'react'
-import styled from 'styled-components'
+import styled, { css } from 'styled-components'
 
 import StyleProps from '../../styleUtils/StyleProps'
 import Palette from '../../styleUtils/Palette'
@@ -23,77 +23,137 @@ import Palette from '../../styleUtils/Palette'
 const Wrapper = styled.div`
   display: flex;
   flex-direction: column;
+  ${props => props.secondary ? css`
+    &:after {
+      content: ' ';
+      height: 4px;
+      background: ${Palette.grayscale[1]};
+      border-bottom-left-radius: ${StyleProps.borderRadius};
+      border-bottom-right-radius: ${StyleProps.borderRadius};
+    }
+  ` : ''}
 `
 const Header = styled.div`
   display: flex;
-  padding-bottom: 8px;
+  border-bottom: 1px solid ${props => props.secondary ? Palette.grayscale[5] : Palette.grayscale[2]};
+  ${props => props.secondary ? css`
+    padding: 8px;
+    background: ${Palette.grayscale[1]};
+    border-top-left-radius: ${StyleProps.borderRadius};
+    border-top-right-radius: ${StyleProps.borderRadius};
+  ` : css`
+    padding-bottom: 8px;
+  `}
+  ${props => props.customStyle}
 `
 const HeaderData = styled.div`
-  width: ${props => props.width};
-  color: ${Palette.grayscale[3]};
+  ${props => props.useExactWidth ? StyleProps.exactWidth(props.width) : `width: ${props.width};`}
+  color: ${props => props.secondary ? Palette.grayscale[5] : Palette.grayscale[3]};
   font-size: 10px;
   font-weight: ${StyleProps.fontWeights.medium};
   text-transform: uppercase;
+  line-height: 16px;
 `
 const Body = styled.div`
   display: flex;
   flex-direction: column;
+  max-height: 225px;
+  overflow: auto;
+  ${props => props.customStyle}
 `
 const Row = styled.div`
   display: flex;
-  padding: 6px 0;
-  border-top: 1px solid ${Palette.grayscale[2]};
-
-  &:last-child {
-    border-bottom: 1px solid ${Palette.grayscale[2]};
-  }
+  padding: ${props => props.secondary ? '8px' : '8px 0'};
+  ${props => props.secondary ? `background: ${Palette.grayscale[1]};` : ''}
+  border-bottom: 1px solid ${props => props.secondary ? 'white' : Palette.grayscale[2]};
+  flex-shrink: 0;
+  ${props => props.secondary ? css`
+    &:last-child {
+      border-bottom: 0;
+      padding-bottom: 4px;
+    }
+  ` : ''}
 `
 const RowData = styled.div`
   width: ${props => props.width};
   color: ${Palette.grayscale[4]};
   ${props => props.customStyle}
 `
+const NoItems = styled.div`
+  text-align: center;
+  padding: 16px;
+  margin-left: 24px;
+  ${props => props.secondary ? `background: ${Palette.grayscale[1]};` : ''}
+`
 
 type Props = {
-  header: string[],
+  header: React.Node[],
   items: Array<Array<React.Node>>,
-  columnsStyle: { [string]: mixed }[],
-  className: string,
+  columnsStyle?: mixed[],
+  columnsWidths?: string[],
+  className?: string,
+  useSecondaryStyle?: boolean,
+  noItemsLabel?: string,
+  bodyStyle?: any,
+  headerStyle?: any,
 }
 class Table extends React.Component<Props> {
+  static defaultProps: $Shape<Props> = {
+    columnsWidths: [],
+    noItemsLabel: 'No items!',
+  }
+
   renderHeader() {
     let dataWidth = `${100 / this.props.header.length}%`
     return (
-      <Header>
-        {this.props.header.map(headerItem => {
+      <Header secondary={this.props.useSecondaryStyle} customStyle={this.props.headerStyle}>
+        {this.props.header.map((headerItem, i) => {
           return (
-            <HeaderData width={dataWidth} key={headerItem}>{headerItem}</HeaderData>
+            <HeaderData
+              width={this.props.columnsWidths && this.props.columnsWidths.length > 0 ? this.props.columnsWidths[i] : dataWidth}
+              key={i}
+              useExactWidth={i < this.props.header.length - 1}
+              secondary={this.props.useSecondaryStyle}
+            >{headerItem}</HeaderData>
           )
         })}
       </Header>
     )
   }
 
+  renderNoItems() {
+    if (this.props.items.length > 0) {
+      return null
+    }
+
+    return <NoItems secondary={this.props.useSecondaryStyle}>{this.props.noItemsLabel}</NoItems>
+  }
+
   renderItems() {
+    if (this.props.items.length === 0) {
+      return null
+    }
+
     let dataWidth = `${100 / this.props.items.length}%`
     return (
-      <Body>
+      <Body customStyle={this.props.bodyStyle}>
         {this.props.items.map((row, i) => {
           return (
-            <Row key={i}>
-              {row.map((data, j) => {
-                let columnStyle = ''
+            <Row key={i} secondary={this.props.useSecondaryStyle}>
+              {
+                row.constructor === Array ? row.map((data, j) => {
+                  let columnStyle = ''
 
-                if (this.props.columnsStyle) {
-                  columnStyle = this.props.columnsStyle[j] || ''
-                }
+                  if (this.props.columnsStyle) {
+                    columnStyle = this.props.columnsStyle[j] || ''
+                  }
 
-                return (
-                  <RowData customStyle={columnStyle} width={dataWidth} key={`${i}-${j}`}>
-                    {data}
-                  </RowData>
-                )
-              })}
+                  return (
+                    <RowData customStyle={columnStyle} width={dataWidth} key={`${i}-${j}`}>
+                      {data}
+                    </RowData>
+                  )
+                }) : row}
             </Row>
           )
         })}
@@ -103,9 +163,10 @@ class Table extends React.Component<Props> {
 
   render() {
     return (
-      <Wrapper className={this.props.className}>
+      <Wrapper className={this.props.className} secondary={this.props.useSecondaryStyle}>
         {this.renderHeader()}
         {this.renderItems()}
+        {this.renderNoItems()}
       </Wrapper>
     )
   }

+ 11 - 2
src/components/molecules/Table/story.jsx

@@ -27,15 +27,24 @@ let header = ['Header 1', 'Header 2', 'Header 3', 'Header 4', 'Header 5']
 
 storiesOf('Table', module)
   .add('default', () => (
-    <div style={{ width: '300px' }}>
+    <div style={{ width: '800px' }}>
       <Table
         header={header}
         items={items}
       />
     </div>
   ))
+  .add('secondary', () => (
+    <div style={{ width: '800px' }}>
+      <Table
+        header={header}
+        items={items}
+        useSecondaryStyle
+      />
+    </div>
+  ))
   .add('styled column', () => (
-    <div style={{ width: '300px' }}>
+    <div style={{ width: '800px' }}>
       <Table
         header={header}
         items={items}

+ 13 - 0
src/components/organisms/AssessmentDetailsContent/images/arrow.svg

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="20px" height="12px" viewBox="0 0 20 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 48.1 (47250) - http://www.bohemiancoding.com/sketch -->
+    <title>Arrow</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Azure-Migrate/Net-2" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(-423.000000, -58.000000)">
+        <g id="Icon/Arrow/Small" transform="translate(416.000000, 56.000000)" stroke="#A4AAB5" stroke-width="1.5">
+            <polyline id="Path-181-Copy-4" points="21 2.5 26 8 21 13.5"></polyline>
+            <path d="M7,8 L26,8" id="Line" transform="translate(16.500000, 8.000000) rotate(-180.000000) translate(-16.500000, -8.000000) "></path>
+        </g>
+    </g>
+</svg>

+ 22 - 0
src/components/organisms/AssessmentDetailsContent/images/azure-migrate.svg

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="48px" height="32px" viewBox="0 0 48 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 48.1 (47250) - http://www.bohemiancoding.com/sketch -->
+    <title>Group 6 Copy</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="AM-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(-385.000000, -257.000000)">
+        <g id="Azure-Migrate-Logo" transform="translate(385.000000, 254.000000)">
+            <g id="Group-6-Copy" transform="translate(0.000000, 3.000000)">
+                <g id="Group-5">
+                    <path d="M41.1428571,25.8728368 L41.1428571,26.0465116 L9.42857143,26.0465116 L9.42857143,19.9689922 L12.2649615,19.9689922 C11.0112733,17.95872 10.2857143,15.5768143 10.2857143,13.0232558 C10.2857143,5.83071024 16.0420532,0 23.1428571,0 C29.0801086,0 34.0774087,4.07640476 35.5557249,9.6168607 C36.718957,9.01907339 38.0349221,8.68217054 39.4285714,8.68217054 C44.1624407,8.68217054 48,12.5693107 48,17.3643411 C48,21.5647021 45.0552691,25.0683984 41.1428571,25.8728368 Z" id="Combined-Shape-Copy-5" fill="#227EBA"></path>
+                    <path d="M5.14285714,31.2393657 C2.26609384,31.0176991 0,28.5831075 0,25.6124031 C0,22.4956333 2.49441354,19.9689922 5.57142857,19.9689922 C5.77612888,19.9689922 5.97825079,19.9801743 6.17723144,20.0019682 C6.06064143,19.2849776 6,18.5488869 6,17.7984496 C6,10.3661525 11.9482169,4.34108527 19.2857143,4.34108527 C25.3772266,4.34108527 30.5112269,8.49364832 32.0791898,14.1565473 C33.3683783,13.4343516 34.8510789,13.0232558 36.4285714,13.0232558 C41.3991342,13.0232558 45.4285714,17.104753 45.4285714,22.1395349 C45.4285714,26.7321495 42.0758068,30.5315666 37.7142857,31.1634936 L37.7142857,31.255814 L5.14285714,31.255814 L5.14285714,31.2393657 Z" id="Combined-Shape-Copy-4" fill="#2E97DE"></path>
+                    <g id="Group-3" transform="translate(12.975824, 6.077519)" fill="#FFFFFF" fill-rule="nonzero">
+                        <path d="M0.712676078,25.1782946 L9.21070462,16.680266 L2.94904323,10.4186047 L7.79777545,10.4186047 L14.0907594,16.7115886 L5.62405341,25.1782946 L0.712676078,25.1782946 Z" id="Combined-Shape"></path>
+                        <path d="M16.1744701,22.5736434 L22.0678475,16.680266 L17.5426202,12.1550388 L22.3913524,12.1550388 L26.9479022,16.7115886 L21.0858474,22.5736434 L16.1744701,22.5736434 Z" id="Combined-Shape-Copy"></path>
+                        <path d="M11.031613,10.4186047 L16.9249903,4.52522728 L12.3997631,0 L17.2484953,0 L21.8050451,4.55654983 L15.9429903,10.4186047 L11.031613,10.4186047 Z" id="Combined-Shape-Copy-2"></path>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 498 - 0
src/components/organisms/AssessmentDetailsContent/index.jsx

@@ -0,0 +1,498 @@
+/*
+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 React from 'react'
+import styled, { css } from 'styled-components'
+import moment from 'moment'
+import { observer } from 'mobx-react'
+
+import DetailsNavigation from '../../molecules/DetailsNavigation'
+import Button from '../../atoms/Button'
+import StatusImage from '../../atoms/StatusImage'
+import DropdownLink from '../../molecules/DropdownLink'
+import Table from '../../molecules/Table'
+import AssessedVmListItem from '../../molecules/AssessedVmListItem'
+import DropdownFilter from '../../molecules/DropdownFilter'
+import Tooltip from '../../atoms/Tooltip'
+import Checkbox from '../../atoms/Checkbox'
+
+import Palette from '../../styleUtils/Palette'
+import StyleProps from '../../styleUtils/StyleProps'
+import type { Assessment, VmItem, VmSize } from '../../../types/Assessment'
+import type { Endpoint } from '../../../types/Endpoint'
+import type { Instance, Nic } from '../../../types/Instance'
+import type { Network, NetworkMap } from '../../../types/Network'
+
+import azureMigrateImage from './images/azure-migrate.svg'
+import arrowImage from './images/arrow.svg'
+
+const Wrapper = styled.div`
+  display: flex;
+  justify-content: center;
+`
+const Buttons = styled.div`
+  margin-top: 46px;
+  display: flex;
+  flex-direction: column;
+
+  button:first-child {
+    margin-bottom: 16px;
+  }
+`
+const DetailsBody = styled.div`
+  min-width: 800px;
+  max-width: 800px;
+  margin-bottom: 32px;
+`
+const Columns = styled.div`
+  display: flex;
+`
+const Column = styled.div`
+  width: 50%;
+`
+const Row = styled.div`
+  margin-bottom: 32px;
+`
+const Field = styled.div`
+  display: flex;
+  flex-direction: column;
+`
+const Label = styled.div`
+  font-size: 10px;
+  color: ${Palette.grayscale[3]};
+  font-weight: ${StyleProps.fontWeights.medium};
+  text-transform: uppercase;
+`
+const Value = styled.div`
+  display: ${props => props.flex ? 'flex' : 'inline-table'};
+  margin-top: 3px;
+  ${props => props.capitalize ? 'text-transform: capitalize;' : ''}
+`
+const AzureMigrateLogo = styled.div`
+  display: flex;
+  text-align: center;
+`
+const AzureMigrateLogoImage = styled.div`
+  width: 48px;
+  height: 33px;
+  background: url('${azureMigrateImage}') center no-repeat;
+`
+const AzureMigrateLogoText = styled.div`
+  font-size: 27px;
+  color: #2E97DE;
+  margin-left: 12px;
+`
+const LoadingWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin: 32px 0;
+`
+const LoadingText = styled.div`
+  font-size: 18px;
+  margin-top: 32px;
+`
+const TableStyled = styled(Table) `
+  margin-top: 62px;
+  ${props => props.addWidthPadding ? css`
+    margin-left: -24px;
+    &:after {
+      margin-left: 24px;
+    }
+  ` : ''}
+`
+const TableHeaderStyle = css`
+  margin-left: 24px;
+`
+const TableBodyStyle = css`
+  padding-left: 24px;
+`
+const NetworkItem = styled.div`
+  display: flex;
+`
+const NetworkName = styled.div`
+  ${props => StyleProps.exactWidth(props.width)}
+`
+const Arrow = styled.div`
+  height: 16px;
+  background: url('${arrowImage}') no-repeat;
+  ${props => StyleProps.exactWidth(props.width)}
+  background-position-y: center;
+`
+const VmHeaderItem = styled.div`
+  display: flex;
+  font-size: 14px;
+`
+const VmHeaderItemLabel = styled.div`
+  font-size: 10px;
+  margin-left: 8px;
+`
+
+const NavigationItems = [
+  {
+    label: 'Details',
+    value: '',
+  },
+]
+
+type Props = {
+  item: ?Assessment,
+  targetEndpoint: Endpoint,
+  detailsLoading: boolean,
+  instancesDetailsLoading: boolean,
+  instancesLoading: boolean,
+  networksLoading: boolean,
+  instancesDetailsProgress: ?number,
+  sourceEndpoints: Endpoint[],
+  sourceEndpointsLoading: boolean,
+  assessedVmsCount: number,
+  filteredAssessedVms: VmItem[],
+  selectedVms: VmItem[],
+  instancesDetails: Instance[],
+  instances: Instance[],
+  loadingVmSizes: boolean,
+  vmSizes: VmSize[],
+  onVmSizeChange: (vm: VmItem, size: { name: string }) => void,
+  onGetVmSize: (vm: VmItem) => ?VmSize,
+  networks: Network[],
+  sourceEndpoint: ?Endpoint,
+  page: string,
+  onSourceEndpointChange: (endpoint: Endpoint) => void,
+  onVmSearchValueChange: (value: string) => void,
+  vmSearchValue: string,
+  onVmSelectedChange: (vm: VmItem, selected: boolean) => void,
+  onNetworkChange: (sourceNic: Nic, targetNetwork: Network) => void,
+  onRefresh: () => void,
+  onMigrateClick: () => void,
+  selectedNetworks: NetworkMap[],
+  selectAllVmsChecked: boolean,
+  onSelectAllVmsChange: (selected: boolean) => void,
+}
+@observer
+class AssessmentDetailsContent extends React.Component<Props> {
+  static defaultProps: $Shape<Props> = {
+    page: '',
+  }
+
+  componentDidUpdate() {
+    Tooltip.rebuild()
+  }
+
+  doesVmMatchSource(vm: VmItem) {
+    if (!this.props.sourceEndpoint || !this.props.sourceEndpoint.connection_info) {
+      return false
+    }
+
+    if (this.props.instances.length > 0 &&
+      !this.props.instances.find(i => i.instance_name === `${vm.properties.datacenterContainer}/${vm.properties.displayName}`)) {
+      return false
+    }
+
+    return this.props.sourceEndpoint.connection_info.host === vm.properties.datacenterManagementServer
+  }
+
+  renderBottomControls() {
+    return (
+      <Buttons>
+        <Button
+          alert
+          hollow
+          onClick={() => { }}
+        >Migrate</Button>
+      </Buttons>
+    )
+  }
+
+  renderSourceDropdown() {
+    return (
+      <DropdownLink
+        selectedItem={this.props.sourceEndpoint ? this.props.sourceEndpoint.id : ''}
+        items={this.props.sourceEndpoints.map(endpoint => ({ label: endpoint.name, value: endpoint.id, endpoint }))}
+        onChange={item => { this.props.onSourceEndpointChange(item.endpoint) }}
+        selectItemLabel="Select Endpoint"
+        noItemsLabel={this.props.sourceEndpointsLoading ? 'Loading ....' : 'No matching endpoints'}
+      />
+    )
+  }
+
+  renderMainDetails() {
+    if (this.props.detailsLoading) {
+      return null
+    }
+
+    if (this.props.page !== '' || !this.props.item || !this.props.item.id) {
+      return null
+    }
+
+    let status = this.props.item ?
+      this.props.item.properties.status === 'Completed' ? 'Ready' : this.props.item.properties.status : ''
+
+    return (
+      <Columns>
+        <Column>
+          <Row>
+            <Field>
+              <Label>Type</Label>
+              <Value>Azure Migrate</Value>
+            </Field>
+          </Row>
+          <Row>
+            <AzureMigrateLogo>
+              <AzureMigrateLogoImage />
+              <AzureMigrateLogoText>Azure Migrate</AzureMigrateLogoText>
+            </AzureMigrateLogo>
+          </Row>
+          <Row>
+            <Field>
+              <Label>Last Update</Label>
+              <Value>
+                {moment(this.props.item.properties.updatedTimestamp).format('YYYY-MM-DD HH:mm:ss')}
+              </Value>
+            </Field>
+          </Row>
+          <Row>
+            <Field>
+              <Label>Status</Label>
+              <Value>{status}</Value>
+            </Field>
+          </Row>
+          <Row>
+            <Field>
+              <Label>Source Endpoint</Label>
+              <Value>{this.renderSourceDropdown()}</Value>
+            </Field>
+          </Row>
+        </Column>
+        <Column>
+          <Row>
+            <Field>
+              <Label>Project</Label>
+              <Value>{this.props.item ? this.props.item.projectName : ''}</Value>
+            </Field>
+          </Row>
+          <Row>
+            <Field>
+              <Label>Location</Label>
+              <Value>{this.props.item ? this.props.item.properties.azureLocation : ''}</Value>
+            </Field>
+          </Row>
+          <Row>
+            <Field>
+              <Label>Resource Group</Label>
+              <Value>{this.props.item ? this.props.item.resourceGroupName : ''}</Value>
+            </Field>
+          </Row>
+          <Row>
+            <Field>
+              <Label>VM Group</Label>
+              <Value>{this.props.item ? this.props.item.groupName : ''}</Value>
+            </Field>
+          </Row>
+          <Row>
+            <Field>
+              <Label>Target endpoint</Label>
+              <Value>{this.props.targetEndpoint.name}</Value>
+            </Field>
+          </Row>
+        </Column>
+      </Columns>
+    )
+  }
+
+  renderVmsTable() {
+    if (this.props.detailsLoading || this.props.sourceEndpointsLoading || this.props.instancesLoading) {
+      return null
+    }
+
+    let columnsWidths = ['244px', '164px', '220px', '156px']
+    let items = this.props.filteredAssessedVms.map(vm => {
+      let filteredVm = this.props.filteredAssessedVms.find(v => v.id === vm.id)
+      return (
+        <AssessedVmListItem
+          item={vm}
+          columnsWidths={columnsWidths}
+          selected={this.props.selectedVms.filter(m => m.id === vm.id).length > 0}
+          onSelectedChange={(vm, selected) => { this.props.onVmSelectedChange(vm, selected) }}
+          disabled={!this.doesVmMatchSource(vm)}
+          loadingVmSizes={this.props.loadingVmSizes}
+          recommendedVmSize={filteredVm ? filteredVm.properties.recommendedSize : ''}
+          vmSizes={this.props.vmSizes}
+          selectedVmSize={this.props.onGetVmSize(vm)}
+          onVmSizeChange={size => { this.props.onVmSizeChange(vm, size) }}
+        />
+      )
+    })
+
+    let vmCountLabel = `(${this.props.filteredAssessedVms.length === this.props.assessedVmsCount ? this.props.assessedVmsCount :
+      `${this.props.filteredAssessedVms.length} OUT OF ${this.props.assessedVmsCount}`})`
+    let vmHeaderItem = (
+      <VmHeaderItem>
+        <Checkbox checked={this.props.selectAllVmsChecked} onChange={checked => { this.props.onSelectAllVmsChange(checked) }} />
+        <VmHeaderItemLabel>Virtual Machine {vmCountLabel}</VmHeaderItemLabel>
+        <DropdownFilter
+          searchPlaceholder="Filter Virtual Machines"
+          searchValue={this.props.vmSearchValue}
+          onSearchChange={value => { this.props.onVmSearchValueChange(value) }}
+        />
+      </VmHeaderItem>
+    )
+
+    return (
+      <TableStyled
+        addWidthPadding
+        items={items}
+        bodyStyle={TableBodyStyle}
+        headerStyle={TableHeaderStyle}
+        columnsWidths={columnsWidths}
+        header={[vmHeaderItem, 'OS', 'Target Disk Type', 'Azure VM Size']}
+        useSecondaryStyle
+        noItemsLabel="No VMs found!"
+      />
+    )
+  }
+
+  renderNetworkTable() {
+    if (this.props.detailsLoading || this.props.sourceEndpointsLoading || this.props.instancesDetailsLoading || this.props.networksLoading || this.props.instancesLoading) {
+      return null
+    }
+
+    let nics = []
+    this.props.instancesDetails.forEach(instance => {
+      if (!instance.devices || !instance.devices.nics) {
+        return
+      }
+      instance.devices.nics.forEach(nic => {
+        if (nics.find(n => n.network_name === nic.network_name)) {
+          return
+        }
+        nics.push(nic)
+      })
+    })
+
+    if (nics.length === 0) {
+      return null
+    }
+
+    let columnsWidths = ['357px', '275px', '152px']
+    let items = nics.map(nic => {
+      let selectedNetworkName = this.props.selectedNetworks && this.props.selectedNetworks.find(n => n.sourceNic.network_name === nic.network_name)
+      if (selectedNetworkName) {
+        selectedNetworkName = selectedNetworkName.targetNetwork.name
+      }
+
+      return (
+        // $FlowIgnore
+        <NetworkItem key={nic.network_name}>
+          <NetworkName width={columnsWidths[0]}>{nic.network_name}</NetworkName>
+          <Arrow width={columnsWidths[1]} />
+          <DropdownLink
+            width={columnsWidths[2]}
+            noItemsLabel="No Networks found"
+            selectItemLabel="Select Network"
+            selectedItem={selectedNetworkName}
+            onChange={item => { this.props.onNetworkChange(nic, item.network) }}
+            items={this.props.networks.map(network => ({ value: network.name || '', label: network.name || '', network }))}
+          />
+        </NetworkItem>
+      )
+    })
+    return (
+      <TableStyled
+        items={items}
+        columnsWidths={columnsWidths}
+        header={['Source Network', '', 'Target Network']}
+        useSecondaryStyle
+      />
+    )
+  }
+
+  renderButtons() {
+    if (this.props.detailsLoading) {
+      return null
+    }
+
+    return (
+      <Buttons>
+        <Button secondary onClick={this.props.onRefresh}>Refresh</Button>
+        <Button
+          disabled={this.props.selectedVms.length === 0 || this.props.selectedNetworks.length === 0}
+          onClick={() => { this.props.onMigrateClick() }}
+        >Migrate / Replicate</Button>
+      </Buttons>
+    )
+  }
+
+  renderLoading() {
+    let message = ''
+    let loadingProgress = -1
+    if (!this.props.detailsLoading && !this.props.sourceEndpointsLoading && !this.props.instancesDetailsLoading && !this.props.networksLoading && !this.props.instancesLoading) {
+      return null
+    }
+
+    if (this.props.instancesDetailsLoading) {
+      if (this.props.instancesDetailsProgress !== undefined && this.props.instancesDetailsProgress !== null) {
+        loadingProgress = Math.round(this.props.instancesDetailsProgress * 100)
+      }
+      message = 'Loading instances details, please wait ...'
+    }
+
+    if (this.props.instancesLoading) {
+      message = 'Loading instances ...'
+    }
+
+    if (this.props.networksLoading) {
+      message = 'Loading networks ...'
+    }
+
+    if (this.props.sourceEndpointsLoading) {
+      message = 'Loading source endpoints ...'
+    }
+
+    if (this.props.detailsLoading) {
+      message = 'Loading assessment ...'
+    }
+
+    return (
+      <LoadingWrapper>
+        <StatusImage loading loadingProgress={loadingProgress} />
+        <LoadingText>{message}</LoadingText>
+      </LoadingWrapper>
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <DetailsNavigation
+          items={NavigationItems}
+          selectedValue={this.props.page}
+          itemId={this.props.item ? this.props.item.id : ''}
+          customHref={() => null}
+        />
+        <DetailsBody>
+          {this.renderMainDetails()}
+          {this.renderVmsTable()}
+          {this.renderNetworkTable()}
+          {this.renderLoading()}
+          {this.renderButtons()}
+          <Tooltip />
+        </DetailsBody>
+      </Wrapper>
+    )
+  }
+}
+
+export default AssessmentDetailsContent

+ 17 - 0
src/components/organisms/AssessmentMigrationOptions/images/assessment.svg

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="96px" height="68px" viewBox="0 0 96 68" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 48.1 (47250) - http://www.bohemiancoding.com/sketch -->
+    <title>User-VL Copy</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="NAM-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(-240.000000, -110.000000)">
+        <g id="Modal/Icon-96/2-Buttons" stroke="#0044CA" stroke-width="1.5">
+            <g id="Azure-Migrate/Icon96" transform="translate(240.000000, 96.000000)">
+                <polyline id="Rectangle-Copy" stroke-linecap="round" stroke-linejoin="round" transform="translate(44.000000, 54.800000) rotate(-405.000000) translate(-44.000000, -54.800000) " points="54.3708995 44.2216826 54.3708995 65.3783174 33.6291005 65.3783174"></polyline>
+                <polyline id="Rectangle-Copy" stroke-linecap="round" stroke-linejoin="round" transform="translate(62.666667, 54.800000) rotate(-405.000000) translate(-62.666667, -54.800000) " points="73.0375661 44.2216826 73.0375661 65.3783174 52.2957672 65.3783174"></polyline>
+                <polyline id="Rectangle-Copy" stroke-linecap="round" stroke-linejoin="round" transform="translate(25.333333, 54.800000) rotate(-405.000000) translate(-25.333333, -54.800000) " points="35.7042328 44.2216826 35.7042328 65.3783174 14.9624339 65.3783174"></polyline>
+                <path d="M38.4,14.75 C26.4942961,14.75 16.8057103,24.8628507 16.2898777,37.726303 L16.2682887,38.2646725 L15.751187,38.4160451 C6.95162499,40.9919634 0.75,49.5048643 0.75,59.3340202 C0.75,71.3871691 9.94011624,81.25 21.12,81.25 L78.72,81.25 C87.7878838,81.25 95.25,73.2416583 95.25,63.4551074 C95.25,55.9253123 90.6318176,49.2645554 84.0462573,46.9019388 L83.5670876,46.7300333 L83.5499453,46.2212492 C83.1534893,34.4544158 74.2216824,25.0527181 63.36,25.0527181 C61.7595305,25.0527181 60.3552686,25.3544351 57.8800133,26.1157834 L57.323809,26.2868628 L57.0199148,25.7905971 C52.8472502,18.9765458 45.9267364,14.75 38.4,14.75 Z" id="Path-Copy" transform="translate(48.000000, 48.000000) scale(-1, 1) translate(-48.000000, -48.000000) "></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 162 - 0
src/components/organisms/AssessmentMigrationOptions/index.jsx

@@ -0,0 +1,162 @@
+/*
+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 React from 'react'
+import styled from 'styled-components'
+
+import Button from '../../atoms/Button'
+import WizardOptionsField from '../../molecules/WizardOptionsField'
+
+import LabelDictionary from '../../../utils/LabelDictionary'
+import type { Field } from '../../../types/Field'
+
+import assessmentImage from './images/assessment.svg'
+
+const Wrapper = styled.div`
+  padding: 48px 32px 32px 32px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`
+const Image = styled.div`
+  width: 96px;
+  height: 96px;
+  background: url('${assessmentImage}') center no-repeat;
+`
+const Fields = styled.div`
+  margin-top: 64px;
+`
+const WizardOptionsFieldStyled = styled(WizardOptionsField) `
+  width: 319px;
+  justify-content: space-between;
+  margin-bottom: 32px;
+  &:last-child {
+    margin-bottom: 0;
+  }
+`
+const Buttons = styled.div`
+  display: flex;
+  justify-content: space-between;
+  width: 100%;
+  margin-top: 48px;
+`
+
+const generalFields = [
+  {
+    name: 'use_replica',
+    type: 'boolean',
+  },
+  {
+    name: 'separate_vm',
+    type: 'boolean',
+    value: true,
+  },
+]
+const replicaFields = [
+  {
+    name: 'shutdown_instances',
+    type: 'boolean',
+  },
+]
+const migrationFields = [
+  {
+    name: 'skip_os_morphing',
+    type: 'boolean',
+  },
+]
+
+type Props = {
+  onCancelClick: () => void,
+  onExecuteClick: (fields: Field[]) => void,
+  executeButtonDisabled: boolean,
+}
+type State = {
+  generalFields: Field[],
+  migrationFields: Field[],
+  replicaFields: Field[],
+}
+class AssessmentMigrationOptions extends React.Component<Props, State> {
+  constructor() {
+    super()
+
+    this.state = {
+      generalFields: [...generalFields],
+      migrationFields: [...migrationFields],
+      replicaFields: [...replicaFields],
+    }
+  }
+
+  handleValueChange(field: Field, value: any) {
+    let mapFields = fields => {
+      let mappedFields = fields.map(f => {
+        if (f.name === field.name) {
+          return { ...f, value }
+        }
+        return { ...f }
+      })
+      return mappedFields
+    }
+    this.setState({
+      generalFields: mapFields(this.state.generalFields),
+      migrationFields: mapFields(this.state.migrationFields),
+      replicaFields: mapFields(this.state.replicaFields),
+    })
+  }
+
+  renderField(field: Field) {
+    return (
+      <WizardOptionsFieldStyled
+        key={field.name}
+        name={field.name}
+        type="strict-boolean"
+        value={field.value}
+        label={LabelDictionary.get(field.name)}
+        onChange={value => this.handleValueChange(field, value)}
+      />
+    )
+  }
+
+  render() {
+    let fields = this.state.generalFields
+    let useReplicaField = fields.find(f => f.name === 'use_replica')
+
+    if (useReplicaField && useReplicaField.value) {
+      fields = [...fields, ...this.state.replicaFields]
+    } else {
+      fields = [...fields, ...this.state.migrationFields]
+    }
+
+    return (
+      <Wrapper>
+        <Image />
+        <Fields>
+          {fields.map(field => {
+            return this.renderField(field)
+          })}
+        </Fields>
+        <Buttons>
+          <Button secondary onClick={() => { this.props.onCancelClick() }}>Cancel</Button>
+          <Button
+            onClick={() => { this.props.onExecuteClick(fields) }}
+            disabled={this.props.executeButtonDisabled}
+          >Execute</Button>
+        </Buttons>
+      </Wrapper>
+    )
+  }
+}
+
+export default AssessmentMigrationOptions

+ 18 - 4
src/components/organisms/DetailsContentHeader/index.jsx

@@ -75,9 +75,10 @@ const MockButton = styled.div`
 type Props = {
   onBackButonClick: () => void,
   onActionButtonClick?: () => void,
-  onCancelClick: (?Execution | ?MainItem) => void,
-  typeImage: string,
+  onCancelClick?: (?Execution | ?MainItem) => void,
+  typeImage?: string,
   buttonLabel?: string,
+  statusLabel?: string,
   item: ?MainItem,
   alertInfoPill?: boolean,
   primaryInfoPill?: boolean,
@@ -110,6 +111,10 @@ class DetailsContentHeader extends React.Component<Props> {
     if (!this.getStatus()) {
       return null
     }
+    let statusLabel = this.getStatus()
+    if (this.props.statusLabel) {
+      statusLabel = this.props.statusLabel
+    }
     return (
       <StatusPills>
         <StatusPill
@@ -118,7 +123,13 @@ class DetailsContentHeader extends React.Component<Props> {
           alert={this.props.alertInfoPill}
           primary={this.props.primaryInfoPill}
         />
-        <StatusPill status={this.getStatus()} />
+        <StatusPill
+          status={this.getStatus()}
+          label={
+            // $FlowIssue
+            statusLabel
+          }
+        />
       </StatusPills>
     )
   }
@@ -132,7 +143,10 @@ class DetailsContentHeader extends React.Component<Props> {
       return (
         <Button
           secondary
-          onClick={() => { this.props.onCancelClick(this.getLastExecution()) }}
+          onClick={() => {
+            // $FlowIssue
+            if (this.props.onCancelClick) this.props.onCancelClick(this.getLastExecution())
+          }}
         >Cancel</Button>
       )
     }

+ 62 - 0
src/components/organisms/DropdownFilterGroup/index.jsx

@@ -0,0 +1,62 @@
+/*
+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 * as React from 'react'
+import styled from 'styled-components'
+
+import DropdownLink from '../../molecules/DropdownLink'
+
+import Palette from '../../styleUtils/Palette'
+
+const Wrapper = styled.div``
+const Dropdowns = styled.div``
+const DropdownLinkStyled = styled(DropdownLink)`
+  margin-right: 32px;
+  position: relative;
+
+  &:after {
+    position: absolute;
+    content: '';
+    width: 1px;
+    height: 18px;
+    background: ${Palette.grayscale[4]};
+    right: -16px;
+    top: -1px;
+  }
+`
+
+type Props = {
+  items: React.ElementProps<typeof DropdownLink>[]
+}
+class DropdownFilterGroup extends React.Component<Props> {
+  renderDropdowns() {
+    return (
+      <Dropdowns>
+        {this.props.items.map(config => <DropdownLinkStyled {...config} />)}
+      </Dropdowns>
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        {this.renderDropdowns()}
+      </Wrapper>
+    )
+  }
+}
+
+export default DropdownFilterGroup

+ 9 - 6
src/components/organisms/FilterList/index.jsx

@@ -27,20 +27,21 @@ const Wrapper = styled.div``
 type DictItem = { value: string, label: string }
 type Props = {
   items: MainItem[],
-  actions: DictItem[],
+  actions?: DictItem[],
   loading: boolean,
   onReloadButtonClick: () => void,
   onItemClick: (item: MainItem) => void,
-  onActionChange: (selectedItems: MainItem[], actionValue: string) => void,
+  onActionChange?: (selectedItems: MainItem[], actionValue: string) => void,
   selectionLabel: string,
   renderItemComponent: (componentProps: ItemComponentProps) => React.Node,
   itemFilterFunction: (item: MainItem, filterStatus?: ?string, filterState?: string) => boolean,
   filterItems: DictItem[],
-  emptyListImage: string,
+  emptyListImage: ?string,
   emptyListMessage: string,
   emptyListExtraMessage: string,
-  emptyListButtonLabel: string,
-  onEmptyListButtonClick: () => void,
+  emptyListButtonLabel?: string,
+  onEmptyListButtonClick?: () => void,
+  customFilterComponent?: React.Node,
 }
 type State = {
   items: MainItem[],
@@ -125,7 +126,7 @@ class FilterList extends React.Component<Props, State> {
   }
 
   handleActionChange(actionValue: string) {
-    this.props.onActionChange(this.state.selectedItems, actionValue)
+    if (this.props.onActionChange) this.props.onActionChange(this.state.selectedItems, actionValue)
   }
 
   filterItems(items: MainItem[], filterStatus?: ?string, filterText?: string): MainItem[] {
@@ -146,8 +147,10 @@ class FilterList extends React.Component<Props, State> {
           selectedValue={this.state.filterStatus}
           onReloadButtonClick={this.props.onReloadButtonClick}
           onSearchChange={text => { this.handleSearchChange(text) }}
+          searchValue={this.state.filterText}
           onSelectAllChange={selected => { this.handleSelectAllChange(selected) }}
           selectAllSelected={this.state.selectAllSelected}
+          customFilterComponent={this.props.customFilterComponent}
           selectionInfo={{
             selected: this.state.selectedItems.length,
             total: this.state.items.length,

+ 19 - 5
src/components/organisms/MainList/index.jsx

@@ -81,11 +81,11 @@ type Props = {
   onItemClick: (item: MainItem) => void,
   renderItemComponent: (componentProps: ItemComponentProps) => React.Node,
   showEmptyList: boolean,
-  emptyListImage: string,
+  emptyListImage: ?string,
   emptyListMessage: string,
   emptyListExtraMessage: string,
-  emptyListButtonLabel: string,
-  onEmptyListButtonClick: () => void,
+  emptyListButtonLabel?: string,
+  onEmptyListButtonClick?: () => void,
 }
 class MainList extends React.Component<Props> {
   renderList() {
@@ -125,12 +125,26 @@ class MainList extends React.Component<Props> {
   }
 
   renderEmptyList() {
+    let renderImage = () => {
+      if (this.props.emptyListImage) {
+        return <EmptyListImage source={this.props.emptyListImage} />
+      }
+      return null
+    }
+
+    let renderButton = () => {
+      if (this.props.emptyListButtonLabel) {
+        return <Button onClick={this.props.onEmptyListButtonClick}>{this.props.emptyListButtonLabel}</Button>
+      }
+      return null
+    }
+
     return (
       <EmptyList>
-        <EmptyListImage source={this.props.emptyListImage} />
+        {renderImage()}
         <EmptyListMessage>{this.props.emptyListMessage}</EmptyListMessage>
         <EmptyListExtraMessage>{this.props.emptyListExtraMessage}</EmptyListExtraMessage>
-        <Button onClick={this.props.onEmptyListButtonClick}>{this.props.emptyListButtonLabel}</Button>
+        {renderButton()}
       </EmptyList>
     )
   }

+ 4 - 16
src/components/organisms/Navigation/index.jsx

@@ -19,6 +19,7 @@ import styled from 'styled-components'
 
 import Logo from '../../atoms/Logo'
 
+import { navigationMenu } from '../../../config'
 import backgroundImage from './images/star-bg.jpg'
 
 const Wrapper = styled.div`
@@ -46,24 +47,11 @@ const MenuItem = styled.a`
 `
 const Footer = styled.div``
 
-const MenuItems = [
-  {
-    label: 'Replicas',
-    value: 'replicas',
-  }, {
-    label: 'Migrations',
-    value: 'migrations',
-  }, {
-    label: 'Cloud Endpoints',
-    value: 'endpoints',
-  },
-]
-
-class Navigation extends React.Component<{ currentPage: string }> {
+class Navigation extends React.Component<{currentPage: string}> {
   renderMenu() {
     return (
       <Menu>
-        {MenuItems.map(item => {
+        {navigationMenu.filter(i => !i.disabled).map(item => {
           return (
             <MenuItem
               key={item.value}
@@ -79,7 +67,7 @@ class Navigation extends React.Component<{ currentPage: string }> {
   render() {
     return (
       <Wrapper>
-        <LogoStyled small href="/#/replicas" />
+        <LogoStyled small href={navigationMenu[0].value} />
         {this.renderMenu()}
         <Footer />
       </Wrapper>

+ 2 - 1
src/components/organisms/WizardInstances/index.jsx

@@ -211,8 +211,8 @@ class WizardInstances extends React.Component<Props, State> {
 
   handleSeachInputChange(searchText: string) {
     clearTimeout(this.timeout)
+    this.setState({ searchText })
     this.timeout = setTimeout(() => {
-      this.setState({ searchText })
       this.props.onSearchInputChange(searchText)
     }, 500)
   }
@@ -318,6 +318,7 @@ class WizardInstances extends React.Component<Props, State> {
           alwaysOpen
           onChange={searchText => { this.handleSeachInputChange(searchText) }}
           loading={this.props.searching}
+          value={this.state.searchText}
           placeholder="Search VMs"
         />
         <FilterInfo>

+ 20 - 0
src/components/pages/AssessmentDetailsPage/images/assessment.svg

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="64px" height="64px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 48.1 (47250) - http://www.bohemiancoding.com/sketch -->
+    <title>Group 3</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="AM-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(-224.000000, -80.000000)">
+        <g id="Group-3" transform="translate(224.000000, 80.000000)">
+            <path d="M32,64 C49.673112,64 64,49.673112 64,32 C64,14.326888 49.673112,0 32,0 C14.326888,0 0,14.326888 0,32 C0,49.673112 14.326888,64 32,64 Z" id="Pat-Benetar" fill="#FFFFFF"></path>
+            <g id="Group-2" transform="translate(10.000000, 15.000000)" stroke="#0044CA" stroke-linecap="round" stroke-width="1.5">
+                <g id="Group">
+                    <polyline id="Rectangle-Copy" stroke-linejoin="round" transform="translate(20.625000, 19.200000) rotate(-405.000000) translate(-20.625000, -19.200000) " points="25.4863591 14.2219683 25.4863591 24.1780317 15.7636409 24.1780317"></polyline>
+                    <polyline id="Rectangle-Copy" stroke-linejoin="round" transform="translate(29.375000, 19.200000) rotate(-405.000000) translate(-29.375000, -19.200000) " points="34.2363591 14.2219683 34.2363591 24.1780317 24.5136409 24.1780317"></polyline>
+                    <polyline id="Rectangle-Copy" stroke-linejoin="round" transform="translate(11.875000, 19.200000) rotate(-405.000000) translate(-11.875000, -19.200000) " points="16.7363591 14.2219683 16.7363591 24.1780317 7.01364088 24.1780317"></polyline>
+                    <path d="M18,0 C12.1869,0 7.5321,4.95500136 7.2846,11.1511772 C3.0744,12.3884731 0,16.48144 0,21.3336566 C0,27.1952971 4.4595,32 9.9,32 L36.9,32 C41.3505,32 45,28.0679979 45,23.2729917 C45,19.4912881 42.66,16.2836278 39.5154,15.151056 C39.3246,9.46589497 35.0208,4.84833793 29.7,4.84833793 C28.7649,4.84833793 27.8946,5.09657283 27.0279,5.36420108 C25.0938,2.19338808 21.7953,0 18,0 Z" id="Path-Copy" transform="translate(22.500000, 16.000000) scale(-1, 1) translate(-22.500000, -16.000000) "></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 413 - 0
src/components/pages/AssessmentDetailsPage/index.jsx

@@ -0,0 +1,413 @@
+/*
+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 React from 'react'
+import styled from 'styled-components'
+import { observer } from 'mobx-react'
+
+import DetailsTemplate from '../../templates/DetailsTemplate'
+import { DetailsPageHeader } from '../../organisms/DetailsPageHeader'
+import DetailsContentHeader from '../../organisms/DetailsContentHeader'
+import AssessmentDetailsContent from '../../organisms/AssessmentDetailsContent'
+import Modal from '../../molecules/Modal'
+import AssessmentMigrationOptions from '../../organisms/AssessmentMigrationOptions'
+import type { Endpoint } from '../../../types/Endpoint'
+import type { Nic } from '../../../types/Instance'
+import type { VmItem, VmSize } from '../../../types/Assessment'
+import type { Field } from '../../../types/Field'
+import type { Network, NetworkMap } from '../../../types/Network'
+
+import AzureStore from '../../../stores/AzureStore'
+import EndpointStore from '../../../stores/EndpointStore'
+import NotificationStore from '../../../stores/NotificationStore'
+import ReplicaStore from '../../../stores/ReplicaStore'
+import InstanceStore from '../../../stores/InstanceStore'
+import NetworkStore from '../../../stores/NetworkStore'
+import UserStore from '../../../stores/UserStore'
+import AssessmentStore from '../../../stores/AssessmentStore'
+
+import assessmentImage from './images/assessment.svg'
+
+const Wrapper = styled.div``
+
+type Props = {
+  match: any,
+}
+type State = {
+  sourceEndpoint: ?Endpoint,
+  selectedVms: VmItem[],
+  selectedNetworks: NetworkMap[],
+  showMigrationOptions: boolean,
+  executeButtonDisabled: boolean,
+  vmSizes: { [string]: VmSize },
+  vmSearchValue: string,
+}
+@observer
+class AssessmentDetailsPage extends React.Component<Props, State> {
+  constructor() {
+    super()
+
+    this.state = {
+      sourceEndpoint: null,
+      selectedVms: [],
+      selectedNetworks: [],
+      showMigrationOptions: false,
+      executeButtonDisabled: false,
+      vmSizes: {},
+      vmSearchValue: '',
+    }
+  }
+
+  componentWillMount() {
+    document.title = 'Assessment Details'
+
+    this.azureAuthenticate()
+  }
+
+  componentWillUnmount() {
+    AzureStore.clearAssessmentDetails()
+    AzureStore.clearAssessedVms()
+    InstanceStore.clearInstancesDetails()
+  }
+
+  getUrlInfo() {
+    let urlInfo = JSON.parse(atob(decodeURIComponent(this.props.match.params.info)))
+    return urlInfo
+  }
+
+  getEndpoints() {
+    let vms = AzureStore.assessedVms
+    let connectionsInfo = EndpointStore.connectionsInfo
+
+    if (vms.length === 0 || connectionsInfo.length === 0) {
+      return []
+    }
+    let endpoints = connectionsInfo.filter(
+      // $FlowIgnore
+      endpoint => vms.find(vm => vm.properties.datacenterManagementServer.toLowerCase() === endpoint.connection_info.host.toLowerCase())
+    )
+    return endpoints
+  }
+
+  getInstancesDetailsProgress() {
+    let count = InstanceStore.instancesDetailsCount
+    if (count < 5) {
+      return null
+    }
+    let remaining = InstanceStore.instancesDetailsRemaining
+    return (count - remaining) / count
+  }
+
+  getFilteredAssessedVms(vms?: VmItem[]) {
+    if (!vms) {
+      vms = AzureStore.assessedVms
+    }
+    return vms.filter(vm =>
+      `${vm.properties.datacenterContainer}/${vm.properties.displayName}`.toLowerCase().indexOf(this.state.vmSearchValue.toLowerCase()) > -1
+    )
+  }
+
+  getSourceEndpointId() {
+    return this.state.sourceEndpoint ? this.state.sourceEndpoint.id : ''
+  }
+
+  getEnabledVms() {
+    let sourceConnInfo = EndpointStore.connectionsInfo.find(e => e.id === this.getSourceEndpointId())
+    if (!sourceConnInfo) {
+      return []
+    }
+
+    let sourceHost = sourceConnInfo.connection_info.host
+    if (!sourceHost) {
+      return []
+    }
+    return AzureStore.assessedVms.filter(vm => {
+      if (vm.properties.datacenterManagementServer.toLowerCase() === sourceHost.toLowerCase() &&
+        InstanceStore.instances.find(i => i.instance_name === `${vm.properties.datacenterContainer}/${vm.properties.displayName}`)) {
+        return true
+      }
+      return false
+    })
+  }
+
+  getSelectAllVmsChecked() {
+    if (this.getFilteredAssessedVms().length === 0 || this.getEnabledVms().length === 0) {
+      return false
+    }
+
+    return this.state.selectedVms.length === this.getFilteredAssessedVms(this.getEnabledVms()).length
+  }
+
+  handleVmSelectedChange(vm: VmItem, selected: boolean) {
+    if (selected) {
+      this.setState({ selectedVms: [...this.state.selectedVms, vm] }, () => { this.loadInstancesDetails() })
+    } else {
+      this.setState({ selectedVms: this.state.selectedVms.filter(m => m.id !== vm.id) }, () => { this.loadInstancesDetails() })
+    }
+  }
+
+  handleSelectAllVmsChange(selected: boolean) {
+    let selectedVms = selected ? [...this.getFilteredAssessedVms(this.getEnabledVms())] : []
+    this.setState({ selectedVms }, () => { this.loadInstancesDetails() })
+  }
+
+  handleSourceEndpointChange(sourceEndpoint: ?Endpoint) {
+    this.setState({ sourceEndpoint, selectedVms: [], selectedNetworks: [] })
+    InstanceStore.loadInstances(this.getSourceEndpointId(), true, true).then(() => {
+      this.initSelectedVms()
+      this.loadInstancesDetails()
+    })
+  }
+
+  handleUserItemClick(item: { value: string }) {
+    switch (item.value) {
+      case 'signout':
+        UserStore.logout()
+        return
+      case 'profile':
+        window.location.href = '/#/profile'
+        break
+      default:
+    }
+  }
+
+  handleBackButtonClick() {
+    window.location.href = '/#/planning'
+  }
+
+  handleNetworkChange(sourceNic: Nic, targetNetwork: Network) {
+    let selectedNetworks = this.state.selectedNetworks
+
+    selectedNetworks = selectedNetworks.filter(n => n.sourceNic.network_name !== sourceNic.network_name)
+    selectedNetworks.push({ sourceNic, targetNetwork })
+    this.setState({ selectedNetworks })
+  }
+
+  handleRefresh() {
+    let urlInfo = this.getUrlInfo()
+    AzureStore.getAssessmentDetails({ ...urlInfo })
+    AzureStore.getAssessedVms({ ...urlInfo })
+    this.loadInstancesDetails()
+  }
+
+  handleMigrateClick() {
+    this.setState({ showMigrationOptions: true })
+  }
+
+  handleCloseMigrationOptions() {
+    this.setState({ showMigrationOptions: false })
+  }
+
+  handleMigrationExecute(options: Field[]) {
+    let selectedInstances = InstanceStore.instancesDetails
+      .filter(i => this.state.selectedVms.find(m => i.instance_name === `${m.properties.datacenterContainer}/${m.properties.displayName}`))
+    let vmSizes = {}
+    selectedInstances.forEach(i => {
+      let vm = this.state.selectedVms.find(m => i.instance_name === `${m.properties.datacenterContainer}/${m.properties.displayName}`)
+      vmSizes[i.instance_name] = vm ? this.state.vmSizes[vm.id].name : ''
+    })
+
+    this.setState({ executeButtonDisabled: true })
+
+    AssessmentStore.migrate({
+      source: this.state.sourceEndpoint,
+      target: this.getUrlInfo().endpoint,
+      networks: [...this.state.selectedNetworks],
+      options: [...options],
+      destinationEnv: {
+        resource_group: this.getUrlInfo().resourceGroupName,
+        location: AzureStore.assessmentDetails ? AzureStore.assessmentDetails.properties.azureLocation : '',
+      },
+      vmSizes,
+      selectedInstances,
+    }).then(() => {
+      this.setState({ showMigrationOptions: false })
+      let useReplicaOption = options.find(o => o.name === 'use_replica')
+      let type = useReplicaOption && useReplicaOption.value ? 'Replica' : 'Migration'
+      NotificationStore.notify(`${type} was succesfully created`, 'success', { persist: true, persistInfo: { title: `${type} created` } })
+
+      if (type === 'Replica') {
+        AssessmentStore.migrations.forEach(replica => {
+          ReplicaStore.execute(replica.id, options)
+        })
+      }
+
+      window.location.href = `/#/${type.toLowerCase()}s`
+    })
+  }
+
+  handleVmSizeChange(vm: VmItem, vmSize: VmSize) {
+    let vmSizes = this.state.vmSizes
+    vmSizes[vm.id] = vmSize
+
+    this.setState({ vmSizes })
+  }
+
+  handleGetVmSize(vm: VmItem): VmSize {
+    return this.state.vmSizes[vm.id]
+  }
+
+  handleVmSearchValueChange(vmSearchValue: string) {
+    this.setState({ vmSearchValue })
+  }
+
+  azureAuthenticate() {
+    let connectionInfo = this.getUrlInfo().connectionInfo
+    AzureStore.authenticate(connectionInfo.user_credentials.username, connectionInfo.user_credentials.password).then(() => {
+      this.loadAssessmentDetails()
+    })
+  }
+
+  loadSourceEndpoints() {
+    EndpointStore.getEndpoints({ showLoading: true }).then(() => {
+      EndpointStore.getConnectionsInfo(EndpointStore.endpoints.filter(e => e.type === 'vmware_vsphere')).then(() => {
+        let endpoints = this.getEndpoints()
+        let sourceEndpoint = endpoints.find(e => e.id === this.getSourceEndpointId())
+        if (sourceEndpoint) {
+          this.handleSourceEndpointChange(sourceEndpoint)
+        } else if (endpoints.length > 0) {
+          this.handleSourceEndpointChange(endpoints[0])
+        } else {
+          this.handleSourceEndpointChange(null)
+        }
+      })
+    })
+  }
+
+  loadAssessmentDetails() {
+    let urlInfo = this.getUrlInfo()
+    AzureStore.getAssessmentDetails({ ...urlInfo }).then(() => {
+      AzureStore.getVmSizes({ ...urlInfo, location: AzureStore.assessmentDetails ? AzureStore.assessmentDetails.properties.azureLocation : '' })
+      this.loadNetworks()
+    })
+    AzureStore.getAssessedVms({ ...urlInfo }).then(() => {
+      this.initVmSizes()
+      this.loadSourceEndpoints()
+    })
+  }
+
+  initSelectedVms() {
+    this.setState({ selectedVms: this.getEnabledVms() })
+  }
+
+  initVmSizes() {
+    let vmSizes = {}
+    let vms = AzureStore.assessedVms
+
+    vms.forEach(vm => {
+      vmSizes[vm.id] = { name: vm.properties.recommendedSize }
+    })
+
+    this.setState({ vmSizes })
+  }
+
+  loadNetworks() {
+    this.setState({ selectedNetworks: [] })
+    let details = AzureStore.assessmentDetails
+    NetworkStore.loadNetworks(this.getUrlInfo().endpoint.id, {
+      location: details ? details.properties.azureLocation : '',
+      resource_group: this.getUrlInfo().resourceGroupName,
+    })
+  }
+
+  loadInstancesDetails() {
+    let instances = InstanceStore.instances.filter(i => this.state.selectedVms.find(m => i.instance_name === `${m.properties.datacenterContainer}/${m.properties.displayName}`))
+    InstanceStore.clearInstancesDetails()
+    InstanceStore.loadInstancesDetails(this.getSourceEndpointId(), instances)
+  }
+
+  render() {
+    let details = AzureStore.assessmentDetails
+    let loading = AzureStore.loadingAssessmentDetails || AzureStore.authenticating || AzureStore.loadingAssessedVms
+    let endpointsLoading = EndpointStore.connectionsInfoLoading || EndpointStore.loading
+    let status = details ? details.properties.status.toUpperCase() : ''
+    let statusLabel = status === 'COMPLETED' ? 'READY' : status
+
+    return (
+      <Wrapper>
+        <DetailsTemplate
+          pageHeaderComponent={<DetailsPageHeader
+            user={UserStore.user}
+            onUserItemClick={item => { this.handleUserItemClick(item) }}
+          />}
+          contentHeaderComponent={<DetailsContentHeader
+            item={
+              // $FlowIgnore
+              {
+                ...details,
+                type: 'Azure Migrate',
+                status,
+              }
+            }
+            statusLabel={statusLabel}
+            onBackButonClick={() => { this.handleBackButtonClick() }}
+            typeImage={assessmentImage}
+          />}
+          contentComponent={(
+            <AssessmentDetailsContent
+              item={details}
+              detailsLoading={loading}
+              targetEndpoint={this.getUrlInfo().endpoint}
+              sourceEndpoints={this.getEndpoints()}
+              sourceEndpointsLoading={endpointsLoading}
+              sourceEndpoint={this.state.sourceEndpoint}
+              assessedVmsCount={AzureStore.assessedVms.length}
+              filteredAssessedVms={this.getFilteredAssessedVms()}
+              onSourceEndpointChange={endpoint => this.handleSourceEndpointChange(endpoint)}
+              selectedVms={this.state.selectedVms}
+              onVmSelectedChange={(vm, selected) => { this.handleVmSelectedChange(vm, selected) }}
+              selectAllVmsChecked={this.getSelectAllVmsChecked()}
+              onSelectAllVmsChange={checked => { this.handleSelectAllVmsChange(checked) }}
+              instances={InstanceStore.instances}
+              instancesDetails={InstanceStore.instancesDetails}
+              instancesDetailsLoading={InstanceStore.loadingInstancesDetails}
+              instancesLoading={InstanceStore.instancesLoading}
+              networksLoading={NetworkStore.loading}
+              instancesDetailsProgress={this.getInstancesDetailsProgress()}
+              networks={NetworkStore.networks}
+              selectedNetworks={this.state.selectedNetworks}
+              loadingVmSizes={AzureStore.loadingVmSizes}
+              vmSizes={AzureStore.vmSizes}
+              onVmSizeChange={(vm, size) => {
+                // $FlowIgnore
+                this.handleVmSizeChange(vm, size)
+              }}
+              vmSearchValue={this.state.vmSearchValue}
+              onVmSearchValueChange={value => { this.handleVmSearchValueChange(value) }}
+              onGetVmSize={vm => this.handleGetVmSize(vm)}
+              onNetworkChange={(sourceNic, targetNetwork) => { this.handleNetworkChange(sourceNic, targetNetwork) }}
+              onRefresh={() => this.handleRefresh()}
+              onMigrateClick={() => { this.handleMigrateClick() }}
+            />
+          )}
+        />
+        <Modal
+          isOpen={this.state.showMigrationOptions}
+          title="Options"
+          onRequestClose={() => { this.handleCloseMigrationOptions() }}
+        >
+          <AssessmentMigrationOptions
+            onCancelClick={() => { this.handleCloseMigrationOptions() }}
+            onExecuteClick={options => { this.handleMigrationExecute(options) }}
+            executeButtonDisabled={this.state.executeButtonDisabled}
+          />
+        </Modal>
+      </Wrapper>
+    )
+  }
+}
+
+export default AssessmentDetailsPage

+ 292 - 0
src/components/pages/AssessmentsPage/index.jsx

@@ -0,0 +1,292 @@
+/*
+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 React from 'react'
+import styled from 'styled-components'
+import { observer } from 'mobx-react'
+
+import FilterList from '../../organisms/FilterList'
+import MainTemplate from '../../templates/MainTemplate'
+import PageHeader from '../../organisms/PageHeader'
+import Navigation from '../../organisms/Navigation'
+import DropdownFilterGroup from '../../organisms/DropdownFilterGroup'
+import AssessmentListItem from '../../molecules/AssessmentListItem'
+import type { Assessment } from '../../../types/Assessment'
+import type { Endpoint } from '../../../types/Endpoint'
+import type { Project } from '../../../types/Project'
+
+import AzureStore from '../../../stores/AzureStore'
+import AssessmentStore from '../../../stores/AssessmentStore'
+import EndpointStore from '../../../stores/EndpointStore'
+import ProjectStore from '../../../stores/ProjectStore'
+import UserStore from '../../../stores/UserStore'
+import Wait from '../../../utils/Wait'
+
+import { requestPollTimeout } from '../../../config'
+
+const Wrapper = styled.div``
+
+type Props = {}
+@observer
+class AssessmentsPage extends React.Component<Props> {
+  disablePolling: boolean
+  pollTimeout: TimeoutID
+
+  componentWillMount() {
+    ProjectStore.getProjects()
+
+    if (!AzureStore.isLoadedForCurrentProject()) {
+      AssessmentStore.clearSelection()
+      AzureStore.clearAssessments()
+    }
+
+    this.disablePolling = false
+    this.pollData()
+
+    EndpointStore.getEndpoints({ showLoading: true }).then(() => {
+      let endpoints = EndpointStore.endpoints.filter(e => e.type === 'azure')
+      if (endpoints.length > 0) {
+        this.chooseEndpoint(AssessmentStore.selectedEndpoint && AssessmentStore.selectedEndpoint.id ? AssessmentStore.selectedEndpoint : endpoints[0])
+      }
+    })
+  }
+
+  componentWillUnmount() {
+    this.disablePolling = true
+  }
+
+  getEndpointsDropdownConfig() {
+    let endpoints = EndpointStore.endpoints.filter(e => e.type === 'azure')
+    return {
+      key: 'endpoint',
+      selectedItem: AssessmentStore.selectedEndpoint ? AssessmentStore.selectedEndpoint.id : '',
+      items: endpoints.map(endpoint => ({ label: endpoint.name, value: endpoint.id, endpoint })),
+      onChange: (item: { endpoint: Endpoint }) => { this.chooseEndpoint(item.endpoint, true) },
+      selectItemLabel: 'Select Endpoint',
+      noItemsLabel: EndpointStore.loading ? 'Loading ...' : 'No Endpoints',
+      limitListOffset: true,
+    }
+  }
+
+  getResourceGroupsDropdownConfig() {
+    let groups = AzureStore.resourceGroups
+    return {
+      key: 'resource-group',
+      selectedItem: AssessmentStore.selectedResourceGroup ? AssessmentStore.selectedResourceGroup.id : '',
+      items: groups.map(group => ({ label: group.name, value: group.id, group })),
+      onChange: (item: Assessment) => { this.chooseResourceGroup(item.group) },
+      selectItemLabel: 'Loading ...',
+      noItemsLabel: this.areResourceGroupsLoading() ? 'Loading ...' : 'No Resource Groups',
+    }
+  }
+
+  getFilterItems() {
+    let types = [{ label: 'All projects', value: 'all' }]
+    let assessments = AzureStore.assessments
+    let uniqueProjects = []
+
+    assessments.forEach(a => {
+      if (uniqueProjects.findIndex(p => p.name === a.project.name) === -1) {
+        uniqueProjects.push(a.project)
+      }
+    })
+
+    let projects = uniqueProjects.map(p => ({ label: p.name, value: p.name }))
+    return types.concat(projects)
+  }
+
+  handleReloadButtonClick() {
+    if (!EndpointStore.connectionInfo) {
+      return
+    }
+
+    AzureStore.getAssessments(
+      // $FlowIgnore
+      EndpointStore.connectionInfo.subscription_id,
+      AssessmentStore.selectedResourceGroup ? AssessmentStore.selectedResourceGroup.name : '',
+      UserStore.user ? UserStore.user.project.id : ''
+    )
+  }
+
+  handleItemClick(assessment: Assessment) {
+    if (assessment.properties.status.toUpperCase() !== 'COMPLETED') {
+      return
+    }
+
+    let connectionInfo = EndpointStore.connectionInfo
+    let endpoint = AssessmentStore.selectedEndpoint
+    let resourceGroupName = AssessmentStore.selectedResourceGroup ? AssessmentStore.selectedResourceGroup.name : ''
+    let projectName = assessment.project.name
+    let groupName = assessment.group.name
+    let assessmentName = assessment.name
+
+    let info = { endpoint, connectionInfo, resourceGroupName, projectName, groupName, assessmentName }
+
+    window.location.href = `/#/assessment/${encodeURIComponent(btoa(JSON.stringify({ ...info })))}`
+  }
+
+  handleProjectChange(project: Project) {
+    Wait.for(() => UserStore.user ? UserStore.user.project.id === project.id : false, () => {
+      AssessmentStore.clearSelection()
+      AzureStore.clearAssessments()
+      EndpointStore.getEndpoints({ showLoading: true }).then(() => {
+        let endpoints = EndpointStore.endpoints.filter(e => e.type === 'azure')
+        if (endpoints.length > 0) {
+          this.chooseEndpoint(AssessmentStore.selectedEndpoint && AssessmentStore.selectedEndpoint.id ? AssessmentStore.selectedEndpoint : endpoints[0])
+        }
+      })
+    })
+
+    UserStore.switchProject(project.id)
+  }
+
+  areResourceGroupsLoading() {
+    return AzureStore.authenticating || AzureStore.loadingResourceGroups
+  }
+
+  pollData() {
+    if (this.disablePolling) {
+      clearTimeout(this.pollTimeout)
+      return
+    }
+
+    let connectionInfo = EndpointStore.connectionInfo
+    let selectedResourceGroup = AssessmentStore.selectedResourceGroup
+
+    if (!connectionInfo || !connectionInfo.subscription_id || !selectedResourceGroup || !selectedResourceGroup.name) {
+      this.pollTimeout = setTimeout(() => { this.pollData() }, requestPollTimeout)
+      return
+    }
+
+    AzureStore.getAssessments(
+      // $FlowIgnore
+      connectionInfo.subscription_id,
+      selectedResourceGroup.name,
+      UserStore.user ? UserStore.user.project.id : '',
+      { backgroundLoading: true }
+    ).then(() => {
+      this.pollTimeout = setTimeout(() => { this.pollData() }, requestPollTimeout)
+    })
+  }
+
+  chooseResourceGroup(selectedResourceGroup: $PropertyType<Assessment, 'group'>) {
+    if (!EndpointStore.connectionInfo) {
+      return
+    }
+
+    AssessmentStore.updateSelectedResourceGroup(selectedResourceGroup)
+    AzureStore.getAssessments(
+      // $FlowIssue
+      EndpointStore.connectionInfo.subscription_id,
+      selectedResourceGroup.name,
+      UserStore.user ? UserStore.user.project.id : '',
+    )
+  }
+
+  chooseEndpoint(selectedEndpoint: Endpoint, clearResourceGroup?: boolean) {
+    if (AssessmentStore.selectedEndpoint && AssessmentStore.selectedEndpoint.id === selectedEndpoint.id) {
+      return
+    }
+
+    AssessmentStore.updateSelectedEndpoint(selectedEndpoint)
+
+    if (clearResourceGroup) {
+      AssessmentStore.updateSelectedResourceGroup(null)
+    }
+
+    EndpointStore.getConnectionInfo(selectedEndpoint).then(() => {
+      let connectionInfo = EndpointStore.connectionInfo
+      if (!connectionInfo) {
+        return
+      }
+      // $FlowIgnore
+      AzureStore.authenticate(connectionInfo.user_credentials.username, connectionInfo.user_credentials.password).then(() => {
+        // $FlowIgnore
+        AzureStore.getResourceGroups(connectionInfo.subscription_id).then(() => {
+          let groups = AzureStore.resourceGroups
+          let selectedGroup = AssessmentStore.selectedResourceGroup
+          // $FlowIssue
+          if (groups.filter(rg => rg.id === selectedGroup ? selectedGroup.id : '').length > 0) {
+            return
+          }
+          if (groups.length > 0) {
+            let defaultResourceGroup = groups.find(g => g.name === connectionInfo.default_resource_group) || groups[0]
+            this.chooseResourceGroup(defaultResourceGroup)
+          }
+        })
+      })
+    })
+  }
+
+  itemFilterFunction(item: any, filterItem: ?string, filterText?: string) {
+    let assessment: Assessment = item
+    if ((filterItem !== 'all' && (assessment.project.name !== filterItem)) ||
+      (item.name.toLowerCase().indexOf(filterText) === -1 && assessment.id.toLowerCase().indexOf(filterText || '') === -1)) {
+      return false
+    }
+
+    return true
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <MainTemplate
+          listNoMargin
+          navigationComponent={<Navigation currentPage="planning" />}
+          listComponent={
+            <FilterList
+              filterItems={this.getFilterItems()}
+              selectionLabel="assessments"
+              loading={this.areResourceGroupsLoading() || AzureStore.loadingAssessments}
+              items={
+                // $FlowIgnore
+                AzureStore.assessments
+              }
+              onItemClick={item => {
+                let itemAny: any = item
+                let assessment: Assessment = itemAny
+                this.handleItemClick(assessment)
+              }}
+              onReloadButtonClick={() => { this.handleReloadButtonClick() }}
+              itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
+              renderItemComponent={options => {
+                let optionsAny: any = options
+                return <AssessmentListItem {...optionsAny} />
+              }}
+              customFilterComponent={(
+                <DropdownFilterGroup
+                  items={[this.getEndpointsDropdownConfig(), this.getResourceGroupsDropdownConfig()]}
+                />
+              )}
+              emptyListImage={null}
+              emptyListMessage="You don’t have any Assessments."
+              emptyListExtraMessage="Try selecting a new Endpoint or a new Resource Group."
+            />
+          }
+          headerComponent={
+            <PageHeader
+              title="Planning"
+              onProjectChange={project => { this.handleProjectChange(project) }}
+            />
+          }
+        />
+      </Wrapper>
+    )
+  }
+}
+
+export default AssessmentsPage

+ 4 - 2
src/components/templates/MainTemplate/index.jsx

@@ -27,7 +27,7 @@ const Navigation = styled.div`
   width: 320px;
 `
 const Content = styled.div`
-  padding: 0 64px 0 32px;
+  padding: 0 64px 0 64px;
   position: absolute;
   left: 320px;
   right: 0;
@@ -37,6 +37,7 @@ const Content = styled.div`
 `
 const List = styled.div`
   padding-bottom: 32px;
+  margin-left: ${props => props.noMargin ? 0 : '-32px'};
 `
 const Header = styled.div``
 
@@ -44,6 +45,7 @@ type Props = {
   navigationComponent: React.Node,
   headerComponent: React.Node,
   listComponent: React.Node,
+  listNoMargin?: boolean,
 }
 const MainTemplate = (props: Props) => {
   return (
@@ -51,7 +53,7 @@ const MainTemplate = (props: Props) => {
       <Navigation>{props.navigationComponent}</Navigation>
       <Content>
         <Header>{props.headerComponent}</Header>
-        <List>{props.listComponent}</List>
+        <List noMargin={props.listNoMargin}>{props.listComponent}</List>
       </Content>
     </Wrapper>
   )

+ 7 - 0
src/config.js

@@ -27,6 +27,13 @@ export const servicesUrl = {
 
 export const useSecret = true // use secret_ref when creating and endpoint
 
+export const navigationMenu = [
+  { label: 'Replicas', value: 'replicas' },
+  { label: 'Migrations', value: 'migrations' },
+  { label: 'Cloud Endpoints', value: 'endpoints' },
+  { label: 'Planning', value: 'planning', disabled: false },
+]
+
 export const requestPollTimeout = 5000
 
 export const providerTypes = {

+ 104 - 0
src/sources/AssessmentSource.js

@@ -0,0 +1,104 @@
+/*
+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 cookie from 'js-cookie'
+
+import type { MigrationInfo } from '../types/Assessment'
+import type { MainItem } from '../types/MainItem'
+import Api from '../utils/ApiCaller'
+import { servicesUrl } from '../config'
+import NotificationStore from '../stores/NotificationStore'
+
+class AssessmentSourceUtils {
+  static getDestinationEnv(data: MigrationInfo) {
+    let env = { ...data.destinationEnv }
+    env.network_map = {}
+    if (data.networks && data.networks.length) {
+      data.networks.forEach(mapping => {
+        env.network_map[mapping.sourceNic.network_name] = mapping.targetNetwork.name
+      })
+    }
+    env.vm_size = data.vmSizes[Object.keys(data.vmSizes).filter(k => k === data.selectedInstances[0].instance_name)[0]]
+    return env
+  }
+}
+
+class AssessmentSource {
+  static migrate(data: MigrationInfo): Promise<MainItem> {
+    return new Promise((resolve, reject) => {
+      let projectId = cookie.get('projectId')
+      let useReplicaField = data.options.find(o => o.name === 'use_replica')
+      let type = useReplicaField && useReplicaField.value ? 'replica' : 'migration'
+      let payload = {}
+      payload[type] = {
+        origin_endpoint_id: data.source ? data.source.id : 'null',
+        destination_endpoint_id: data.target.id,
+        destination_environment: AssessmentSourceUtils.getDestinationEnv(data),
+        instances: data.selectedInstances.map(i => i.instance_name),
+        notes: '',
+        security_groups: ['testgroup'],
+      }
+
+      data.options.forEach(option => {
+        if (option.name === 'use_replica') {
+          return
+        }
+        if (option.value !== null && option.value !== undefined) {
+          payload[type][option.name] = option.value
+        }
+      })
+
+      Api.sendAjaxRequest({
+        url: `${servicesUrl.coriolis}/${projectId || 'null'}/${type}s`,
+        method: 'POST',
+        data: payload,
+      }).then(response => {
+        resolve(response.data[type])
+      }, reject).catch(reject)
+    })
+  }
+
+  static migrateMultiple(data: MigrationInfo): Promise<MainItem[]> {
+    return new Promise((resolve, reject) => {
+      let items = []
+      let count = 0
+
+      data.selectedInstances.forEach(instance => {
+        let newData = { ...data }
+        newData.selectedInstances = [instance]
+        this.migrate(newData).then(item => {
+          count += 1
+          items.push(item)
+          if (count === data.selectedInstances.length) {
+            if (items.length > 0) {
+              resolve(items)
+            } else {
+              reject()
+            }
+          }
+        }, () => {
+          count += 1
+          NotificationStore.notify(`Error while migrating instance ${instance.name}`, 'error', {
+            persist: true,
+            persistInfo: { title: 'Migration creation error' },
+          })
+        })
+      })
+    })
+  }
+}
+
+export default AssessmentSource

+ 169 - 0
src/sources/AzureSource.js

@@ -0,0 +1,169 @@
+/*
+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 moment from 'moment'
+
+import AzureApiCaller from '../utils/AzureApiCaller'
+import type { Assessment, VmItem, VmSize } from '../types/Assessment'
+
+// $FlowIgnore
+const resourceGroupsUrl = ({ subscriptionId }) => `/subscriptions/${subscriptionId}/resourceGroups`
+const projectsUrl = ({ resourceGroupName, ...other }) => `${resourceGroupsUrl({ ...other })}/${resourceGroupName}/providers/Microsoft.Migrate/projects`
+const groupsUrl = ({ projectName, ...other }) => `${projectsUrl({ ...other })}/${projectName}/groups`
+const assessmentsUrl = ({ groupName, ...other }) => `${groupsUrl({ ...other })}/${groupName}/assessments`
+const assessmentDetailsUrl = ({ assessmentName, ...other }) => `${assessmentsUrl({ ...other })}/${assessmentName}`
+const assessedVmsUrl = ({ ...other }) => `${assessmentDetailsUrl({ ...other })}/assessedMachines`
+
+class AzureSourceUtil {
+  static sortAssessments(assessments) {
+    assessments.sort((a, b) => {
+      return moment(b.properties.updatedTimestamp).toDate().getTime() - moment(a.properties.updatedTimestamp)
+    })
+    return assessments
+  }
+
+  static checkQueues(queues, requestIds, callback) {
+    if (requestIds[0] !== requestIds[1]) {
+      return
+    }
+
+    let doneQeues = queues.filter(q => q === 0).length
+    if (doneQeues === queues.length) {
+      callback()
+    }
+  }
+}
+
+class AzureSource {
+  static authenticate(username: string, password: string): Promise<any> {
+    return new Promise((resolve, reject) => {
+      AzureApiCaller.send({
+        url: '/azure-login',
+        method: 'POST',
+        data: { username, password },
+      }).then(response => {
+        let entries = Object.keys(response.tokenCache)[0]
+        let accessToken = response.tokenCache[entries][0].accessToken
+        AzureApiCaller.setHeader('Authorization', `Bearer ${accessToken}`)
+        resolve(response)
+      }, reject)
+    })
+  }
+
+  static getResourceGroups(subscriptionId: string): Promise<$PropertyType<Assessment, 'group'>[]> {
+    return new Promise((resolve, reject) => {
+      AzureApiCaller.send({
+        url: resourceGroupsUrl({ subscriptionId }),
+      }, '2017-08-01').then(response => {
+        resolve(response.value)
+      }, reject)
+    })
+  }
+
+  static reqId: string
+
+  static getAssessments(subscriptionId: string, resourceGroupName: string): Promise<Assessment[]> {
+    this.reqId = subscriptionId + resourceGroupName
+
+    return new Promise((resolve, reject) => {
+      let assessments = []
+      let projectsQueue
+      let groupsQueue = 0
+
+      // Load projects
+      AzureApiCaller.send({
+        url: projectsUrl({ resourceGroupName, subscriptionId }),
+      }).then(response => {
+        let projects = response.value
+        projectsQueue = projects.length
+
+        if (projectsQueue === 0 && subscriptionId + resourceGroupName === this.reqId) {
+          resolve([])
+        }
+
+        projects.forEach(project => {
+          if (project.type !== 'Microsoft.Migrate/projects') {
+            return
+          }
+          // Load Groups
+          AzureApiCaller.send({
+            url: groupsUrl({ projectName: project.name, subscriptionId, resourceGroupName }),
+          }).then(response => {
+            projectsQueue -= 1
+
+            let groups = response.value
+            groupsQueue = groups.length
+
+            if (groupsQueue === 0 && subscriptionId + resourceGroupName === this.reqId) {
+              resolve([])
+            }
+
+            groups.forEach(group => {
+              // Load Assessments
+              AzureApiCaller.send({
+                url: assessmentsUrl({ subscriptionId, resourceGroupName, projectName: project.name, groupName: group.name }),
+              }).then(response => {
+                groupsQueue -= 1
+
+                assessments = assessments.concat(response.value.map(a => ({ ...a, project, group })))
+                AzureSourceUtil.checkQueues([groupsQueue, projectsQueue], [subscriptionId + resourceGroupName, this.reqId], () => { resolve(AzureSourceUtil.sortAssessments(assessments)) })
+              }, () => { groupsQueue -= 1; AzureSourceUtil.checkQueues([groupsQueue, projectsQueue], [subscriptionId + resourceGroupName, this.reqId], () => { resolve(AzureSourceUtil.sortAssessments(assessments)) }) })
+            })
+          }, () => { projectsQueue -= 1; AzureSourceUtil.checkQueues([groupsQueue, projectsQueue], [subscriptionId + resourceGroupName, this.reqId], () => { resolve(AzureSourceUtil.sortAssessments(assessments)) }) })
+        })
+      }, reject)
+    })
+  }
+
+  static getAssessmentDetails(info: Assessment): Promise<Assessment> {
+    return new Promise((resolve, reject) => {
+      AzureApiCaller.send({
+        url: assessmentDetailsUrl({ ...info, subscriptionId: info.connectionInfo.subscription_id }),
+      }).then(response => {
+        let assessment = { ...response, ...info }
+        resolve(assessment)
+      }, reject)
+    })
+  }
+
+  static getAssessedVms(info: Assessment): Promise<VmItem[]> {
+    return new Promise((resolve, reject) => {
+      AzureApiCaller.send({
+        url: assessedVmsUrl({ ...info, subscriptionId: info.connectionInfo.subscription_id }),
+      }).then(response => {
+        let vms = response.value
+        vms.sort((a, b) => {
+          let getLabel = item => `${item.properties.datacenterContainer}/${item.properties.displayName}`
+          return getLabel(a).localeCompare(getLabel(b))
+        })
+        resolve(vms)
+      }, reject)
+    })
+  }
+
+  static getVmSizes(info: Assessment): Promise<VmSize[]> {
+    return new Promise((resolve, reject) => {
+      AzureApiCaller.send({
+        // $FlowIgnore
+        url: `/subscriptions/${info.connectionInfo.subscription_id}/providers/Microsoft.Compute/locations/${info.location}/vmSizes`,
+      }, '2017-12-01').then(response => {
+        resolve(response.value)
+      }, reject)
+    })
+  }
+}
+
+export default AzureSource

+ 32 - 0
src/sources/EndpointSource.js

@@ -101,6 +101,38 @@ class EdnpointSource {
     })
   }
 
+  static getConnectionsInfo(endpoints: Endpoint[]): Promise<Endpoint[]> {
+    return new Promise(resolve => {
+      if (!endpoints || endpoints.length === 0) {
+        resolve([])
+        return
+      }
+
+      let count = 0
+      let connectionsInfo = []
+      let isDone = () => {
+        count += 1
+        if (count === endpoints.length) {
+          resolve(connectionsInfo)
+        }
+      }
+
+      endpoints.forEach(endpoint => {
+        let index = endpoint.connection_info.secret_ref ? endpoint.connection_info.secret_ref.lastIndexOf('/') : ''
+        let uuid = endpoint.connection_info.secret_ref && index ? endpoint.connection_info.secret_ref.substr(index + 1) : ''
+        Api.sendAjaxRequest({
+          url: `${servicesUrl.barbican}/v1/secrets/${uuid}/payload`,
+          method: 'GET',
+          json: false,
+          headers: { Accept: 'text/plain' },
+        }).then(response => {
+          connectionsInfo.push({ ...endpoint, connection_info: JSON.parse(response.data) })
+          isDone()
+        }, isDone).catch(isDone)
+      })
+    })
+  }
+
   static validate(endpoint: Endpoint): Promise<Validation> {
     return new Promise((resolve, reject) => {
       let projectId = cookie.get('projectId')

+ 31 - 9
src/sources/InstanceSource.js

@@ -22,38 +22,60 @@ import type { Instance } from '../types/Instance'
 import { servicesUrl, wizardConfig } from '../config'
 
 class InstanceSource {
-  static loadInstances(endpointId: string, searchText?: string, lastInstanceId?: string): Promise<Instance[]> {
+  static endpointId: string
+
+  static loadInstances(endpointId: string, searchText: ?string, lastInstanceId: ?string, skipLimit?: boolean): Promise<Instance[]> {
+    this.endpointId = endpointId
+
     return new Promise((resolve, reject) => {
-      let projectId = cookie.get('projectId') || 'undefined'
-      let url = `${servicesUrl.coriolis}/${projectId}/endpoints/${endpointId}/instances?limit=${wizardConfig.instancesItemsPerPage + 1}`
+      let projectId = cookie.get('projectId')
+      let url = `${servicesUrl.coriolis}/${projectId || 'null'}/endpoints/${endpointId}/instances`
+      let symbol = '?'
+
+      if (!skipLimit) {
+        url = `${url + symbol}limit=${wizardConfig.instancesItemsPerPage + 1}`
+        symbol = '&'
+      }
 
       if (searchText) {
-        url = `${url}&name=${searchText}`
+        url = `${url + symbol}name=${searchText}`
+        symbol = '&'
       }
 
       if (lastInstanceId) {
-        url = `${url}&marker=${lastInstanceId}`
+        url = `${url + symbol}&marker=${lastInstanceId}`
       }
 
       Api.sendAjaxRequest({
         url,
         method: 'GET',
       }).then(response => {
-        resolve(response.data.instances)
+        if (this.endpointId === endpointId) {
+          resolve(response.data.instances)
+        }
       }, reject).catch(reject)
     })
   }
 
-  static loadInstanceDetails(endpointId: string, instanceName: string): Promise<Instance> {
+  static loadInstanceDetails(endpointId: string, instanceName: string, reqId: number): Promise<{ instance: Instance, reqId: number }> {
     return new Promise((resolve, reject) => {
       let projectId = cookie.get('projectId') || 'undefined'
 
       Api.sendAjaxRequest({
         url: `${servicesUrl.coriolis}/${projectId}/endpoints/${endpointId}/instances/${btoa(instanceName)}`,
         method: 'GET',
+        requestId: `instanceDetail-${reqId}}`,
       }).then(response => {
-        resolve(response.data.instance)
-      }, reject).catch(reject)
+        resolve({ instance: response.data.instance, reqId })
+      }, response => { reject({ response, reqId }) }).catch(reject)
+    })
+  }
+
+  static cancelInstancesDetailsRequests(reqId: number) {
+    Api.requests.forEach(request => {
+      if (request.requestId.indexOf(`instanceDetail-${reqId}`) > -1) {
+        Api.cancelRequest(request)
+      }
     })
   }
 }

+ 71 - 0
src/stores/AssessmentStore.js

@@ -0,0 +1,71 @@
+/*
+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 { observable, action } from 'mobx'
+
+import AssessmentSource from '../sources/AssessmentSource'
+import type { Endpoint } from '../types/Endpoint'
+import type { Assessment, MigrationInfo } from '../types/Assessment'
+import type { MainItem } from '../types/MainItem'
+
+class AssessmentStore {
+  @observable selectedEndpoint: ?Endpoint = null
+  @observable selectedResourceGroup: ?$PropertyType<Assessment, 'group'> = null
+  @observable migrating: boolean = false
+  @observable migrations: MainItem[] = []
+
+  @action updateSelectedEndpoint(endpoint: Endpoint) {
+    this.selectedEndpoint = endpoint
+  }
+
+  @action updateSelectedResourceGroup(resourceGroup: ?$PropertyType<Assessment, 'group'>) {
+    this.selectedResourceGroup = resourceGroup
+  }
+
+  @action migrate(data: MigrationInfo): Promise<void> {
+    if (!data.options) {
+      return Promise.resolve()
+    }
+
+    this.migrating = true
+    this.migrations = []
+    let seperateVmField = data.options.find(o => o.name === 'separate_vm')
+    let separateVm = seperateVmField ? seperateVmField.value : ''
+
+    if (separateVm) {
+      return AssessmentSource.migrateMultiple(data).then((items: MainItem[]) => {
+        this.migrating = false
+        this.migrations = items
+      }).catch(() => {
+        this.migrating = false
+      })
+    }
+
+    return AssessmentSource.migrate(data).then((item: MainItem) => {
+      this.migrating = false
+      this.migrations = [item]
+    }).catch(() => {
+      this.migrating = false
+    })
+  }
+
+  @action clearSelection() {
+    this.selectedEndpoint = null
+    this.selectedResourceGroup = null
+  }
+}
+
+export default new AssessmentStore()

+ 136 - 0
src/stores/AzureStore.js

@@ -0,0 +1,136 @@
+/*
+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 { observable, action } from 'mobx'
+import cookie from 'js-cookie'
+
+import AzureSource from '../sources/AzureSource'
+import type { Assessment, VmItem, VmSize } from '../types/Assessment'
+
+class AzureStore {
+  @observable authenticating: boolean = false
+  @observable loadingResourceGroups: boolean = false
+  @observable resourceGroups: $PropertyType<Assessment, 'group'>[] = []
+  @observable loadingAssessments: boolean = false
+  @observable loadingAssessmentDetails: boolean = false
+  @observable assessmentDetails: ?Assessment = null
+  @observable assessments: Assessment[] = []
+  @observable loadingAssessedVms: boolean = false
+  @observable assessedVms: VmItem[] = []
+  @observable loadingVmSizes: boolean = false
+  @observable vmSizes: VmSize[] = []
+  @observable assessmentsProjectId: string = ''
+
+  @action authenticate(username: string, password: string): Promise<void> {
+    this.authenticating = true
+    return AzureSource.authenticate(username, password).then(() => {
+      this.authenticating = false
+    }).catch(() => {
+      this.authenticating = false
+    })
+  }
+
+  @action getResourceGroups(subscriptionId: string): Promise<void> {
+    this.loadingResourceGroups = true
+
+    return AzureSource.getResourceGroups(subscriptionId).then((groups: $PropertyType<Assessment, 'group'>[]) => {
+      this.loadingResourceGroups = false
+      this.resourceGroups = groups
+    }).catch(() => {
+      this.loadingResourceGroups = false
+    })
+  }
+
+  @action isLoadedForCurrentProject() {
+    return this.assessmentsProjectId === (cookie.get('projectId') || 'null')
+  }
+
+  @action getAssessments(
+    subscriptionId: string,
+    resourceGroupName: string,
+    projectId: string,
+    options?: { backgroundLoading: boolean },
+  ): Promise<void> {
+    let cookieProjectId = cookie.get('projectId') || 'null'
+    if (projectId !== cookieProjectId) {
+      return Promise.resolve()
+    }
+
+    if (!options || !options.backgroundLoading) {
+      this.loadingAssessments = true
+    }
+    return AzureSource.getAssessments(subscriptionId, resourceGroupName).then((assessments: Assessment[]) => {
+      this.loadingAssessments = false
+
+      cookieProjectId = cookie.get('projectId') || 'null'
+      if (projectId !== cookieProjectId) {
+        return
+      }
+      this.assessmentsProjectId = cookieProjectId
+      this.assessments = assessments
+    }).catch(() => {
+      this.loadingAssessments = false
+    })
+  }
+
+  @action getAssessmentDetails(info: Assessment): Promise<void> {
+    this.loadingAssessmentDetails = true
+    return AzureSource.getAssessmentDetails(info).then((assessment: Assessment) => {
+      this.loadingAssessmentDetails = false
+      this.assessmentDetails = assessment
+    }).catch(() => {
+      this.loadingAssessmentDetails = false
+    })
+  }
+
+  @action clearAssessmentDetails() {
+    this.assessmentDetails = null
+    this.assessedVms = []
+  }
+
+  @action getAssessedVms(info: Assessment): Promise<void> {
+    this.loadingAssessedVms = true
+
+    return AzureSource.getAssessedVms(info).then((vms: VmItem[]) => {
+      this.loadingAssessedVms = false
+      this.assessedVms = vms
+    }).catch(() => {
+      this.loadingAssessedVms = false
+    })
+  }
+
+  @action getVmSizes(info: Assessment): Promise<void> {
+    this.loadingVmSizes = true
+
+    return AzureSource.getVmSizes(info).then((sizes: VmSize[]) => {
+      this.loadingVmSizes = false
+      this.vmSizes = sizes
+    }).catch(() => {
+      this.loadingVmSizes = false
+    })
+  }
+
+  @action clearAssessedVms() {
+    this.assessedVms = []
+  }
+
+  @action clearAssessments() {
+    this.resourceGroups = []
+    this.assessments = []
+  }
+}
+
+export default new AzureStore()

+ 12 - 0
src/stores/EndpointStore.js

@@ -29,11 +29,13 @@ class EndpointStore {
   @observable loading = false
   @observable loading = false
   @observable connectionInfo: ?$PropertyType<Endpoint, 'connection_info'> = null
+  @observable connectionsInfo: Endpoint[] = []
   @observable validation: ?Validation = null
   @observable validating = false
   @observable updating = false
   @observable adding = false
   @observable connectionInfoLoading = false
+  @observable connectionsInfoLoading = false
 
   @action getEndpoints(options?: { showLoading: boolean }) {
     if ((options && options.showLoading) || this.endpoints.length === 0) {
@@ -64,6 +66,16 @@ class EndpointStore {
     })
   }
 
+  @action getConnectionsInfo(endpointsData: Endpoint[]): Promise<void> {
+    this.connectionsInfoLoading = true
+    return EndpointSource.getConnectionsInfo(endpointsData).then(endpoints => {
+      this.connectionsInfoLoading = false
+      this.connectionsInfo = endpoints
+    }).catch(() => {
+      this.connectionsInfoLoading = false
+    })
+  }
+
   @action setConnectionInfo(connectionInfo: $PropertyType<Endpoint, 'connection_info'>) {
     this.connectionInfo = connectionInfo
     this.connectionInfoLoading = false

+ 48 - 17
src/stores/InstanceStore.js

@@ -57,18 +57,26 @@ class InstanceStore {
   @observable reloading = false
   @observable instancesDetails: Instance[] = []
   @observable loadingInstancesDetails = true
+  @observable instancesDetailsCount: number = 0
+  @observable instancesDetailsRemaining: number = 0
 
   lastEndpointId: string
+  reqId: number
+
+  @action loadInstances(endpointId: string, skipLimit?: boolean, useCache?: boolean): Promise<void> {
+    if (this.cachedInstances.length > 0 && this.lastEndpointId === endpointId && useCache) {
+      return Promise.resolve()
+    }
 
-  @action loadInstances(endpointId: string) {
     this.instancesLoading = true
     this.searchNotFound = false
     this.lastEndpointId = endpointId
 
-    return InstanceSource.loadInstances(endpointId).then(instances => {
+    return InstanceSource.loadInstances(endpointId, null, null, skipLimit).then(instances => {
       if (endpointId !== this.lastEndpointId) {
         return
       }
+
       this.currentPage = 1
       this.hasNextPage = InstanceStoreUtils.hasNextPage(instances)
       this.instances = instances
@@ -151,38 +159,54 @@ class InstanceStore {
     })
   }
 
-  @action loadInstancesDetails(endpointId: string, instances: Instance[]): Promise<void> {
-    instances.sort((a, b) => a.instance_name.localeCompare(b.instance_name))
+  @action loadInstancesDetails(endpointId: string, instancesInfo: Instance[]): Promise<void> {
+    // Use reqId to be able to uniquely identify the request so all but the latest request can be igonred and canceled
+    this.reqId = !this.reqId ? 1 : this.reqId + 1
+    InstanceSource.cancelInstancesDetailsRequests(this.reqId - 1)
+
+    instancesInfo.sort((a, b) => a.instance_name.localeCompare(b.instance_name))
     let hash = i => `${i.instance_name}-${i.id}`
-    if (this.instancesDetails.map(hash).join('_') === instances.map(hash).join('_')) {
+    if (this.instancesDetails.map(hash).join('_') === instancesInfo.map(hash).join('_')) {
       return Promise.resolve()
     }
 
+    let count = instancesInfo.length
+    this.loadingInstancesDetails = true
+    this.instancesDetails = []
     this.loadingInstancesDetails = true
+    this.instancesDetailsCount = count
+    this.instancesDetailsRemaining = count
     this.instancesDetails = []
-    let count = instances.length
+
     return new Promise((resolve) => {
-      instances.forEach(instance => {
-        InstanceSource.loadInstanceDetails(endpointId, instance.instance_name).then(instance => {
-          count -= 1
-          this.loadingInstancesDetails = count > 0
+      instancesInfo.forEach(instanceInfo => {
+        InstanceSource.loadInstanceDetails(endpointId, instanceInfo.instance_name, this.reqId).then((resp: { instance: Instance, reqId: number }) => {
+          if (resp.reqId !== this.reqId) {
+            return
+          }
+
+          this.instancesDetailsRemaining -= 1
+          this.loadingInstancesDetails = this.instancesDetailsRemaining > 0
 
-          if (this.instancesDetails.find(i => i.id === instance.id)) {
-            this.instancesDetails = this.instancesDetails.filter(i => i.id !== instance.id)
+          if (this.instancesDetails.find(i => i.id === resp.instance.id)) {
+            this.instancesDetails = this.instancesDetails.filter(i => i.id !== resp.instance.id)
           }
 
           this.instancesDetails = [
             ...this.instancesDetails,
-            instance,
+            resp.instance,
           ]
           this.instancesDetails.sort((a, b) => a.instance_name.localeCompare(b.instance_name))
 
-          if (count === 0) {
+          if (this.instancesDetailsRemaining === 0) {
             resolve()
           }
-        }).catch(() => {
-          count -= 1
-          this.loadingInstancesDetails = count > 0
+        }).catch((resp?: { reqId: number }) => {
+          if (!resp || resp.reqId !== this.reqId) {
+            return
+          }
+          this.instancesDetailsRemaining -= 1
+          this.loadingInstancesDetails = this.instancesDetailsRemaining > 0
           if (count === 0) {
             resolve()
           }
@@ -190,6 +214,13 @@ class InstanceStore {
       })
     })
   }
+
+  @action clearInstancesDetails() {
+    this.instancesDetails = []
+    this.loadingInstancesDetails = false
+    this.instancesDetailsCount = 0
+    this.instancesDetailsRemaining = 0
+  }
 }
 
 export default new InstanceStore()

+ 75 - 0
src/types/Assessment.js

@@ -0,0 +1,75 @@
+/*
+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 { Field } from './Field'
+import type { Endpoint } from './Endpoint'
+import type { Instance } from './Instance'
+import type { NetworkMap } from './Network'
+
+export type VmSize = {
+  name: string,
+  size?: string,
+}
+
+export type VmItem = {
+  id: string,
+  properties: {
+    recommendedSize: string,
+    disks: {
+      [string]: {
+        recommendedDiskType: string,
+      },
+    },
+    datacenterContainer: string,
+    datacenterManagementServer: string,
+    displayName: string,
+    operatingSystem: string,
+  },
+}
+
+export type Assessment = {
+  name: string,
+  id: string,
+  projectName: string,
+  resourceGroupName: string,
+  groupName: string,
+  properties: {
+    azureLocation: string,
+  },
+  project: {
+    name: string,
+  },
+  group: {
+    name: string,
+    id: string,
+  },
+  properties: {
+    status: string,
+    updatedTimestamp: string,
+    azureLocation: string,
+  },
+  connectionInfo: $PropertyType<Endpoint, 'connection_info'>,
+}
+
+export type MigrationInfo = {
+  options: Field[],
+  source: ?Endpoint,
+  target: Endpoint,
+  selectedInstances: Instance[],
+  destinationEnv: {[string]: mixed},
+  networks: NetworkMap[],
+  vmSizes: {[string]: VmSize},
+}

+ 1 - 0
src/types/Endpoint.js

@@ -27,6 +27,7 @@ export type Endpoint = {
   created_at: Date,
   connection_info: {
     secret_ref?: string,
+    host?: string,
     [string]: mixed
   },
 }

+ 21 - 0
src/utils/ApiCaller.js

@@ -21,6 +21,8 @@ class ApiCaller {
     'Content-Type': 'application/json',
   }
 
+  requests = []
+
   constructor() {
     if (!apiInstance) {
       apiInstance = this
@@ -29,9 +31,26 @@ class ApiCaller {
     return apiInstance
   }
 
+  addRequest(request) {
+    this.requests.unshift(request)
+    if (this.requests.length > 100) {
+      this.requests.pop()
+    }
+  }
+
+  cancelRequest(requestInfo) {
+    requestInfo.request.abort()
+    this.requests = this.requests.filter(r => r.requestId !== requestInfo.requestId)
+  }
+
   sendAjaxRequest(options) {
     return new Promise((resolve, reject) => {
       let request = new XMLHttpRequest()
+
+      if (options.requestId) {
+        this.addRequest({ requestId: options.requestId, request })
+      }
+
       request.open(options.method, options.url)
 
       let headers = Object.assign({}, this.defaultHeaders)
@@ -50,6 +69,8 @@ class ApiCaller {
         if (request.readyState === 4) { // if complete
           if (!(request.status >= 200 && request.status <= 299)) { // check if "OK" (200)
             reject({ status: request.status })
+          } else if (request.status === 0) { // check if aborted
+            reject({ status: 0 })
           }
         }
       }

+ 82 - 0
src/utils/AzureApiCaller.js

@@ -0,0 +1,82 @@
+/*
+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/>.
+*/
+
+import request from 'ajax-request'
+
+let apiInstance = null
+let defaultApiVersion = '2017-11-11-preview'
+let azureUrl = 'https://management.azure.com/'
+
+class AzureApiCaller {
+  constructor() {
+    if (!apiInstance) {
+      apiInstance = this
+    }
+
+    this.headers = {}
+
+    return apiInstance
+  }
+
+  rejectError(error, reject) {
+    console.error('%c', 'color: #D0021B', error) // eslint-disable-line no-console
+    reject(error)
+  }
+
+  send(options, apiVersion) {
+    return new Promise((resolve, reject) => {
+      options.headers = {
+        ...options.headers,
+        ...this.headers,
+      }
+      let logUrl = options.url
+      console.log(`%cSending request to Azure proxy: ${logUrl}`, 'color: #F5A623') // eslint-disable-line no-console
+
+      if (options.url.indexOf('/azure-login') === -1) {
+        options.url = `/proxy/${`${azureUrl + options.url}?api-version=${apiVersion || defaultApiVersion}`}`
+      }
+
+      request(options, (err, resp, body) => {
+        if (!err && resp.statusCode === 200) {
+          let bodyJs
+
+          try {
+            bodyJs = JSON.parse(body)
+          } catch (ex) {
+            reject(ex)
+          }
+
+          if (!bodyJs) {
+            this.rejectError('Incorrect response body', reject)
+          } else if (bodyJs.error) {
+            this.rejectError(`${bodyJs.error.code}: ${bodyJs.error.message}`, reject)
+          } else {
+            console.log(`%cReceiving request from Azure proxy '${logUrl}':`, 'color: #0044CA', bodyJs) // eslint-disable-line no-console
+            resolve(bodyJs)
+          }
+        } else if (err) {
+          this.rejectError(`${err.code}: ${err.message}`, reject)
+        } else {
+          this.rejectError('Request failed, there might be a problem with the connection to the server.', reject)
+        }
+      })
+    })
+  }
+
+  setHeader(name, value) {
+    this.headers[name] = value
+  }
+}
+
+export default new AzureApiCaller()

+ 2 - 1
src/utils/LabelDictionary.js

@@ -93,7 +93,8 @@ class LabelDictionary {
     opc: 'Oracle Cloud',
     azure: 'Azure',
     vmware_vsphere: 'VMware',
-    separate_vm: { label: 'Separate Migration/VM?', description: 'Separate migration per selected instance' },
+    separate_vm: 'Separate Migration/VM?',
+    use_replica: 'Use replica',
   }
 
   static get(fieldName) {

+ 3 - 3
src/utils/Wait.js

@@ -21,7 +21,7 @@ class Wait {
    * @param {number} timeout Specifies after how many miliseconds should the wait give up.
    * @param {Function} timeoutCallback Called if wait reaches timeout.
    */
-  static for(stopCondition, stopCallback, timeout = 5000, timeoutCallback = () => { }) {
+  static for(stopCondition, stopCallback, timeout = -1, timeoutCallback = () => { }) {
     let startTime = new Date()
 
     if (stopCondition()) {
@@ -32,7 +32,7 @@ class Wait {
     let interval = setInterval(() => {
       let currentTime = new Date()
 
-      if (currentTime - startTime > timeout) {
+      if (timeout > -1 && currentTime - startTime > timeout) {
         clearInterval(interval)
         timeoutCallback()
       }
@@ -41,7 +41,7 @@ class Wait {
         clearInterval(interval)
         stopCallback()
       }
-    }, 500)
+    }, 0)
   }
 }
 

+ 159 - 10
yarn.lock

@@ -140,10 +140,20 @@
     react-treebeard "^2.0.3"
     redux "^3.7.2"
 
+"@types/form-data@*":
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-2.2.1.tgz#ee2b3b8eaa11c0938289953606b745b738c54b1e"
+  dependencies:
+    "@types/node" "*"
+
 "@types/inline-style-prefixer@^3.0.0":
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/@types/inline-style-prefixer/-/inline-style-prefixer-3.0.1.tgz#8541e636b029124b747952e9a28848286d2b5bf6"
 
+"@types/node@*", "@types/node@^8.0.25", "@types/node@^8.0.47", "@types/node@^8.0.53":
+  version "8.5.1"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-8.5.1.tgz#4ec3020bcdfe2abffeef9ba3fbf26fca097514b5"
+
 "@types/node@^6.0.46":
   version "6.0.88"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.88.tgz#f618f11a944f6a18d92b5c472028728a3e3d4b66"
@@ -152,6 +162,19 @@
   version "16.0.22"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-16.0.22.tgz#19ad106e124aceebd2b4d430a278d55413ee8759"
 
+"@types/request@^2.0.8":
+  version "2.0.9"
+  resolved "https://registry.yarnpkg.com/@types/request/-/request-2.0.9.tgz#125b8a60d8a439e8d87e6d1335c61cccdc18343a"
+  dependencies:
+    "@types/form-data" "*"
+    "@types/node" "*"
+
+"@types/uuid@^3.4.2", "@types/uuid@^3.4.3":
+  version "3.4.3"
+  resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.3.tgz#121ace265f5569ce40f4f6d0ff78a338c732a754"
+  dependencies:
+    "@types/node" "*"
+
 "@webpack-blocks/core@^0.4.0":
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/@webpack-blocks/core/-/core-0.4.0.tgz#a9225cdaafec06576713a552cd78e0ee054828a9"
@@ -219,6 +242,20 @@ acorn@^5.0.0, acorn@^5.1.1:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.1.2.tgz#911cb53e036807cf0fa778dc5d370fbd864246d7"
 
+adal-node@^0.1.25:
+  version "0.1.26"
+  resolved "https://registry.yarnpkg.com/adal-node/-/adal-node-0.1.26.tgz#5a0a955b74ee8f2bb44f32305cafdc7a6877fced"
+  dependencies:
+    "@types/node" "^8.0.47"
+    async ">=0.6.0"
+    date-utils "*"
+    jws "3.x.x"
+    request ">= 2.52.0"
+    underscore ">= 1.3.1"
+    uuid "^3.1.0"
+    xmldom ">= 0.1.x"
+    xpath.js "~1.0.5"
+
 airbnb-js-shims@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/airbnb-js-shims/-/airbnb-js-shims-1.3.0.tgz#aac46d80057fb0b414f70e06d07e362fd99ee2fa"
@@ -234,6 +271,13 @@ airbnb-js-shims@^1.3.0:
     string.prototype.padend "^3.0.0"
     string.prototype.padstart "^3.0.0"
 
+ajax-request@^1.2.3:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/ajax-request/-/ajax-request-1.2.3.tgz#99fcbec1d6d2792f85fa949535332bd14f5f3790"
+  dependencies:
+    file-system "^2.1.1"
+    utils-extend "^1.0.7"
+
 ajv-keywords@^1.1.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-1.5.1.tgz#314dd0a4b3368fad3dfcdc54ede6171b886daf3c"
@@ -454,22 +498,22 @@ async@1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/async/-/async-1.5.0.tgz#2796642723573859565633fc6274444bee2f8ce3"
 
-async@^1.4.0:
-  version "1.5.2"
-  resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
-
-async@^2.1.2:
+async@2.5.0, async@^2.1.2:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/async/-/async-2.5.0.tgz#843190fd6b7357a0b9e1c956edddd5ec8462b54d"
   dependencies:
     lodash "^4.14.0"
 
-async@^2.1.4:
+async@>=0.6.0, async@^2.1.4:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/async/-/async-2.6.0.tgz#61a29abb6fcc026fea77e56d1c6ec53a795951f4"
   dependencies:
     lodash "^4.14.0"
 
+async@^1.4.0:
+  version "1.5.2"
+  resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
+
 asynckit@^0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
@@ -1588,6 +1632,10 @@ base64-js@^1.0.2:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.1.tgz#a91947da1f4a516ea38e5b4ec0ec3773675e0886"
 
+base64url@2.0.0, base64url@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/base64url/-/base64url-2.0.0.tgz#eac16e03ea1438eff9423d69baa36262ed1f70bb"
+
 bcrypt-pbkdf@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d"
@@ -1623,7 +1671,7 @@ bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
   version "4.11.8"
   resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
 
-body-parser@1.18.2:
+body-parser@1.18.2, body-parser@^1.18.2:
   version "1.18.2"
   resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.2.tgz#87678a19d84b47d859b83199bd59bce222b10454"
   dependencies:
@@ -1772,6 +1820,10 @@ bser@^2.0.0:
   dependencies:
     node-int64 "^0.4.0"
 
+buffer-equal-constant-time@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
+
 buffer-xor@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9"
@@ -2431,6 +2483,10 @@ date-now@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
 
+date-utils@*:
+  version "1.2.21"
+  resolved "https://registry.yarnpkg.com/date-utils/-/date-utils-1.2.21.tgz#61fb16cdc1274b3c9acaaffe9fc69df8720a2b64"
+
 debug@2.6.9, debug@^2.2.0, debug@^2.6.8:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@@ -2632,12 +2688,23 @@ duplexer3@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
 
+duplexer@~0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1"
+
 ecc-jsbn@~0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz#0fc73a9ed5f0d53c38193398523ef7e543777505"
   dependencies:
     jsbn "~0.1.0"
 
+ecdsa-sig-formatter@1.0.9:
+  version "1.0.9"
+  resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.9.tgz#4bc926274ec3b5abb5016e7e1d60921ac262b2a1"
+  dependencies:
+    base64url "^2.0.0"
+    safe-buffer "^5.0.1"
+
 ee-first@1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
@@ -3235,6 +3302,19 @@ file-loader@^1.1.5:
     loader-utils "^1.0.2"
     schema-utils "^0.3.0"
 
+file-match@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/file-match/-/file-match-1.0.2.tgz#c9cad265d2c8adf3a81475b0df475859069faef7"
+  dependencies:
+    utils-extend "^1.0.6"
+
+file-system@^2.1.1:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/file-system/-/file-system-2.2.2.tgz#7d65833e3a2347dcd956a813c677153ed3edd987"
+  dependencies:
+    file-match "^1.0.1"
+    utils-extend "^1.0.4"
+
 filename-regex@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
@@ -3980,6 +4060,10 @@ is-buffer@^1.1.5, is-buffer@~1.1.1:
   version "1.1.5"
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.5.tgz#1f3b26ef613b214b88cbca23cc6c01d87961eecc"
 
+is-buffer@^1.1.6:
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
+
 is-builtin-module@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-1.0.0.tgz#540572d34f7ac3119f8f76c30cbc1b1e037affbe"
@@ -4621,6 +4705,23 @@ just-extend@^1.1.26:
   version "1.1.27"
   resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-1.1.27.tgz#ec6e79410ff914e472652abfa0e603c03d60e905"
 
+jwa@^1.1.4:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.5.tgz#a0552ce0220742cd52e153774a32905c30e756e5"
+  dependencies:
+    base64url "2.0.0"
+    buffer-equal-constant-time "1.0.1"
+    ecdsa-sig-formatter "1.0.9"
+    safe-buffer "^5.0.1"
+
+jws@3.x.x:
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/jws/-/jws-3.1.4.tgz#f9e8b9338e8a847277d6444b1464f61880e050a2"
+  dependencies:
+    base64url "^2.0.0"
+    jwa "^1.1.4"
+    safe-buffer "^5.0.1"
+
 keycode@^2.1.8:
   version "2.1.9"
   resolved "https://registry.yarnpkg.com/keycode/-/keycode-2.1.9.tgz#964a23c54e4889405b4861a5c9f0480d45141dfa"
@@ -5046,10 +5147,38 @@ mobx@^3.6.1:
   version "3.6.1"
   resolved "https://registry.yarnpkg.com/mobx/-/mobx-3.6.1.tgz#ae63a8f00e1485a740d0f91ae2f6a5f68e303bea"
 
-moment@^2.18.1:
+moment@^2.18.1, moment@~2.18.1:
   version "2.18.1"
   resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f"
 
+ms-rest-azure@^2.4.5:
+  version "2.4.5"
+  resolved "https://registry.yarnpkg.com/ms-rest-azure/-/ms-rest-azure-2.4.5.tgz#bf27a7c8ff5f10a54f0f184130bfd0b7974e7553"
+  dependencies:
+    "@types/node" "^8.0.25"
+    "@types/uuid" "^3.4.2"
+    adal-node "^0.1.25"
+    async "2.5.0"
+    moment "~2.18.1"
+    ms-rest "^2.2.6"
+    uuid "^3.1.0"
+
+ms-rest@^2.2.6:
+  version "2.2.7"
+  resolved "https://registry.yarnpkg.com/ms-rest/-/ms-rest-2.2.7.tgz#cca0f4b35555e2df64744028e2523d40175e8e28"
+  dependencies:
+    "@types/node" "^8.0.53"
+    "@types/request" "^2.0.8"
+    "@types/uuid" "^3.4.3"
+    duplexer "~0.1.1"
+    is-buffer "^1.1.6"
+    is-stream "^1.1.0"
+    moment "~2.18.1"
+    request "^2.83.0"
+    through "~2.3.8"
+    tunnel "~0.0.5"
+    uuid "^3.1.0"
+
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@@ -6552,7 +6681,7 @@ request@2.81.0:
     tunnel-agent "^0.6.0"
     uuid "^3.0.0"
 
-request@^2.79.0, request@^2.83.0:
+"request@>= 2.52.0", request@^2.79.0, request@^2.83.0:
   version "2.83.0"
   resolved "https://registry.yarnpkg.com/request/-/request-2.83.0.tgz#ca0b65da02ed62935887808e6f510381034e3356"
   dependencies:
@@ -7195,7 +7324,7 @@ through2@^2.0.1:
     readable-stream "^2.1.5"
     xtend "~4.0.1"
 
-through@^2.3.6, through@^2.3.8:
+through@^2.3.6, through@^2.3.8, through@~2.3.8:
   version "2.3.8"
   resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
 
@@ -7271,6 +7400,10 @@ tunnel-agent@^0.6.0:
   dependencies:
     safe-buffer "^5.0.1"
 
+tunnel@~0.0.5:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.5.tgz#d1532254749ed36620fcd1010865495a1fa9d0ae"
+
 tweetnacl@^0.14.3, tweetnacl@~0.14.0:
   version "0.14.5"
   resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
@@ -7332,6 +7465,10 @@ uid-number@^0.0.6:
   version "0.0.6"
   resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81"
 
+"underscore@>= 1.3.1":
+  version "1.8.3"
+  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022"
+
 underscore@~1.4.4:
   version "1.4.4"
   resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.4.4.tgz#61a6a32010622afa07963bf325203cf12239d604"
@@ -7429,6 +7566,10 @@ utila@~0.4:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c"
 
+utils-extend@^1.0.4, utils-extend@^1.0.6, utils-extend@^1.0.7:
+  version "1.0.8"
+  resolved "https://registry.yarnpkg.com/utils-extend/-/utils-extend-1.0.8.tgz#ccfd7b64540f8e90ee21eec57769d0651cab8a5f"
+
 utils-merge@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
@@ -7763,6 +7904,14 @@ xml-name-validator@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-2.0.1.tgz#4d8b8f1eccd3419aa362061becef515e1e559635"
 
+"xmldom@>= 0.1.x":
+  version "0.1.27"
+  resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9"
+
+xpath.js@~1.0.5:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/xpath.js/-/xpath.js-1.0.7.tgz#7e94627f541276cbc6a6b02b5d35e9418565b3e4"
+
 xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"