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

Merge pull request #569 from smiclea/details-paths

Add support for data restructuring in API lists
Nashwan Azhari 5 лет назад
Родитель
Сommit
0614d9b6b7
56 измененных файлов с 797 добавлено и 639 удалено
  1. 6 2
      src/@types/Execution.ts
  2. 35 11
      src/@types/MainItem.ts
  3. 12 12
      src/components/App.tsx
  4. 1 0
      src/components/atoms/StatusIcon/StatusIcon.tsx
  5. 1 0
      src/components/atoms/StatusIcon/story.tsx
  6. 2 0
      src/components/atoms/StatusPill/StatusPill.tsx
  7. 1 0
      src/components/atoms/StatusPill/story.tsx
  8. 66 27
      src/components/molecules/DeleteReplicaModal/DeleteReplicaModal.tsx
  9. 1 2
      src/components/molecules/DetailsNavigation/DetailsNavigation.tsx
  10. 6 5
      src/components/molecules/MainDetailsTable/MainDetailsTable.tsx
  11. 44 73
      src/components/molecules/MainListItem/MainListItem.tsx
  12. 5 1
      src/components/molecules/MainListItem/story.tsx
  13. 1 2
      src/components/molecules/NotificationDropdown/NotificationDropdown.tsx
  14. 1 1
      src/components/molecules/UserDropdown/UserDropdown.tsx
  15. 5 4
      src/components/organisms/DashboardContent/DashboardContent.tsx
  16. 1 1
      src/components/organisms/DashboardContent/modules/ActivityModule/ActivityModule.tsx
  17. 39 42
      src/components/organisms/DashboardContent/modules/ExecutionsModule/ExecutionsModule.tsx
  18. 5 5
      src/components/organisms/DashboardContent/modules/TopEndpointsModule/TopEndpointsModule.tsx
  19. 12 45
      src/components/organisms/DetailsContentHeader/DetailsContentHeader.tsx
  20. 13 10
      src/components/organisms/EditReplica/EditReplica.tsx
  21. 5 4
      src/components/organisms/EndpointDetailsContent/EndpointDetailsContent.tsx
  22. 55 31
      src/components/organisms/Executions/Executions.tsx
  23. 2 4
      src/components/organisms/FilterList/FilterList.tsx
  24. 6 20
      src/components/organisms/MainDetails/MainDetails.tsx
  25. 3 4
      src/components/organisms/MainList/MainList.tsx
  26. 3 3
      src/components/organisms/MigrationDetailsContent/MigrationDetailsContent.tsx
  27. 1 1
      src/components/organisms/ProjectDetailsContent/ProjectDetailsContent.tsx
  28. 11 5
      src/components/organisms/ReplicaDetailsContent/ReplicaDetailsContent.tsx
  29. 33 3
      src/components/organisms/Tasks/Tasks.tsx
  30. 1 1
      src/components/organisms/Tasks/story.tsx
  31. 1 1
      src/components/organisms/UserDetailsContent/UserDetailsContent.tsx
  32. 3 7
      src/components/pages/AssessmentDetailsPage/AssessmentDetailsPage.tsx
  33. 6 4
      src/components/pages/EndpointDetailsPage/EndpointDetailsPage.tsx
  34. 1 1
      src/components/pages/EndpointsPage/EndpointsPage.tsx
  35. 19 4
      src/components/pages/MigrationDetailsPage/MigrationDetailsPage.tsx
  36. 20 12
      src/components/pages/MigrationsPage/MigrationsPage.tsx
  37. 2 1
      src/components/pages/ProjectDetailsPage/ProjectDetailsPage.tsx
  38. 1 1
      src/components/pages/ProjectsPage/ProjectsPage.tsx
  39. 64 28
      src/components/pages/ReplicaDetailsPage/ReplicaDetailsPage.tsx
  40. 35 39
      src/components/pages/ReplicasPage/ReplicasPage.tsx
  41. 2 1
      src/components/pages/UserDetailsPage/UserDetailsPage.tsx
  42. 1 1
      src/components/pages/UsersPage/UsersPage.tsx
  43. 11 8
      src/components/pages/WizardPage/WizardPage.tsx
  44. 3 3
      src/sources/AssessmentSource.ts
  45. 6 6
      src/sources/MigrationSource.ts
  46. 8 48
      src/sources/NotificationSource.ts
  47. 69 58
      src/sources/ReplicaSource.ts
  48. 6 2
      src/sources/UserSource.ts
  49. 3 3
      src/sources/WizardSource.ts
  50. 2 2
      src/stores/AssessmentStore.ts
  51. 3 3
      src/stores/InstanceStore.ts
  52. 10 16
      src/stores/MigrationStore.ts
  53. 127 62
      src/stores/ReplicaStore.ts
  54. 11 3
      src/stores/UserStore.ts
  55. 4 4
      src/stores/WizardStore.ts
  56. 2 2
      src/utils/DateUtils.ts

+ 6 - 2
src/@types/Execution.ts

@@ -12,7 +12,7 @@ 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 type { Task } from './Task'
+import { Task } from './Task'
 
 export type Execution = {
   id: string,
@@ -20,6 +20,10 @@ export type Execution = {
   status: string,
   created_at: Date,
   updated_at: Date,
-  tasks: Task[],
+  deleted_at?: Date,
   type: 'replica_execution' | 'replica_disks_delete' | 'replica_deploy' | 'replica_update'
 }
+
+export type ExecutionTasks = Execution & {
+  tasks: Task[]
+}

+ 35 - 11
src/@types/MainItem.ts

@@ -13,10 +13,10 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
 import type { Execution } from './Execution'
-import type { Task } from './Task'
 import type { Instance } from './Instance'
 import type { NetworkMap } from './Network'
 import type { StorageMap } from './Endpoint'
+import { Task } from './Task'
 
 export type MainItemInfo = {
   export_info: {
@@ -55,26 +55,50 @@ export type StorageMapping = {
     disk_id: string,
   }[] | null,
 }
-export type MainItem = {
+
+type BaseItem = {
   id: string,
-  executions: Execution[],
   name: string,
+  description?: string
   notes: string,
-  status: string,
-  tasks: Task[],
-  created_at: Date,
-  updated_at: Date,
-  replica_id?: string,
+  created_at: string,
+  updated_at: string,
   origin_endpoint_id: string,
   destination_endpoint_id: string,
   instances: string[],
-  type: 'replica' | 'migration',
   info: { [prop: string]: MainItemInfo },
   destination_environment: { [prop: string]: any },
   source_environment: { [prop: string]: any },
   transfer_result: { [prop: string]: Instance } | null,
   replication_count?: number,
   storage_mappings?: StorageMapping | null,
-  network_map?: TransferNetworkMap
-  [prop: string]: any
+  network_map?: TransferNetworkMap,
+  last_execution_status: string
+  user_id: string
+}
+
+export type ReplicaItem = BaseItem & {
+  type: 'replica',
 }
+
+export type MigrationItem = BaseItem & {
+  type: 'migration',
+  replica_id?: string,
+}
+
+export type MigrationItemOptions = MigrationItem & {
+  skip_os_morphing: boolean,
+  shutdown_instances: boolean,
+}
+
+export type TransferItem = ReplicaItem | MigrationItem
+
+export type ReplicaItemDetails = ReplicaItem & {
+  executions: Execution[],
+}
+
+export type MigrationItemDetails = MigrationItem & {
+  tasks: Task[]
+}
+
+export type TransferItemDetails = ReplicaItemDetails | MigrationItemDetails

+ 12 - 12
src/components/App.tsx

@@ -177,21 +177,21 @@ class App extends React.Component<{}, State> {
             {renderRoute('/', DashboardPage, true)}
             <Route path="/login" component={LoginPage} />
             {renderRoute('/dashboard', DashboardPage)}
-            {renderRoute('/replicas', ReplicasPage)}
-            {renderRoute('/replica/:id', ReplicaDetailsPage, true)}
-            {renderRoute('/replica/:page/:id', ReplicaDetailsPage)}
-            {renderRoute('/migrations', MigrationsPage)}
-            {renderRoute('/migration/:id', MigrationDetailsPage, true)}
-            {renderRoute('/migration/:page/:id', MigrationDetailsPage)}
-            {renderRoute('/endpoints', EndpointsPage)}
-            {renderRoute('/endpoint/:id', EndpointDetailsPage)}
+            {renderRoute('/replicas', ReplicasPage, true)}
+            {renderRoute('/replicas/:id', ReplicaDetailsPage, true)}
+            {renderRoute('/replicas/:id/:page', ReplicaDetailsPage)}
+            {renderRoute('/migrations', MigrationsPage, true)}
+            {renderRoute('/migrations/:id', MigrationDetailsPage, true)}
+            {renderRoute('/migrations/:id/:page', MigrationDetailsPage)}
+            {renderRoute('/endpoints', EndpointsPage, true)}
+            {renderRoute('/endpoints/:id', EndpointDetailsPage)}
             {renderRoute('/wizard/:type', WizardPage)}
             {renderOptionalRoute('planning', AssessmentsPage)}
             {renderOptionalRoute('planning', AssessmentDetailsPage, '/assessment/:info')}
-            {renderOptionalRoute('users', UsersPage)}
-            {renderOptionalRoute('users', UserDetailsPage, '/user/:id', true)}
-            {renderOptionalRoute('projects', ProjectsPage)}
-            {renderOptionalRoute('projects', ProjectDetailsPage, '/project/:id', true)}
+            {renderOptionalRoute('users', UsersPage, undefined, true)}
+            {renderOptionalRoute('users', UserDetailsPage, '/users/:id')}
+            {renderOptionalRoute('projects', ProjectsPage, undefined, true)}
+            {renderOptionalRoute('projects', ProjectDetailsPage, '/projects/:id')}
             {renderOptionalRoute('logging', LogsPage)}
             {renderRoute('/streamlog', LogStreamPage)}
             <Route component={MessagePage} />

+ 1 - 0
src/components/atoms/StatusIcon/StatusIcon.tsx

@@ -90,6 +90,7 @@ const statuses = (status: any, props: any) => {
         background-image: ${getWarningUrl('#424242')};
       `
     case 'UNSCHEDULED':
+    case 'UNEXECUTED':
       return css`
         background-image: ${getWarningUrl(Palette.grayscale[2])};
       `

+ 1 - 0
src/components/atoms/StatusIcon/story.tsx

@@ -23,6 +23,7 @@ const Wrapper = styled.div<any>`
 `
 
 const STATUSES = [
+  'UNEXECUTED',
   'SCHEDULED',
   'UNSCHEDULED',
   'COMPLETED',

+ 2 - 0
src/components/atoms/StatusPill/StatusPill.tsx

@@ -100,6 +100,7 @@ const statuses = (status: any) => {
       `
     case 'INFO':
     case 'SCHEDULED':
+    case 'UNEXECUTED':
       return null
     default:
       return null
@@ -143,6 +144,7 @@ const Wrapper = styled.div<any>`
   border-radius: 4px;
   ${(props: any) => statuses(props.status)}
   ${(props: any) => (props.status === 'INFO' ? getInfoStatusColor(props) : '')}
+  text-transform: uppercase;
 `
 
 type Props = {

+ 1 - 0
src/components/atoms/StatusPill/story.tsx

@@ -22,6 +22,7 @@ const Wrapper = styled.div<any>`
   flex-wrap: wrap;
 `
 const STATUSES = [
+  'UNEXECUTED',
   'SCHEDULED',
   'UNSCHEDULED',
   'COMPLETED',

+ 66 - 27
src/components/molecules/DeleteReplicaModal/DeleteReplicaModal.tsx

@@ -50,10 +50,30 @@ const ButtonsColumn = styled.div<any>`
   display: flex;
   flex-direction: column;
 `
+const Loading = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin: 64px;
+`
+const LoadingMessage = styled.div`
+  max-width: 100%;
+  overflow: auto;
+  margin-top: 48px;
+  text-align: center;
+`
+const LoadingTitle = styled.div`
+  font-size: 18px;
+  margin-bottom: 8px;
+`
+const LoadingSubtitle = styled.div<any>`
+  color: ${Palette.grayscale[4]};
+`
 
 type Props = {
   hasDisks: boolean,
   isMultiReplicaSelection?: boolean,
+  loading?: boolean
   onDeleteReplica: () => void,
   onDeleteDisks: () => void,
   onRequestClose: () => void,
@@ -93,41 +113,60 @@ class DeleteReplicaModal extends React.Component<Props> {
     )
   }
 
+  renderLoading() {
+    return (
+      <Loading>
+        <StatusImage loading />
+        <LoadingMessage>
+          <LoadingTitle>Validating Replicas Details</LoadingTitle>
+          <LoadingSubtitle>Please wait ...</LoadingSubtitle>
+        </LoadingMessage>
+      </Loading>
+    )
+  }
+
+  renderContent() {
+    const message = this.props.isMultiReplicaSelection ? 'Are you sure you want to delete the selected replicas?' : 'Are you sure you want to delete this replica?'
+
+    return (
+      <Wrapper>
+        <StatusImage status="QUESTION" />
+        <Message>{message}</Message>
+        { this.renderExtraMessage() }
+        <Buttons>
+          <Button secondary onClick={this.props.onRequestClose}>Cancel</Button>
+          <ButtonsColumn>
+            {this.props.hasDisks ? (
+              <Button
+                onClick={this.props.onDeleteDisks}
+                hollow
+                style={{ marginBottom: '16px' }}
+                alert
+              >
+                Delete Replica Disks
+              </Button>
+            ) : null}
+            <Button
+              onClick={this.props.onDeleteReplica}
+              alert
+            >
+              Delete Replica{this.props.isMultiReplicaSelection ? 's' : ''}
+            </Button>
+          </ButtonsColumn>
+        </Buttons>
+      </Wrapper>
+    )
+  }
+
   render() {
     const title = this.props.isMultiReplicaSelection ? 'Delete Selected Replicas?' : 'Delete Replica?'
-    const message = this.props.isMultiReplicaSelection ? 'Are you sure you want to delete the selected replicas?' : 'Are you sure you want to delete this replica?'
     return (
       <Modal
         isOpen
         title={title}
         onRequestClose={this.props.onRequestClose}
       >
-        <Wrapper>
-          <StatusImage status="QUESTION" />
-          <Message>{message}</Message>
-          {this.renderExtraMessage()}
-          <Buttons>
-            <Button secondary onClick={this.props.onRequestClose}>Cancel</Button>
-            <ButtonsColumn>
-              {this.props.hasDisks ? (
-                <Button
-                  onClick={this.props.onDeleteDisks}
-                  hollow
-                  style={{ marginBottom: '16px' }}
-                  alert
-                >
-                  Delete Replica Disks
-                </Button>
-              ) : null}
-              <Button
-                onClick={this.props.onDeleteReplica}
-                alert
-              >
-                Delete Replica{this.props.isMultiReplicaSelection ? 's' : ''}
-              </Button>
-            </ButtonsColumn>
-          </Buttons>
-        </Wrapper>
+        {this.props.loading ? this.renderLoading() : this.renderContent()}
       </Modal>
     )
   }

+ 1 - 2
src/components/molecules/DetailsNavigation/DetailsNavigation.tsx

@@ -46,10 +46,9 @@ class DetailsNavigation extends React.Component<Props> {
     return (
       this.props.items.map(item => (
         <Item
-          data-test-id={`detailsNavigation-${item.value}`}
           selected={item.value === this.props.selectedValue}
           key={item.value || item.label}
-          to={this.props.customHref ? this.props.customHref(item) : `/${this.props.itemType || ''}${(item.value && '/') || ''}${item.value}/${this.props.itemId || ''}`}
+          to={this.props.customHref ? this.props.customHref(item) : `/${this.props.itemType || ''}s/${this.props.itemId || ''}${(item.value && '/') || ''}${item.value}`}
         >{item.label}
         </Item>
       ))

+ 6 - 5
src/components/molecules/MainDetailsTable/MainDetailsTable.tsx

@@ -22,7 +22,8 @@ import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
 
 import {
-  MainItem, TransferNetworkMap, isNetworkMapSecurityGroups, isNetworkMapSourceDest,
+  TransferNetworkMap, isNetworkMapSecurityGroups,
+  isNetworkMapSourceDest, TransferItem,
 } from '../../../@types/MainItem'
 import type { Instance, Nic, Disk } from '../../../@types/Instance'
 import type { Network } from '../../../@types/Network'
@@ -147,7 +148,7 @@ const ArrowIcon = styled.div<any>`
 export const TEST_ID = 'mainDetailsTable'
 
 export type Props = {
-  item?: MainItem | null,
+  item?: TransferItem | null,
   instancesDetails: Instance[],
   networks?: Network[],
 }
@@ -279,7 +280,7 @@ class MainDetailsTable extends React.Component<Props, State> {
           destinationKey = destinationName as string
           destinationBody = getBody(transferDisk)
         }
-      } else if (this.props.item && this.props.item.status === 'RUNNING' && this.props.item.type === 'migration') {
+      } else if (this.props.item?.type === 'migration' && this.props.item.last_execution_status === 'RUNNING') {
         destinationBody = ['Waiting for migration to finish']
       }
 
@@ -354,7 +355,7 @@ class MainDetailsTable extends React.Component<Props, State> {
             destinationNetworkName = destinationNic.network_name
             destinationBody = getBody(destinationNic)
           }
-        } else if (this.props.item && this.props.item.status === 'RUNNING' && this.props.item.type === 'migration') {
+        } else if (this.props.item?.type === 'migration' && this.props.item.last_execution_status === 'RUNNING') {
           destinationBody = ['Waiting for migration to finish']
         }
 
@@ -387,7 +388,7 @@ class MainDetailsTable extends React.Component<Props, State> {
     if (transferResult) {
       destinationName = transferResult.instance_name || transferResult.name
       destinationBody = getBody(transferResult)
-    } else if (this.props.item && this.props.item.status === 'RUNNING' && this.props.item.type === 'migration') {
+    } else if (this.props.item?.type === 'migration' && this.props.item.last_execution_status === 'RUNNING') {
       destinationName = 'Waiting for migration to finish'
     }
     const instanceName = instance.instance_name || instance.name

+ 44 - 73
src/components/molecules/MainListItem/MainListItem.tsx

@@ -21,12 +21,11 @@ import StatusPill from '../../atoms/StatusPill'
 import EndpointLogos from '../../atoms/EndpointLogos'
 import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
-import DateUtils from '../../../utils/DateUtils'
-import type { MainItem } from '../../../@types/MainItem'
-import type { Execution } from '../../../@types/Execution'
+import type { TransferItem } from '../../../@types/MainItem'
 
 import arrowImage from './images/arrow.svg'
 import scheduleImage from './images/schedule.svg'
+import DateUtils from '../../../utils/DateUtils'
 
 const CheckboxStyled = styled(Checkbox)`
   opacity: ${props => (props.checked ? 1 : 0)};
@@ -97,97 +96,74 @@ const EndpointImageArrow = styled.div<any>`
   margin: 0 16px;
   background: url('${arrowImage}') center no-repeat;
 `
-const LastExecution = styled.div<any>`
-  min-width: 175px;
-  margin-right: 25px;
-`
 const ItemLabel = styled.div<any>`
   color: ${Palette.grayscale[4]};
 `
 const ItemValue = styled.div<any>`
   color: ${Palette.primary};
 `
-
-const TasksRemaining = styled.div<any>`
-  min-width: 114px;
+const Column = styled.div`
+  align-self: start;
 `
 
 type Props = {
-  item: MainItem,
+  item: TransferItem,
   onClick: () => void,
   selected: boolean,
-  useTasksRemaining?: boolean,
   image: string,
   showScheduleIcon?: boolean,
   endpointType: (endpointId: string) => string,
+  getUserName: (userId: string) => string | undefined,
+  userNameLoading: boolean,
   onSelectedChange: (value: boolean) => void,
 }
 @observer
 class MainListItem extends React.Component<Props> {
-  getLastExecution(): Execution | MainItem | null | undefined {
-    if (this.props.item.executions && this.props.item.executions.length) {
-      return this.props.item.executions[this.props.item.executions.length - 1]
-    }
-
-    if (typeof this.props.item.executions === 'undefined') {
-      return this.props.item
-    }
-
-    return null
-  }
-
   getStatus() {
-    const lastExecution = this.getLastExecution()
-    if (lastExecution) {
-      return lastExecution.status
-    }
-
-    return null
+    return this.props.item.last_execution_status
   }
 
-  getTasksRemaining() {
-    const lastExecution = this.getLastExecution()
-
-    if (!lastExecution || !lastExecution.tasks || lastExecution.tasks.length === 0) {
-      return '-'
-    }
-
-    const unfinished = lastExecution.tasks.filter(task => task.status !== 'COMPLETED').length
-
-    if (unfinished === 0) {
-      return '-'
-    }
-
-    const total = lastExecution.tasks.length
-
-    return `${unfinished} of ${total}`
+  renderCreationDate() {
+    return (
+      <Column style={{ minWidth: '170px', maxWidth: '170px', marginRight: '25px' }}>
+        <ItemLabel>
+          Created
+        </ItemLabel>
+        <ItemValue>
+          {DateUtils.getLocalTime(this.props.item.created_at).format('DD MMMM YYYY, HH:mm')}
+        </ItemValue>
+      </Column>
+    )
   }
 
-  getTotalExecutions() {
-    return (this.props.item.executions && this.props.item.executions.length) || '-'
+  renderUpdateDate() {
+    return (
+      <Column style={{ minWidth: '170px', maxWidth: '170px', marginRight: '25px' }}>
+        <ItemLabel>
+          Updated
+        </ItemLabel>
+        <ItemValue>
+          {this.props.item.updated_at ? DateUtils.getLocalTime(this.props.item.updated_at).format('DD MMMM YYYY, HH:mm') : '-'}
+        </ItemValue>
+      </Column>
+    )
   }
 
-  renderLastExecution() {
-    const lastExecution = this.getLastExecution()
-    let label = 'Last Execution'
-    let time = '-'
-
-    if (this.props.item.executions === undefined) {
-      label = 'Created'
-      time = DateUtils.getLocalTime(lastExecution && lastExecution.created_at).format('DD MMMM YYYY, HH:mm')
-    } else if (lastExecution && (lastExecution.created_at || lastExecution.updated_at)) {
-      time = DateUtils.getLocalTime(lastExecution.updated_at || lastExecution.created_at).format('DD MMMM YYYY, HH:mm')
-    }
-
+  renderUser() {
     return (
-      <LastExecution>
+      <Column style={{ minWidth: '115px', maxWidth: '115px' }}>
         <ItemLabel>
-          {label}
+          User
         </ItemLabel>
-        <ItemValue>
-          {time}
+        <ItemValue
+          style={{
+            textOverflow: 'ellipsis',
+            overflow: 'hidden',
+          }}
+        >
+          {this.props.userNameLoading ? 'Loading...' : (this.props.getUserName(this.props.item.user_id) || this.props.item.user_id)}
         </ItemValue>
-      </LastExecution>
+      </Column>
     )
   }
 
@@ -235,14 +211,9 @@ class MainListItem extends React.Component<Props> {
             </StatusWrapper>
           </Title>
           {endpointImages}
-          {this.renderLastExecution()}
-          <TasksRemaining>
-            <ItemLabel>{this.props.useTasksRemaining ? 'Tasks Remaining' : 'Total Executions'}</ItemLabel>
-            <ItemValue>{
-              this.props.useTasksRemaining ? this.getTasksRemaining() : this.getTotalExecutions()
-}
-            </ItemValue>
-          </TasksRemaining>
+          {this.renderCreationDate()}
+          {this.renderUpdateDate()}
+          {this.renderUser()}
         </Content>
       </Wrapper>
     )

+ 5 - 1
src/components/molecules/MainListItem/story.tsx

@@ -38,7 +38,9 @@ storiesOf('MainListItem', module)
       selected={false}
       image="image"
       onSelectedChange={() => {}}
-      onClick={() => {}}
+      onClick={() => { }}
+      getUserName={id => id}
+      userNameLoading={false}
     />
   ))
   .add('running', () => (
@@ -49,5 +51,7 @@ storiesOf('MainListItem', module)
       image="image"
       onSelectedChange={() => { }}
       onClick={() => { }}
+      getUserName={id => id}
+      userNameLoading={false}
     />
   ))

+ 1 - 2
src/components/molecules/NotificationDropdown/NotificationDropdown.tsx

@@ -264,8 +264,7 @@ class NotificationDropdown extends React.Component<Props, State> {
               onMouseDown={() => { this.itemMouseDown = true }}
               onMouseUp={() => { this.itemMouseDown = false }}
               onClick={() => { this.handleItemClick() }}
-              to={`/${item.type}${executionsHref}/${item.id}`}
-              data-test-id={`${testId}-${item.id}-item`}
+              to={`/${item.type}s/${item.id}/${executionsHref}`}
             >
               <InfoColumn>
                 <MainItemInfo>

+ 1 - 1
src/components/molecules/UserDropdown/UserDropdown.tsx

@@ -163,7 +163,7 @@ class UserDropdown extends React.Component<Props, State> {
     const isAdmin = this.props.user.isAdmin
     if (isAdmin && navigationMenu.find(m => m.value === 'users'
       && !configLoader.config.disabledPages.find(p => p === 'users') && (!m.requiresAdmin || isAdmin))) {
-      href = `/user/${this.props.user.id}`
+      href = `/users/${this.props.user.id}`
     }
 
     return (

+ 5 - 4
src/components/organisms/DashboardContent/DashboardContent.tsx

@@ -25,12 +25,12 @@ import ExecutionsModule from './modules/ExecutionsModule'
 
 import Palette from '../../styleUtils/Palette'
 
-import type { MainItem } from '../../../@types/MainItem'
 import type { Endpoint } from '../../../@types/Endpoint'
 import type { Project } from '../../../@types/Project'
 import type { User } from '../../../@types/User'
 import type { Licence } from '../../../@types/Licence'
 import type { NotificationItemData } from '../../../@types/NotificationItem'
+import { ReplicaItem, MigrationItem } from '../../../@types/MainItem'
 
 const MIDDLE_WIDTHS = ['264px', '264px', '264px']
 
@@ -52,8 +52,8 @@ const MiddleMobileLayout = styled.div<any>`
 `
 
 type Props = {
-  replicas: MainItem[],
-  migrations: MainItem[],
+  replicas: ReplicaItem[],
+  migrations: MigrationItem[],
   endpoints: Endpoint[],
   projects: Project[],
   replicasLoading: boolean,
@@ -211,7 +211,8 @@ class DashboardContent extends React.Component<Props, State> {
         {this.renderMiddleModules()}
         <ExecutionsModule
           replicas={this.props.replicas}
-          loading={this.props.replicasLoading}
+          migrations={this.props.migrations}
+          loading={this.props.replicasLoading || this.props.migrationsLoading}
         />
       </Wrapper>
     )

+ 1 - 1
src/components/organisms/DashboardContent/modules/ActivityModule/ActivityModule.tsx

@@ -106,7 +106,7 @@ class ActivityModule extends React.Component<Props> {
             return (
               <ListItem
                 key={item.id}
-                to={`/${item.type}${executionsHref}/${item.id}`}
+                to={`/${item.type}s/${item.id}/${executionsHref}`}
                 style={{
                   width: `calc(${this.props.large ? 50 : 100}% - 32px)`,
                   paddingTop: (i === 0 || i === 5) ? '16px' : '8px',

+ 39 - 42
src/components/organisms/DashboardContent/modules/ExecutionsModule/ExecutionsModule.tsx

@@ -24,10 +24,8 @@ import BarChart from '../../charts/BarChart'
 import Palette from '../../../../styleUtils/Palette'
 import StyleProps from '../../../../styleUtils/StyleProps'
 
-import type { MainItem } from '../../../../../@types/MainItem'
-import type { Execution } from '../../../../../@types/Execution'
-
 import emptyBackgroundImage from './images/empty-background.svg'
+import { ReplicaItem, MigrationItem, TransferItem } from '../../../../../@types/MainItem'
 
 const INTERVALS = [
   { label: 'Last {x} days', value: '30-days' },
@@ -132,7 +130,8 @@ const EmptyBackgroundImage = styled.div<any>`
 
 type Props = {
   // eslint-disable-next-line react/no-unused-prop-types
-  replicas: MainItem[],
+  replicas: ReplicaItem[],
+  migrations: MigrationItem[],
   loading: boolean,
 }
 type GroupedData = {
@@ -142,8 +141,8 @@ type GroupedData = {
 }
 type TooltipData = {
   title: string,
-  success: number,
-  failed: number,
+  migrations: number,
+  replicas: number,
 }
 type State = {
   selectedPeriod: string,
@@ -151,7 +150,7 @@ type State = {
   tooltipPosition: { x: number, y: number },
   tooltipData: TooltipData | null,
 }
-const COLORS = ['#0044CA', '#2D74FF']
+const COLORS = ['#F91661', '#0044CB']
 
 @observer
 class ExecutionsModule extends React.Component<Props, State> {
@@ -162,54 +161,52 @@ class ExecutionsModule extends React.Component<Props, State> {
     tooltipPosition: { x: 0, y: 0 },
   }
 
-  UNSAFE_componentWillMount() {
-    this.groupExecutions(this.props)
+  componentDidMount() {
+    this.groupCreations(this.props)
   }
 
   UNSAFE_componentWillReceiveProps(props: Props) {
-    this.groupExecutions(props)
+    this.groupCreations(props)
   }
 
-  groupExecutions(props: Props) {
-    let executions: Execution[] = []
-    const replicas = props.replicas
-    replicas.forEach(replica => {
-      executions = [...executions, ...replica.executions]
-    })
+  groupCreations(props: Props) {
+    let creations: TransferItem[] = [...props.replicas, ...props.migrations]
+
     const periodUnit: any = this.state.selectedPeriod.split('-')[1]
     const periodValue: any = Number(this.state.selectedPeriod.split('-')[0])
     const oldestDate: Date = moment().subtract(periodValue, periodUnit).toDate()
-    executions = executions
-      .filter(e => new Date(e.updated_at || e.created_at).getTime() >= oldestDate.getTime())
-    executions.sort((a, b) => new Date(a.updated_at || a.created_at).getTime()
-      - new Date(b.updated_at || b.created_at).getTime())
-    this.groupByPeriod(executions, periodUnit)
+    creations = creations
+      .filter(e => new Date(e.created_at).getTime() >= oldestDate.getTime())
+    creations.sort((a, b) => new Date(a.created_at).getTime()
+      - new Date(b.created_at).getTime())
+
+    this.groupByPeriod(creations, periodUnit)
   }
 
-  groupByPeriod(executions: Execution[], periodUnit: string) {
+  groupByPeriod(transferItems: TransferItem[], periodUnit: string) {
     const groupedData: GroupedData[] = []
-    const periods: { [period: string]: { success: number, failed: number } } = {}
-    executions.forEach(e => {
-      const date = moment(new Date(e.updated_at || e.created_at))
+    const periods: { [period: string]: { replicas: number, migrations: number } } = {}
+    transferItems.forEach(item => {
+      const date = moment(new Date(item.created_at))
       const period: string = periodUnit === 'days' ? date.format('DD-MMM-YYYY_DD MMMM') : date.format('MMM-YYYY_MMMM YYYY')
       if (!periods[period]) {
-        periods[period] = { success: 0, failed: 0 }
+        periods[period] = { replicas: 0, migrations: 0 }
       }
-      if (e.status === 'COMPLETED') {
-        periods[period].success += 1
-      } else if (e.status === 'ERROR') {
-        periods[period].failed += 1
+      if (item.type === 'replica') {
+        periods[period].replicas += 1
+      } else if (item.type === 'migration') {
+        periods[period].migrations += 1
       }
     })
     Object.keys(periods).forEach(period => {
-      if (!periods[period].success && !periods[period].failed) {
+      if (!periods[period].replicas && !periods[period].migrations) {
         return
       }
       const label = period.split('_')[0]
       const title = period.split('_')[1]
       groupedData.push({
         label: periodUnit === 'days' ? `${label.split('-')[0]} ${label.split('-')[1]}` : label.split('-')[0],
-        values: [periods[period].failed, periods[period].success],
+        values: [periods[period].migrations, periods[period].replicas],
         data: title,
       })
     })
@@ -218,7 +215,7 @@ class ExecutionsModule extends React.Component<Props, State> {
 
   handleDropdownChange(selectedPeriod: string) {
     this.setState({ selectedPeriod }, () => {
-      this.groupExecutions(this.props)
+      this.groupCreations(this.props)
     })
   }
 
@@ -226,8 +223,8 @@ class ExecutionsModule extends React.Component<Props, State> {
     this.setState({
       tooltipPosition: { x: position.x - 86, y: position.y },
       tooltipData: {
-        failed: item.values[0],
-        success: item.values[1],
+        replicas: item.values[1],
+        migrations: item.values[0],
         title: item.data || '-',
       },
     })
@@ -264,16 +261,16 @@ class ExecutionsModule extends React.Component<Props, State> {
         <TooltipHeader>{data.title}</TooltipHeader>
         <TooltipBody>
           <TooltipRow>
-            <TooltipRowLabel>Total Executions</TooltipRowLabel>
-            <TooltipRowLabel>{data.success + data.failed}</TooltipRowLabel>
+            <TooltipRowLabel>Created</TooltipRowLabel>
+            <TooltipRowLabel>{data.replicas + data.migrations}</TooltipRowLabel>
           </TooltipRow>
           <TooltipRow>
-            <TooltipRowLabel>Successful</TooltipRowLabel>
-            <TooltipRowLabel>{data.success}</TooltipRowLabel>
+            <TooltipRowLabel>Replicas</TooltipRowLabel>
+            <TooltipRowLabel>{data.replicas}</TooltipRowLabel>
           </TooltipRow>
           <TooltipRow>
-            <TooltipRowLabel>Failed</TooltipRowLabel>
-            <TooltipRowLabel>{data.failed}</TooltipRowLabel>
+            <TooltipRowLabel>Migrations</TooltipRowLabel>
+            <TooltipRowLabel>{data.migrations}</TooltipRowLabel>
           </TooltipRow>
         </TooltipBody>
         <TooltipTip />
@@ -326,7 +323,7 @@ class ExecutionsModule extends React.Component<Props, State> {
   render() {
     return (
       <Wrapper>
-        <Title>Replica Executions</Title>
+        <Title>Items Created</Title>
         <Module>
           {this.props.replicas.length === 0 && this.props.loading
             ? this.renderLoading() : this.renderChart()}

+ 5 - 5
src/components/organisms/DashboardContent/modules/TopEndpointsModule/TopEndpointsModule.tsx

@@ -25,10 +25,10 @@ import PieChart from '../../charts/PieChart'
 import Palette from '../../../../styleUtils/Palette'
 import StyleProps from '../../../../styleUtils/StyleProps'
 
-import type { MainItem } from '../../../../../@types/MainItem'
 import type { Endpoint } from '../../../../../@types/Endpoint'
 
 import endpointImage from './images/endpoint.svg'
+import { ReplicaItem, MigrationItem, TransferItem } from '../../../../../@types/MainItem'
 
 const Wrapper = styled.div<any>`
   flex-grow: 1;
@@ -137,9 +137,9 @@ type GroupedEndpoint = {
 }
 type Props = {
   // eslint-disable-next-line react/no-unused-prop-types
-  replicas: MainItem[],
+  replicas: ReplicaItem[],
   // eslint-disable-next-line react/no-unused-prop-types
-  migrations: MainItem[],
+  migrations: MigrationItem[],
   // eslint-disable-next-line react/no-unused-prop-types
   endpoints: Endpoint[],
   style: any,
@@ -172,7 +172,7 @@ class TopEndpointsModule extends React.Component<Props, State> {
 
   calculateGroupedEndpoints(props: Props) {
     const groupedEndpoints: GroupedEndpoint[] = []
-    const count = (mainItems: MainItem[], endpointId: string) => mainItems
+    const count = (mainItems: TransferItem[], endpointId: string) => mainItems
       .filter(r => r.destination_endpoint_id === endpointId
         || r.origin_endpoint_id === endpointId).length
 
@@ -214,7 +214,7 @@ class TopEndpointsModule extends React.Component<Props, State> {
         {topData.map((item, i) => (
           <LegendItem key={item.endpoint.id}>
             <LegendBullet color={COLORS[i % COLORS.length]} />
-            <LegendLabel to={`/endpoint/${item.endpoint.id}`}>{item.endpoint.name}</LegendLabel>
+            <LegendLabel to={`/endpoints/${item.endpoint.id}`}>{item.endpoint.name}</LegendLabel>
           </LegendItem>
         ))}
       </Legend>

+ 12 - 45
src/components/organisms/DetailsContentHeader/DetailsContentHeader.tsx

@@ -17,8 +17,6 @@ import { observer } from 'mobx-react'
 import styled from 'styled-components'
 import { Link } from 'react-router-dom'
 
-import type { MainItem } from '../../../@types/MainItem'
-import type { Execution } from '../../../@types/Execution'
 import StatusPill from '../../atoms/StatusPill'
 import ActionDropdown from '../../molecules/ActionDropdown'
 import type { Action as DropdownAction } from '../../molecules/ActionDropdown'
@@ -84,37 +82,21 @@ type Props = {
   dropdownActions?: DropdownAction[],
   backLink: string,
   typeImage?: string,
-  statusLabel?: string,
-  item: any,
   alertInfoPill?: boolean,
   primaryInfoPill?: boolean,
+  statusPill?: string,
+  statusLabel?: string,
+  itemTitle?: string | null
+  itemType?: string
+  itemDescription?: string
 }
 @observer
 class DetailsContentHeader extends React.Component<Props> {
-  getLastExecution(): MainItem | Execution | null | undefined {
-    if (this.props.item && this.props.item.executions && this.props.item.executions.length) {
-      return this.props.item.executions[this.props.item.executions.length - 1]
-    } if (this.props.item && typeof this.props.item.executions === 'undefined') {
-      return this.props.item
-    }
-
-    return null
-  }
-
-  getStatus() {
-    const lastExecution = this.getLastExecution()
-    if (lastExecution) {
-      return lastExecution.status
-    }
-
-    return null
-  }
-
   renderStatusPill() {
-    if (!this.getStatus()) {
+    if (!this.props.statusPill) {
       return null
     }
-    let statusLabel = this.getStatus()
+    let statusLabel = this.props.statusPill
     if (this.props.statusLabel) {
       statusLabel = this.props.statusLabel
     }
@@ -122,14 +104,12 @@ class DetailsContentHeader extends React.Component<Props> {
       <StatusPills>
         <StatusPill
           status="INFO"
-          label={this.props.item ? this.props.item.type && this.props.item.type.toUpperCase() : ''}
+          label={this.props.itemType}
           alert={this.props.alertInfoPill}
           primary={this.props.primaryInfoPill}
-          data-test-id="dcHeader-infoPill"
         />
         <StatusPill
-          data-test-id={`dcHeader-statusPill-${statusLabel || ''}`}
-          status={this.getStatus()}
+          status={this.props.statusPill}
           label={statusLabel || ''}
         />
       </StatusPills>
@@ -151,36 +131,23 @@ class DetailsContentHeader extends React.Component<Props> {
   }
 
   renderDescription() {
-    if (!this.props.item || !this.props.item.description) {
+    if (!this.props.itemDescription) {
       return null
     }
 
     return (
-      <Description data-test-id="dcHeader-description">{this.props.item.description}</Description>
+      <Description>{this.props.itemDescription}</Description>
     )
   }
 
   render() {
-    let title = null
-    if (this.props.item) {
-      const { instances } = this.props.item
-      if (instances) {
-        title = instances[0]
-        if (instances.length > 1) {
-          title += ` (+${instances.length - 1} more)`
-        }
-      } else {
-        title = this.props.item.name
-      }
-    }
-
     return (
       <Wrapper>
         <BackButton to={this.props.backLink} data-test-id="dcHeader-backButton" />
         <TypeImage image={this.props.typeImage} />
         <Title>
           <Status>
-            <Text title={title} data-test-id="dcHeader-title">{title}</Text>
+            <Text title={this.props.itemTitle}>{this.props.itemTitle}</Text>
             {this.renderStatusPill()}
             {this.renderDescription()}
           </Status>

+ 13 - 10
src/components/organisms/EditReplica/EditReplica.tsx

@@ -31,7 +31,9 @@ import WizardNetworks from '../WizardNetworks'
 import WizardOptions from '../WizardOptions'
 import WizardStorage from '../WizardStorage/WizardStorage'
 
-import type { MainItem, UpdateData } from '../../../@types/MainItem'
+import type {
+  UpdateData, TransferItemDetails, MigrationItemDetails,
+} from '../../../@types/MainItem'
 import type { NavigationItem } from '../../molecules/Panel'
 import type { Endpoint, StorageBackend, StorageMap } from '../../../@types/Endpoint'
 import type { Field } from '../../../@types/Field'
@@ -84,7 +86,7 @@ type Props = {
   isOpen: boolean,
   onRequestClose: () => void,
   onUpdateComplete: (redirectTo: string) => void,
-  replica: MainItem,
+  replica: TransferItemDetails,
   destinationEndpoint: Endpoint,
   sourceEndpoint: Endpoint,
   instancesDetails: Instance[],
@@ -224,11 +226,12 @@ class EditReplica extends React.Component<Props, State> {
       const osData = replicaData[`${plugin.migrationImageMapFieldName}/${osMapping[1]}`]
       return osData
     }
-    if (migrationFields.find(f => f.name === fieldName) && this.props.replica[fieldName]) {
-      return this.props.replica[fieldName]
+    const anyData = this.props.replica as any
+    if (migrationFields.find(f => f.name === fieldName) && anyData[fieldName]) {
+      return anyData[fieldName]
     }
     if (fieldName === 'skip_os_morphing' && this.props.type === 'migration') {
-      return migrationStore.getDefaultSkipOsMorphing(this.props.replica)
+      return migrationStore.getDefaultSkipOsMorphing(anyData)
     }
     return defaultValue
   }
@@ -439,12 +442,12 @@ class EditReplica extends React.Component<Props, State> {
     if (this.props.type === 'replica') {
       try {
         await replicaStore.update(
-          this.props.replica,
+          this.props.replica as any,
           this.props.destinationEndpoint,
           updateData, this.getDefaultStorage(), endpointStore.storageConfigDefault,
         )
         this.props.onRequestClose()
-        this.props.onUpdateComplete(`/replica/executions/${this.props.replica.id}`)
+        this.props.onUpdateComplete(`/replicas/${this.props.replica.id}/executions`)
       } catch (err) {
         this.setState({ updateDisabled: false })
       }
@@ -452,8 +455,8 @@ class EditReplica extends React.Component<Props, State> {
       try {
         const replicaDefaultStorage = this.props.replica.storage_mappings
           && this.props.replica.storage_mappings.default
-        const migration: MainItem = await migrationStore.recreate(
-          this.props.replica,
+        const migration: MigrationItemDetails = await migrationStore.recreate(
+          this.props.replica as any,
           this.props.sourceEndpoint,
           this.props.destinationEndpoint,
           updateData,
@@ -463,7 +466,7 @@ class EditReplica extends React.Component<Props, State> {
         )
         migrationStore.clearDetails()
         this.props.onRequestClose()
-        this.props.onUpdateComplete(`/migration/tasks/${migration.id}`)
+        this.props.onUpdateComplete(`/migrations/${migration.id}/tasks`)
       } catch (err) {
         this.setState({ updateDisabled: false })
       }

+ 5 - 4
src/components/organisms/EndpointDetailsContent/EndpointDetailsContent.tsx

@@ -25,13 +25,13 @@ import CopyMultilineValue from '../../atoms/CopyMultilineValue'
 import StatusImage from '../../atoms/StatusImage'
 
 import type { Endpoint } from '../../../@types/Endpoint'
-import type { MainItem } from '../../../@types/MainItem'
 import StyleProps from '../../styleUtils/StyleProps'
 import Palette from '../../styleUtils/Palette'
 import DateUtils from '../../../utils/DateUtils'
 import LabelDictionary from '../../../utils/LabelDictionary'
 import configLoader from '../../../utils/Config'
 import { Region } from '../../../@types/Region'
+import { MigrationItem, ReplicaItem, TransferItem } from '../../../@types/MainItem'
 
 const Wrapper = styled.div<any>`
   ${StyleProps.exactWidth(StyleProps.contentWidth)}
@@ -81,7 +81,7 @@ type Props = {
   regions: Region[],
   connectionInfo: Endpoint['connection_info'] | null,
   loading: boolean,
-  usage: { migrations: MainItem[], replicas: MainItem[] },
+  usage: { migrations: MigrationItem[], replicas: ReplicaItem[] },
   onDeleteClick: () => void,
   onValidateClick: () => void,
 }
@@ -174,7 +174,7 @@ class EndpointDetailsContent extends React.Component<Props> {
     )
   }
 
-  renderUsage(items: MainItem[]) {
+  renderUsage(items: TransferItem[]) {
     return items.map(item => (
       <span>
         <LinkStyled
@@ -193,7 +193,8 @@ class EndpointDetailsContent extends React.Component<Props> {
     const {
       type, name, description, created_at, id,
     } = this.props.item || {}
-    const usage = this.props.usage.replicas.concat(this.props.usage.migrations)
+    const usage: TransferItem[] = this.props.usage.replicas
+      .concat(this.props.usage.migrations as any[])
 
     return (
       <Wrapper>

+ 55 - 31
src/components/organisms/Executions/Executions.tsx

@@ -23,8 +23,7 @@ import Button from '../../atoms/Button'
 import Timeline from '../../molecules/Timeline'
 import Tasks from '../Tasks'
 
-import type { MainItem } from '../../../@types/MainItem'
-import type { Execution } from '../../../@types/Execution'
+import type { Execution, ExecutionTasks } from '../../../@types/Execution'
 import Palette from '../../styleUtils/Palette'
 import DateUtils from '../../../utils/DateUtils'
 
@@ -85,8 +84,11 @@ const NoExecutionText = styled.div<any>`
 `
 
 type Props = {
-  item?: MainItem | null,
+  executions: Execution[],
+  executionsTasks: ExecutionTasks[],
   loading: boolean,
+  tasksLoading: boolean,
+  onChange: (executionId: string) => void,
   onCancelExecutionClick: (execution: Execution | null, force?: boolean) => void,
   onDeleteExecutionClick: (execution: Execution | null) => void,
   onExecuteClick: () => void,
@@ -110,27 +112,27 @@ class Executions extends React.Component<Props, State> {
 
   setSelectedExecution(props: Props) {
     const lastExecution = this.getLastExecution(props)
-    let selectExecution = null
+    let selectExecution: Execution | null | undefined = null
 
-    if (props.item && props.item.executions && this.props.item && this.props.item.executions) {
-      if (this.props.item.executions.length !== props.item.executions.length
+    if (props.executions && this.props.executions) {
+      if (this.props.executions.length !== props.executions.length
         && lastExecution && lastExecution.status === 'RUNNING') {
         selectExecution = lastExecution
       }
 
-      if (this.props.item.executions.length > props.item.executions.length) {
-        const isSelectedAvailable = props.item.executions.find(e => this.state.selectedExecution
+      if (this.props.executions.length > props.executions.length) {
+        const isSelectedAvailable = props.executions.find(e => this.state.selectedExecution
           && e.id === this.state.selectedExecution.id)
         if (!isSelectedAvailable) {
-          const lastIndex = this.props.item && this.props.item.executions
-            ? this.props.item.executions
+          const lastIndex = this.props.executions
+            ? this.props.executions
               .findIndex(e => this.state.selectedExecution
               && e.id === this.state.selectedExecution.id) : -1
-          if (props.item && props.item.executions.length) {
-            if (props.item.executions.length - 1 >= lastIndex) {
-              selectExecution = props.item.executions[lastIndex]
+          if (props.executions.length) {
+            if (props.executions.length - 1 >= lastIndex) {
+              selectExecution = props.executions[lastIndex]
             } else {
-              selectExecution = props.item.executions[lastIndex - 1]
+              selectExecution = props.executions[lastIndex - 1]
             }
           }
         }
@@ -140,16 +142,22 @@ class Executions extends React.Component<Props, State> {
     if (!currentSelectedExecution) {
       this.setState({
         selectedExecution: lastExecution || null,
+      }, () => {
+        this.handleChange(lastExecution)
       })
     } else if (selectExecution) {
       this.setState({
         selectedExecution: selectExecution,
+      }, () => {
+        this.handleChange(selectExecution)
       })
     } else if (this.hasExecutions(props)) {
-      selectExecution = (props.item && props.item.executions
+      selectExecution = (props.executions
         .find(e => e.id === currentSelectedExecution.id)) || lastExecution
       this.setState({
         selectedExecution: selectExecution || null,
+      }, () => {
+        this.handleChange(selectExecution)
       })
     } else {
       this.setState({ selectedExecution: null })
@@ -157,23 +165,31 @@ class Executions extends React.Component<Props, State> {
   }
 
   getLastExecution(props: Props) {
-    if (this.hasExecutions(props) && props.item) {
-      return props.item.executions[props.item.executions.length - 1]
+    if (this.hasExecutions(props)) {
+      return props.executions[props.executions.length - 1]
     }
     return null
   }
 
   hasExecutions(props: Props) {
-    return Boolean(props.item && props.item.executions && props.item.executions.length)
+    return Boolean(props.executions.length)
+  }
+
+  handleChange(execution?: Execution | null) {
+    if (!execution) {
+      return
+    }
+
+    this.props.onChange(execution.id)
   }
 
   handlePreviousExecutionClick() {
     const currentSelectedExecution = this.state.selectedExecution
-    if (!this.props.item || !currentSelectedExecution) {
+    if (!this.props.executions.length || !currentSelectedExecution) {
       return
     }
 
-    const selectedIndex = this.props.item
+    const selectedIndex = this.props
       .executions.findIndex(e => e.id === currentSelectedExecution.id)
 
     if (selectedIndex === 0) {
@@ -181,27 +197,33 @@ class Executions extends React.Component<Props, State> {
     }
 
     this.setState({
-      selectedExecution: this.props.item ? this.props.item.executions[selectedIndex - 1] : null,
+      selectedExecution: this.props.executions[selectedIndex - 1],
+    }, () => {
+      this.handleChange(this.props.executions[selectedIndex - 1])
     })
   }
 
   handleNextExecutionClick() {
     const currentSelectedExecution = this.state.selectedExecution
-    if (!this.props.item || !currentSelectedExecution) {
+    if (!this.props.executions.length || !currentSelectedExecution) {
       return
     }
-    const selectedIndex = this.props.item.executions
+    const selectedIndex = this.props.executions
       .findIndex(e => e.id === currentSelectedExecution.id)
 
-    if (!this.props.item || selectedIndex >= this.props.item.executions.length - 1) {
+    if (selectedIndex >= this.props.executions.length - 1) {
       return
     }
 
-    this.setState({ selectedExecution: this.props.item.executions[selectedIndex + 1] })
+    this.setState({ selectedExecution: this.props.executions[selectedIndex + 1] }, () => {
+      this.handleChange(this.props.executions[selectedIndex + 1])
+    })
   }
 
   handleTimelineItemClick(item: Execution) {
-    this.setState({ selectedExecution: item })
+    this.setState({ selectedExecution: item }, () => {
+      this.handleChange(item)
+    })
   }
 
   handleCancelExecutionClick() {
@@ -232,7 +254,7 @@ class Executions extends React.Component<Props, State> {
 
     return (
       <Timeline
-        items={this.props.item ? this.props.item.executions : null}
+        items={this.props.executions}
         selectedItem={this.state.selectedExecution}
         onPreviousClick={() => { this.handlePreviousExecutionClick() }}
         onNextClick={() => { this.handleNextExecutionClick() }}
@@ -305,14 +327,16 @@ class Executions extends React.Component<Props, State> {
   }
 
   renderTasks() {
-    if (!this.state.selectedExecution || !this.state.selectedExecution.tasks
-      || !this.state.selectedExecution.tasks.length
-      || this.props.loading) {
+    if (this.props.loading || this.props.executions.length === 0) {
       return null
     }
 
     return (
-      <Tasks items={this.state.selectedExecution.tasks} />
+      <Tasks
+        loading={this.props.tasksLoading}
+        items={this.props.executionsTasks
+          .find(e => e.id === this.state.selectedExecution?.id)?.tasks || []}
+      />
     )
   }
 

+ 2 - 4
src/components/organisms/FilterList/FilterList.tsx

@@ -22,8 +22,6 @@ import type { Action as DropdownAction } from '../../molecules/ActionDropdown'
 import type { ItemComponentProps } from '../MainList'
 import MainList from '../MainList'
 
-import type { MainItem } from '../../../@types/MainItem'
-
 import configLoader from '../../../utils/Config'
 
 const Wrapper = styled.div<any>`
@@ -135,7 +133,7 @@ class FilterList extends React.Component<Props, State> {
     })
   }
 
-  handleItemSelectedChange(item: MainItem, selected: boolean) {
+  handleItemSelectedChange(item: any, selected: boolean) {
     const items = this.state.selectedItems.slice(0)
     const selectedItems = items.filter(i => item.id !== i.id) || []
 
@@ -159,7 +157,7 @@ class FilterList extends React.Component<Props, State> {
     })
   }
 
-  filterItems(items: MainItem[], filterStatus?: string | null, filterText?: string): MainItem[] {
+  filterItems(items: any[], filterStatus?: string | null, filterText?: string): any[] {
     const newFilterStatus = filterStatus || this.state.filterStatus
     const newFilterText = typeof filterText === 'undefined' ? this.state.filterText : filterText
     const filteredItems = items

+ 6 - 20
src/components/organisms/MainDetails/MainDetails.tsx

@@ -26,7 +26,6 @@ import CopyMultilineValue from '../../atoms/CopyMultilineValue'
 import PasswordValue from '../../atoms/PasswordValue'
 
 import type { Instance } from '../../../@types/Instance'
-import type { MainItem } from '../../../@types/MainItem'
 import type { Endpoint } from '../../../@types/Endpoint'
 import type { Network } from '../../../@types/Network'
 import type { Field as FieldType } from '../../../@types/Field'
@@ -39,6 +38,7 @@ import LabelDictionary from '../../../utils/LabelDictionary'
 import { OptionsSchemaPlugin } from '../../../plugins/endpoint'
 
 import arrowImage from './images/arrow.svg'
+import { TransferItem } from '../../../@types/MainItem'
 
 const Wrapper = styled.div<any>`
   display: flex;
@@ -120,7 +120,7 @@ const PropertyValue = styled.div<any>`
 `
 
 type Props = {
-  item?: MainItem | null,
+  item?: TransferItem | null,
   destinationSchema: FieldType[],
   destinationSchemaLoading: boolean,
   sourceSchema: FieldType[],
@@ -153,14 +153,6 @@ class MainDetails extends React.Component<Props, State> {
     return endpoint
   }
 
-  getLastExecution() {
-    if (this.props.item?.executions && this.props.item.executions.length) {
-      return this.props.item.executions[this.props.item.executions.length - 1]
-    }
-
-    return null
-  }
-
   getConnectedVms(networkId: string) {
     if (this.props.instancesDetailsLoading) {
       return 'Loading...'
@@ -185,13 +177,7 @@ class MainDetails extends React.Component<Props, State> {
   }
 
   renderLastExecutionTime() {
-    const lastExecution = this.getLastExecution()
-    const lastUpdate = lastExecution?.updated_at || lastExecution?.created_at
-    if (lastUpdate) {
-      return this.renderValue(DateUtils.getLocalTime(lastUpdate).format('YYYY-MM-DD HH:mm:ss'))
-    }
-
-    return null
+    return this.props.item ? this.renderValue(DateUtils.getLocalTime(this.props.item.updated_at).format('YYYY-MM-DD HH:mm:ss')) : '-'
   }
 
   renderValue(value: string, dateTestId?: string) {
@@ -208,7 +194,7 @@ class MainDetails extends React.Component<Props, State> {
     const endpoint = type === 'source' ? this.getSourceEndpoint() : this.getDestinationEndpoint()
 
     if (endpoint) {
-      return <ValueLink data-test-id={`mainDetails-name-${type}`} to={`/endpoint/${endpoint.id}`}>{endpoint.name}</ValueLink>
+      return <ValueLink to={`/endpoints/${endpoint.id}`}>{endpoint.name}</ValueLink>
     }
 
     return endpointIsMissing
@@ -353,11 +339,11 @@ class MainDetails extends React.Component<Props, State> {
               </Field>
             </Row>
           ) : null}
-          {this.props.item && this.props.item.replica_id ? (
+          {this.props.item?.type === 'migration' && this.props.item.replica_id ? (
             <Row>
               <Field>
                 <Label>Created from Replica</Label>
-                <ValueLink to={`/replica/${this.props.item.replica_id}`}>{this.props.item.replica_id}</ValueLink>
+                <ValueLink to={`/replicas/${this.props.item.replica_id}`}>{this.props.item.replica_id}</ValueLink>
               </Field>
             </Row>
           ) : null}

+ 3 - 4
src/components/organisms/MainList/MainList.tsx

@@ -19,7 +19,6 @@ import styled from 'styled-components'
 import StatusImage from '../../atoms/StatusImage'
 import Button from '../../atoms/Button'
 
-import type { MainItem } from '../../../@types/MainItem'
 import Palette from '../../styleUtils/Palette'
 
 const Wrapper = styled.div<any>`
@@ -28,7 +27,7 @@ const Wrapper = styled.div<any>`
   display: flex;
   flex-direction: column;
   flex-grow: 1;
-  min-width: 785px;
+  min-width: 900px;
 `
 const Separator = styled.div<any>`
   height: 1px;
@@ -89,8 +88,8 @@ type Props = {
   items: any[],
   selectedItems: any[],
   loading: boolean,
-  onSelectedChange: (item: MainItem, checked: boolean) => void,
-  onItemClick: (item: MainItem) => void,
+  onSelectedChange: (item: any, checked: boolean) => void,
+  onItemClick: (item: any) => void,
   renderItemComponent: (componentProps: ItemComponentProps) => React.ReactNode,
   showEmptyList: boolean,
   emptyListImage?: string | null,

+ 3 - 3
src/components/organisms/MigrationDetailsContent/MigrationDetailsContent.tsx

@@ -23,9 +23,9 @@ import Tasks from '../Tasks'
 import StyleProps from '../../styleUtils/StyleProps'
 
 import type { Instance } from '../../../@types/Instance'
-import type { MainItem } from '../../../@types/MainItem'
 import type { Endpoint } from '../../../@types/Endpoint'
 import type { Field } from '../../../@types/Field'
+import { MigrationItemDetails } from '../../../@types/MainItem'
 
 const Wrapper = styled.div<any>`
   display: flex;
@@ -53,7 +53,7 @@ const NavigationItems = [
 ]
 
 type Props = {
-  item: MainItem | null,
+  item: MigrationItemDetails | null,
   detailsLoading: boolean,
   instancesDetails: Instance[],
   instancesDetailsLoading: boolean,
@@ -110,7 +110,7 @@ class MigrationDetailsContent extends React.Component<Props> {
     return (
       <Tasks
         items={this.props.item.tasks}
-        data-test-id="mdContent-tasks"
+        loading={this.props.detailsLoading}
       />
     )
   }

+ 1 - 1
src/components/organisms/ProjectDetailsContent/ProjectDetailsContent.tsx

@@ -241,7 +241,7 @@ class ProjectDetailsContent extends React.Component<Props, State> {
         <UserName
           data-test-id={`pdContent-users-${user.name}`}
           disabled={!user.enabled}
-          to={`/user/${user.id}`}
+          to={`/users/${user.id}`}
         >{user.name}
         </UserName>,
         <DropdownLink

+ 11 - 5
src/components/organisms/ReplicaDetailsContent/ReplicaDetailsContent.tsx

@@ -23,13 +23,13 @@ import MainDetails from '../MainDetails'
 import Executions from '../Executions'
 import Schedule from '../Schedule'
 import type { Instance } from '../../../@types/Instance'
-import type { MainItem } from '../../../@types/MainItem'
 import type { Endpoint } from '../../../@types/Endpoint'
-import type { Execution } from '../../../@types/Execution'
+import type { Execution, ExecutionTasks } from '../../../@types/Execution'
 import type { Network } from '../../../@types/Network'
 import type { Field } from '../../../@types/Field'
 import type { Schedule as ScheduleType } from '../../../@types/Schedule'
 import StyleProps from '../../styleUtils/StyleProps'
+import { ReplicaItemDetails } from '../../../@types/MainItem'
 
 const Wrapper = styled.div<any>`
   display: flex;
@@ -70,7 +70,7 @@ const NavigationItems = [
 
 type TimezoneValue = 'utc' | 'local'
 type Props = {
-  item?: MainItem | null,
+  item?: ReplicaItemDetails | null,
   endpoints: Endpoint[],
   sourceSchema: Field[],
   sourceSchemaLoading: boolean,
@@ -82,7 +82,11 @@ type Props = {
   scheduleStore: typeof scheduleStore,
   page: string,
   detailsLoading: boolean,
+  executions: Execution[],
   executionsLoading: boolean,
+  executionsTasksLoading: boolean,
+  executionsTasks: ExecutionTasks[],
+  onExecutionChange: (executionId: string) => void,
   onCancelExecutionClick: (execution: Execution | null, force?: boolean) => void,
   onDeleteExecutionClick: (execution: Execution | null) => void,
   onExecuteClick: () => void,
@@ -186,12 +190,14 @@ class ReplicaDetailsContent extends React.Component<Props, State> {
 
     return (
       <Executions
-        item={this.props.item}
+        executions={this.props.executions}
+        executionsTasks={this.props.executionsTasks}
         onCancelExecutionClick={this.props.onCancelExecutionClick}
         onDeleteExecutionClick={this.props.onDeleteExecutionClick}
         onExecuteClick={this.props.onExecuteClick}
         loading={this.props.executionsLoading}
-        data-test-id="rdContent-executions"
+        onChange={this.props.onExecutionChange}
+        tasksLoading={this.props.executionsTasksLoading}
       />
     )
   }

+ 33 - 3
src/components/organisms/Tasks/Tasks.tsx

@@ -21,12 +21,20 @@ import TaskItem from '../../molecules/TaskItem'
 import type { Task } from '../../../@types/Task'
 import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
+import StatusImage from '../../atoms/StatusImage/StatusImage'
 
 const ColumnWidths = ['26%', '18%', '36%', '20%']
 
-const Wrapper = styled.div<any>`
+const Wrapper = styled.div<any>``
+const ContentWrapper = styled.div`
   background: ${Palette.grayscale[1]};
 `
+const LoadingWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 64px;
+`
 const Header = styled.div<any>`
   display: flex;
   border-bottom: 1px solid ${Palette.grayscale[5]};
@@ -43,6 +51,7 @@ const Body = styled.div<any>``
 
 type Props = {
   items: Task[],
+  loading?: boolean,
 }
 type State = {
   openedItems: Task[],
@@ -80,6 +89,10 @@ class Tasks extends React.Component<Props, State> {
     })
   }
 
+  get isLoading() {
+    return this.props.loading || this.props.items.length === 0
+  }
+
   handleItemMouseDown(e: React.MouseEvent<HTMLDivElement>) {
     this.dragStartPosition = { x: e.screenX, y: e.screenY }
   }
@@ -115,6 +128,14 @@ class Tasks extends React.Component<Props, State> {
     })
   }
 
+  renderLoading() {
+    return (
+      <LoadingWrapper>
+        <StatusImage loading />
+      </LoadingWrapper>
+    )
+  }
+
   renderHeader() {
     return (
       <Header>
@@ -145,11 +166,20 @@ class Tasks extends React.Component<Props, State> {
     )
   }
 
-  render() {
+  renderContent() {
     return (
-      <Wrapper>
+      <ContentWrapper>
         {this.renderHeader()}
         {this.renderBody()}
+      </ContentWrapper>
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        {!this.isLoading ? this.renderContent() : null}
+        {this.isLoading ? this.renderLoading() : null}
       </Wrapper>
     )
   }

+ 1 - 1
src/components/organisms/Tasks/story.tsx

@@ -80,5 +80,5 @@ const items: any = [
 
 storiesOf('Tasks', module)
   .add('default', () => (
-    <div style={{ width: '800px' }}><Tasks items={items} /></div>
+    <div style={{ width: '800px' }}><Tasks items={items} loading={false} /></div>
   ))

+ 1 - 1
src/components/organisms/UserDetailsContent/UserDetailsContent.tsx

@@ -135,7 +135,7 @@ class UserDetailsContent extends React.Component<Props> {
     return projects.map((project, i) => (
       <span key={project.id}>
         {project.label ? (
-          <LinkStyled data-test-id={`${TEST_ID}-project-${project.id}`} to={`/project/${project.id}`}>
+          <LinkStyled to={`/projects/${project.id}`}>
             {project.label}
           </LinkStyled>
         ) : project.id}

+ 3 - 7
src/components/pages/AssessmentDetailsPage/AssessmentDetailsPage.tsx

@@ -514,14 +514,10 @@ class AssessmentDetailsPage extends React.Component<Props, State> {
 )}
           contentHeaderComponent={(
             <DetailsContentHeader
-              item={
-              {
-                ...details,
-                type: 'Azure Migrate',
-                status,
-              }
-            }
+              statusPill={status}
               statusLabel={statusLabel}
+              itemTitle={details?.name}
+              itemType="Azure Migrate"
               backLink="/planning"
               typeImage={assessmentImage}
             />

+ 6 - 4
src/components/pages/EndpointDetailsPage/EndpointDetailsPage.tsx

@@ -33,12 +33,12 @@ import userStore from '../../../stores/UserStore'
 import projectStore from '../../../stores/ProjectStore'
 
 import type { Endpoint as EndpointType } from '../../../@types/Endpoint'
-import type { MainItem } from '../../../@types/MainItem'
 
 import Palette from '../../styleUtils/Palette'
 
 import endpointImage from './images/endpoint.svg'
 import regionStore from '../../../stores/RegionStore'
+import { MigrationItem, ReplicaItem } from '../../../@types/MainItem'
 
 const Wrapper = styled.div<any>``
 
@@ -52,7 +52,7 @@ type State = {
   showEndpointModal: boolean,
   showEndpointInUseModal: boolean,
   showEndpointInUseLoadingModal: boolean,
-  endpointUsage: { replicas: MainItem[], migrations: MainItem[] },
+  endpointUsage: { replicas: ReplicaItem[], migrations: MigrationItem[] },
   showDuplicateModal: boolean,
   duplicating: boolean,
 }
@@ -83,7 +83,7 @@ class EndpointDetailsPage extends React.Component<Props, State> {
     return endpointStore.endpoints.find(e => e.id === this.props.match.params.id) || null
   }
 
-  getEndpointUsage(): { migrations: MainItem[], replicas: MainItem[] } {
+  getEndpointUsage(): { migrations: MigrationItem[], replicas: ReplicaItem[] } {
     const endpointId = this.props.match.params.id
     const replicas = replicaStore.replicas.filter(
       r => r.origin_endpoint_id === endpointId || r.destination_endpoint_id === endpointId,
@@ -246,7 +246,9 @@ class EndpointDetailsPage extends React.Component<Props, State> {
 )}
           contentHeaderComponent={(
             <DetailsContentHeader
-              item={endpoint}
+              itemTitle={endpoint?.name}
+              itemType="endpoint"
+              itemDescription={endpoint?.description}
               backLink="/endpoints"
               dropdownActions={dropdownActions}
               typeImage={endpointImage}

+ 1 - 1
src/components/pages/EndpointsPage/EndpointsPage.tsx

@@ -127,7 +127,7 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
   }
 
   handleItemClick(item: EndpointType) {
-    this.props.history.push(`/endpoint/${item.id}`)
+    this.props.history.push(`/endpoints/${item.id}`)
   }
 
   async duplicate(projectId: string) {

+ 19 - 4
src/components/pages/MigrationDetailsPage/MigrationDetailsPage.tsx

@@ -135,7 +135,7 @@ class MigrationDetailsPage extends React.Component<Props, State> {
   }
 
   getStatus() {
-    return migrationStore.migrationDetails && migrationStore.migrationDetails.status
+    return migrationStore.migrationDetails?.last_execution_status
   }
 
   async loadMigrationWithInstances(migrationId: string, cache: boolean) {
@@ -237,7 +237,7 @@ class MigrationDetailsPage extends React.Component<Props, State> {
 
   async migrate(replicaId: string, options: Field[], userScripts: InstanceScript[]) {
     const migration = await migrationStore.migrateReplica(replicaId, options, userScripts)
-    this.props.history.push(`/migration/tasks/${migration.id}`)
+    this.props.history.push(`/migrations/${migration.id}/tasks`)
   }
 
   async pollData() {
@@ -319,7 +319,19 @@ class MigrationDetailsPage extends React.Component<Props, State> {
         action: () => { this.handleDeleteMigrationClick() },
       },
     ]
-
+    let title = null
+    const migration = migrationStore.migrationDetails
+    if (migration) {
+      const { instances } = migration
+      if (instances) {
+        title = instances[0]
+        if (instances.length > 1) {
+          title += ` (+${instances.length - 1} more)`
+        }
+      } else {
+        title = migration.name
+      }
+    }
     return (
       <Wrapper>
         <DetailsTemplate
@@ -331,7 +343,10 @@ class MigrationDetailsPage extends React.Component<Props, State> {
 )}
           contentHeaderComponent={(
             <DetailsContentHeader
-              item={migrationStore.migrationDetails}
+              statusPill={migrationStore.migrationDetails?.last_execution_status}
+              itemTitle={title}
+              itemType="migration"
+              itemDescription={migrationStore.migrationDetails?.description}
               backLink="/migrations"
               typeImage={migrationImage}
               dropdownActions={dropdownActions}

+ 20 - 12
src/components/pages/MigrationsPage/MigrationsPage.tsx

@@ -22,7 +22,6 @@ import FilterList from '../../organisms/FilterList'
 import PageHeader from '../../organisms/PageHeader'
 import AlertModal from '../../organisms/AlertModal'
 import MainListItem from '../../molecules/MainListItem'
-import type { MainItem } from '../../../@types/MainItem'
 
 import migrationItemImage from './images/migration.svg'
 import migrationLargeImage from './images/migration-large.svg'
@@ -35,11 +34,13 @@ import configLoader from '../../../utils/Config'
 
 import Palette from '../../styleUtils/Palette'
 import replicaMigrationFields from '../../organisms/ReplicaMigrationOptions/replicaMigrationFields'
+import { MigrationItem } from '../../../@types/MainItem'
+import userStore from '../../../stores/UserStore'
 
 const Wrapper = styled.div<any>``
 
 type State = {
-  selectedMigrations: MainItem[],
+  selectedMigrations: MigrationItem[],
   modalIsOpen: boolean,
   showDeleteMigrationModal: boolean,
   showCancelMigrationModal: boolean,
@@ -64,6 +65,10 @@ class MigrationsPage extends React.Component<{ history: any }, State> {
 
     projectStore.getProjects()
     endpointStore.getEndpoints({ showLoading: true })
+    userStore.getAllUsers({
+      showLoading: userStore.users.length === 0,
+      quietError: true,
+    })
 
     this.stopPolling = false
     this.pollData()
@@ -90,7 +95,7 @@ class MigrationsPage extends React.Component<{ history: any }, State> {
 
   getStatus(migrationId: string): string {
     const migration = migrationStore.migrations.find(m => m.id === migrationId)
-    return migration ? migration.status : ''
+    return migration ? migration.last_execution_status : ''
   }
 
   handleProjectChange() {
@@ -102,13 +107,14 @@ class MigrationsPage extends React.Component<{ history: any }, State> {
     projectStore.getProjects()
     endpointStore.getEndpoints({ showLoading: true })
     migrationStore.getMigrations({ showLoading: true })
+    userStore.getAllUsers({ showLoading: true, quietError: true })
   }
 
-  handleItemClick(item: MainItem) {
-    if (item.status === 'RUNNING') {
-      this.props.history.push(`/migration/tasks/${item.id}`)
+  handleItemClick(item: MigrationItem) {
+    if (item.last_execution_status === 'RUNNING') {
+      this.props.history.push(`/migrations/${item.id}/tasks`)
     } else {
-      this.props.history.push(`/migration/${item.id}`)
+      this.props.history.push(`/migrations/${item.id}`)
     }
   }
 
@@ -137,7 +143,7 @@ class MigrationsPage extends React.Component<{ history: any }, State> {
       if (migration.replica_id) {
         await migrationStore.migrateReplica(migration.replica_id, replicaMigrationFields, [])
       } else {
-        await migrationStore.recreateFullCopy(migration)
+        await migrationStore.recreateFullCopy(migration as any)
       }
     }))
 
@@ -158,7 +164,7 @@ class MigrationsPage extends React.Component<{ history: any }, State> {
     })
   }
 
-  searchText(item: MainItem, text?: string) {
+  searchText(item: MigrationItem, text?: string) {
     let result = false
     if (item.instances[0].toLowerCase().indexOf(text || '') > -1) {
       return true
@@ -175,8 +181,8 @@ class MigrationsPage extends React.Component<{ history: any }, State> {
     return result
   }
 
-  itemFilterFunction(item: MainItem, filterStatus?: string | null, filterText?: string) {
-    if ((filterStatus !== 'all' && (item.status !== filterStatus))
+  itemFilterFunction(item: MigrationItem, filterStatus?: string | null, filterText?: string) {
+    if ((filterStatus !== 'all' && (item.last_execution_status !== filterStatus))
       || !this.searchText(item, filterText)
     ) {
       return false
@@ -193,6 +199,7 @@ class MigrationsPage extends React.Component<{ history: any }, State> {
     await Promise.all([
       migrationStore.getMigrations({ skipLog: true }),
       endpointStore.getEndpoints({ skipLog: true }),
+      userStore.getAllUsers({ skipLog: true, quietError: true }),
     ])
     this.pollTimeout = setTimeout(() => { this.pollData() }, configLoader.config.requestPollTimeout)
   }
@@ -252,7 +259,8 @@ class MigrationsPage extends React.Component<{ history: any }, State> {
                     }
                     return 'Not Found'
                   }}
-                  useTasksRemaining
+                  getUserName={id => userStore.users.find(u => u.id === id)?.name}
+                  userNameLoading={userStore.allUsersLoading}
                 />
               )}
               emptyListImage={migrationLargeImage}

+ 2 - 1
src/components/pages/ProjectDetailsPage/ProjectDetailsPage.tsx

@@ -193,7 +193,8 @@ class ProjectDetailsPage extends React.Component<Props, State> {
 )}
           contentHeaderComponent={(
             <DetailsContentHeader
-              item={{ ...projectStore.projectDetails, description: '' }}
+              itemTitle={projectStore.projectDetails?.name}
+              itemType="project"
               backLink="/projects"
               dropdownActions={dropdownActions}
               typeImage={projectImage}

+ 1 - 1
src/components/pages/ProjectsPage/ProjectsPage.tsx

@@ -118,7 +118,7 @@ class ProjectsPage extends React.Component<{ history: any }, State> {
               selectionLabel="user"
               loading={projectStore.loading}
               items={projectStore.projects}
-              onItemClick={(user: Project) => { this.props.history.push(`project/${user.id}`) }}
+              onItemClick={(user: Project) => { this.props.history.push(`/projects/${user.id}`) }}
               onReloadButtonClick={() => { this.handleReloadButtonClick() }}
               itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
               renderItemComponent={component => (

+ 64 - 28
src/components/pages/ReplicaDetailsPage/ReplicaDetailsPage.tsx

@@ -27,7 +27,6 @@ import EditReplica from '../../organisms/EditReplica'
 import ReplicaMigrationOptions from '../../organisms/ReplicaMigrationOptions'
 import DeleteReplicaModal from '../../molecules/DeleteReplicaModal'
 
-import type { MainItem } from '../../../@types/MainItem'
 import type { InstanceScript } from '../../../@types/Instance'
 import type { Execution } from '../../../@types/Execution'
 import type { Schedule } from '../../../@types/Schedule'
@@ -45,11 +44,12 @@ import notificationStore from '../../../stores/NotificationStore'
 import providerStore from '../../../stores/ProviderStore'
 
 import configLoader from '../../../utils/Config'
-import utils from '../../../utils/ObjectUtils'
 import { providerTypes } from '../../../constants'
 
 import replicaImage from './images/replica.svg'
 import Palette from '../../styleUtils/Palette'
+import { ReplicaItemDetails } from '../../../@types/MainItem'
+import ObjectUtils from '../../../utils/ObjectUtils'
 
 const Wrapper = styled.div<any>``
 
@@ -65,7 +65,7 @@ type State = {
   showForceCancelConfirmation: boolean,
   showDeleteReplicaConfirmation: boolean,
   showDeleteReplicaDisksConfirmation: boolean,
-  confirmationItem: MainItem | null | Execution | null,
+  confirmationItem?: ReplicaItemDetails | null | Execution | null,
   showCancelConfirmation: boolean,
   isEditable: boolean,
   pausePolling: boolean,
@@ -88,12 +88,12 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
 
   stopPolling: boolean | null = null
 
-  UNSAFE_componentWillMount() {
+  componentDidMount() {
     document.title = 'Replica Details'
 
     const loadReplica = async () => {
       await endpointStore.getEndpoints({ showLoading: true })
-      const replica = await this.loadReplicaWithInstances(this.replicaId, true)
+      const replica = await this.loadReplicaWithInstances(true)
       if (!replica) {
         return
       }
@@ -141,7 +141,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
 
   UNSAFE_componentWillReceiveProps(newProps: Props) {
     if (newProps.match.params.id !== this.props.match.params.id) {
-      this.loadReplicaWithInstances(newProps.match.params.id, true)
+      this.loadReplicaWithInstances(true, newProps.match.params.id)
       scheduleStore.getSchedules(newProps.match.params.id)
     }
   }
@@ -159,8 +159,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
   }
 
   get replica() {
-    const replica = replicaStore.replicas.find(r => r.id === this.replicaId)
-    return replica
+    return replicaStore.replicaDetails
   }
 
   getLastExecution() {
@@ -176,11 +175,11 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     return lastExecution && lastExecution.status
   }
 
-  async loadIsEditable(replicaDetails: MainItem) {
+  async loadIsEditable(replicaDetails: ReplicaItemDetails) {
     const targetEndpointId = replicaDetails.destination_endpoint_id
     const sourceEndpointId = replicaDetails.origin_endpoint_id
     await providerStore.loadProviders()
-    await utils.waitFor(() => endpointStore.endpoints.length > 0)
+    await ObjectUtils.waitFor(() => endpointStore.endpoints.length > 0)
     const sourceEndpoint = endpointStore.endpoints.find(e => e.id === sourceEndpointId)
     const targetEndpoint = endpointStore.endpoints.find(e => e.id === targetEndpointId)
     if (!sourceEndpoint || !targetEndpoint || !providerStore.providers) {
@@ -196,8 +195,8 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     this.setState({ isEditable })
   }
 
-  async loadReplicaWithInstances(_: string, cache: boolean) {
-    await replicaStore.getReplicas({ showLoading: true })
+  async loadReplicaWithInstances(cache: boolean, replicaId?: string) {
+    await replicaStore.getReplicaDetails({ replicaId: replicaId || this.replicaId })
     const replica = this.replica
     if (!replica) {
       return null
@@ -259,7 +258,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     this.handleCloseExecutionConfirmation()
   }
 
-  handleDeleteExecutionClick(execution: Execution | null) {
+  handleDeleteExecutionClick(execution?: Execution | null) {
     this.setState({
       showDeleteExecutionConfirmation: true,
       confirmationItem: execution,
@@ -304,7 +303,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
       return
     }
     replicaStore.deleteDisks(replica.id)
-    this.props.history.push(`/replica/executions/${replica.id}`)
+    this.props.history.push(`/replicas/${replica.id}/executions`)
   }
 
   handleCloseDeleteReplicaDisksConfirmation() {
@@ -354,7 +353,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     this.handleCancelExecution(this.getLastExecution(), force)
   }
 
-  handleCancelExecution(confirmationItem: Execution | null, force?: boolean | null) {
+  handleCancelExecution(confirmationItem?: Execution | null, force?: boolean | null) {
     if (force) {
       this.setState({ confirmationItem, showForceCancelConfirmation: true })
     } else {
@@ -374,11 +373,11 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     if (!this.state.confirmationItem || !replica) {
       return
     }
-    replicaStore.cancelExecution(
-      replica.id,
-      this.state.confirmationItem.id,
+    replicaStore.cancelExecution({
+      replicaId: replica.id,
+      executionId: this.state.confirmationItem.id,
       force,
-    )
+    })
     this.setState({
       showForceCancelConfirmation: false,
       showCancelConfirmation: false,
@@ -404,7 +403,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
       action: {
         label: 'View Migration Status',
         callback: () => {
-          this.props.history.push(`/migration/tasks/${migration.id}`)
+          this.props.history.push(`/migrations/${migration.id}/tasks/`)
         },
       },
     })
@@ -417,7 +416,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     }
     replicaStore.execute(replica.id, fields)
     this.handleCloseOptionsModal()
-    this.props.history.push(`/replica/executions/${replica.id}`)
+    this.props.history.push(`/replicas/${replica.id}/executions`)
   }
 
   async pollData(showLoading: boolean) {
@@ -425,7 +424,16 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
       return
     }
 
-    await replicaStore.getReplicas({ showLoading, skipLog: true })
+    await Promise.all([
+      replicaStore.getReplicaDetails({
+        replicaId: this.replicaId, showLoading, polling: true,
+      }),
+      (async () => {
+        if (window.location.pathname.indexOf('executions') > -1) {
+          await replicaStore.getExecutionTasks({ replicaId: this.replicaId, polling: true })
+        }
+      })(),
+    ])
 
     setTimeout(() => { this.pollData(false) }, configLoader.config.requestPollTimeout)
   }
@@ -437,7 +445,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
   }
 
   handleEditReplicaReload() {
-    this.loadReplicaWithInstances(this.replicaId, false)
+    this.loadReplicaWithInstances(false)
   }
 
   handleUpdateComplete(redirectTo: string) {
@@ -445,6 +453,14 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     this.closeEditModal()
   }
 
+  async handleExecutionChange(executionId: string) {
+    await ObjectUtils.waitFor(() => Boolean(replicaStore.replicaDetails))
+    if (!replicaStore.replicaDetails?.id) {
+      return
+    }
+    replicaStore.getExecutionTasks({ replicaId: replicaStore.replicaDetails.id, executionId })
+  }
+
   renderEditReplica() {
     const replica = this.replica
     if (!replica) {
@@ -516,7 +532,18 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
       },
     ]
     const replica = this.replica
-
+    let title = null
+    if (replica) {
+      const { instances } = replica
+      if (instances) {
+        title = instances[0]
+        if (instances.length > 1) {
+          title += ` (+${instances.length - 1} more)`
+        }
+      } else {
+        title = replica.name
+      }
+    }
     return (
       <Wrapper>
         <DetailsTemplate
@@ -528,7 +555,10 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
 )}
           contentHeaderComponent={(
             <DetailsContentHeader
-              item={replica}
+              statusPill={replica?.last_execution_status}
+              itemTitle={title}
+              itemType="replica"
+              itemDescription={replica?.description}
               dropdownActions={dropdownActions}
               backLink="/replicas"
               typeImage={replicaImage}
@@ -543,7 +573,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
               endpoints={endpointStore.endpoints}
               scheduleStore={scheduleStore}
               networks={networkStore.networks}
-              detailsLoading={replicaStore.loading || endpointStore.loading}
+              detailsLoading={replicaStore.replicaDetailsLoading || endpointStore.loading}
               sourceSchema={providerStore.sourceSchema}
               sourceSchemaLoading={providerStore.sourceSchemaLoading
               || providerStore.sourceOptionsPrimaryLoading
@@ -552,7 +582,13 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
               destinationSchemaLoading={providerStore.destinationSchemaLoading
               || providerStore.destinationOptionsPrimaryLoading
               || providerStore.destinationOptionsSecondaryLoading}
-              executionsLoading={replicaStore.startingExecution}
+              executionsLoading={replicaStore.startingExecution
+                || replicaStore.replicaDetailsLoading}
+              onExecutionChange={id => { this.handleExecutionChange(id) }}
+              executions={replicaStore.replicaDetails?.executions || []}
+              executionsTasksLoading={replicaStore.executionsTasksLoading
+                || replicaStore.replicaDetailsLoading || replicaStore.startingExecution}
+              executionsTasks={replicaStore.executionsTasks}
               page={this.props.match.params.page || ''}
               onCancelExecutionClick={(e, f) => { this.handleCancelExecution(e, f) }}
               onDeleteExecutionClick={execution => { this.handleDeleteExecutionClick(execution) }}
@@ -602,7 +638,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
         />
         {this.state.showDeleteReplicaConfirmation ? (
           <DeleteReplicaModal
-            hasDisks={replicaStore.hasReplicaDisks(this.replica)}
+            hasDisks={replicaStore.testReplicaHasDisks(this.replica)}
             onRequestClose={() => this.handleCloseDeleteReplicaConfirmation()}
             onDeleteReplica={() => { this.handleDeleteReplicaConfirmation() }}
             onDeleteDisks={() => { this.handleDeleteReplicaDisksConfirmation() }}

+ 35 - 39
src/components/pages/ReplicasPage/ReplicasPage.tsx

@@ -27,7 +27,6 @@ import ReplicaExecutionOptions from '../../organisms/ReplicaExecutionOptions'
 import ReplicaMigrationOptions from '../../organisms/ReplicaMigrationOptions'
 import DeleteReplicaModal from '../../molecules/DeleteReplicaModal'
 
-import type { MainItem } from '../../../@types/MainItem'
 import type { Action as DropdownAction } from '../../molecules/ActionDropdown'
 import type { Field } from '../../../@types/Field'
 import type { InstanceScript } from '../../../@types/Instance'
@@ -45,6 +44,8 @@ import notificationStore from '../../../stores/NotificationStore'
 
 import Palette from '../../styleUtils/Palette'
 import configLoader from '../../../utils/Config'
+import { ReplicaItem } from '../../../@types/MainItem'
+import userStore from '../../../stores/UserStore'
 
 const Wrapper = styled.div<any>``
 
@@ -52,7 +53,7 @@ const SCHEDULE_POLL_TIMEOUT = 10000
 
 type State = {
   modalIsOpen: boolean,
-  selectedReplicas: MainItem[],
+  selectedReplicas: ReplicaItem[],
   showCancelExecutionModal: boolean,
   showExecutionOptionsModal: boolean,
   showCreateMigrationsModal: boolean,
@@ -84,6 +85,10 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
 
     projectStore.getProjects()
     endpointStore.getEndpoints({ showLoading: true })
+    userStore.getAllUsers({
+      showLoading: userStore.users.length === 0,
+      quietError: true,
+    })
 
     this.stopPolling = false
     this.pollData()
@@ -108,22 +113,8 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
     ]
   }
 
-  getLastExecution(item: MainItem) {
-    const lastExecution = item.executions && item.executions.length
-      ? item.executions[item.executions.length - 1] : null
-
-    return lastExecution
-  }
-
-  getStatus(replica?: MainItem | null): string {
-    if (!replica) {
-      return ''
-    }
-    const usableReplica = replica
-    if (usableReplica.executions && usableReplica.executions.length) {
-      return usableReplica.executions[usableReplica.executions.length - 1].status
-    }
-    return ''
+  getStatus(replica?: ReplicaItem | null): string {
+    return replica?.last_execution_status || ''
   }
 
   handleProjectChange() {
@@ -135,14 +126,14 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
     projectStore.getProjects()
     replicaStore.getReplicas({ showLoading: true })
     endpointStore.getEndpoints({ showLoading: true })
+    userStore.getAllUsers({ showLoading: true, quietError: true })
   }
 
-  handleItemClick(item: MainItem) {
-    const lastExecution = this.getLastExecution(item)
-    if (lastExecution && lastExecution.status === 'RUNNING') {
-      this.props.history.push(`/replica/executions/${item.id}`)
+  handleItemClick(item: ReplicaItem) {
+    if (item.last_execution_status === 'RUNNING') {
+      this.props.history.push(`/replicas/${item.id}/executions`)
     } else {
-      this.props.history.push(`/replica/${item.id}`)
+      this.props.history.push(`/replicas/${item.id}`)
     }
   }
 
@@ -175,7 +166,12 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
     this.props.history.push('/migrations')
   }
 
-  deleteReplicasDisks(replicas: MainItem[]) {
+  handleShowDeleteReplicas() {
+    replicaStore.loadHaveReplicasDisks(this.state.selectedReplicas)
+    this.setState({ showDeleteReplicasModal: true })
+  }
+
+  deleteReplicasDisks(replicas: ReplicaItem[]) {
     replicas.forEach(replica => {
       replicaStore.deleteDisks(replica.id)
     })
@@ -186,16 +182,14 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
   cancelExecutions() {
     this.state.selectedReplicas.forEach(replica => {
       const actualReplica = replicaStore.replicas.find(r => r.id === replica.id)
-      const lastExecution = actualReplica
-        && actualReplica.executions[actualReplica.executions.length - 1]
-      if (actualReplica && lastExecution && lastExecution.status === 'RUNNING') {
-        replicaStore.cancelExecution(replica.id, lastExecution.id)
+      if (actualReplica?.last_execution_status === 'RUNNING') {
+        replicaStore.cancelExecution({ replicaId: replica.id })
       }
     })
     this.setState({ showCancelExecutionModal: false })
   }
 
-  isExecuteEnabled(replica?: MainItem | null): boolean {
+  isExecuteEnabled(replica?: ReplicaItem | null): boolean {
     if (!replica) {
       return false
     }
@@ -244,7 +238,9 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
     }
 
     await Promise.all([
-      replicaStore.getReplicas({ skipLog: true }), endpointStore.getEndpoints({ skipLog: true }),
+      replicaStore.getReplicas({ skipLog: true }),
+      endpointStore.getEndpoints({ skipLog: true }),
+      userStore.getAllUsers({ skipLog: true, quietError: true }),
     ])
     if (!this.schedulePolling) {
       this.pollSchedule()
@@ -263,7 +259,7 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
     }, SCHEDULE_POLL_TIMEOUT)
   }
 
-  searchText(item: MainItem, text?: string | null) {
+  searchText(item: ReplicaItem, text?: string | null) {
     let result = false
     if (item.instances[0].toLowerCase().indexOf(text || '') > -1) {
       return true
@@ -280,9 +276,8 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
     return result
   }
 
-  itemFilterFunction(item: MainItem, filterStatus?: string | null, filterText?: string) {
-    const lastExecution = this.getLastExecution(item)
-    if ((filterStatus !== 'all' && (!lastExecution || lastExecution.status !== filterStatus))
+  itemFilterFunction(item: ReplicaItem, filterStatus?: string | null, filterText?: string) {
+    if ((filterStatus !== 'all' && item.last_execution_status !== filterStatus)
       || !this.searchText(item, filterText)
     ) {
       return false
@@ -327,7 +322,7 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
     }, {
       label: 'Delete Replicas',
       color: Palette.alert,
-      action: () => { this.setState({ showDeleteReplicasModal: true }) },
+      action: () => { this.handleShowDeleteReplicas() },
     }]
 
     return (
@@ -360,6 +355,8 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
                     }
                     return 'Not Found'
                   }}
+                  getUserName={id => userStore.users.find(u => u.id === id)?.name}
+                  userNameLoading={userStore.allUsersLoading}
                 />
               )}
               emptyListImage={replicaLargeImage}
@@ -380,14 +377,13 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
         />
         {this.state.showDeleteReplicasModal ? (
           <DeleteReplicaModal
-            hasDisks={replicaStore.getReplicasWithDisks(this.state.selectedReplicas).length > 0}
             isMultiReplicaSelection
+            hasDisks={replicaStore.replicasWithDisks.length > 0}
+            loading={replicaStore.replicasWithDisksLoading}
             onRequestClose={() => { this.setState({ showDeleteReplicasModal: false }) }}
             onDeleteReplica={() => { this.deleteSelectedReplicas() }}
             onDeleteDisks={() => {
-              this.deleteReplicasDisks(
-                replicaStore.getReplicasWithDisks(this.state.selectedReplicas),
-              )
+              this.deleteReplicasDisks(replicaStore.replicasWithDisks)
             }}
           />
         ) : null}

+ 2 - 1
src/components/pages/UserDetailsPage/UserDetailsPage.tsx

@@ -134,7 +134,8 @@ class UserDetailsPage extends React.Component<Props, State> {
 )}
           contentHeaderComponent={(
             <DetailsContentHeader
-              item={{ ...userStore.userDetails, description: '' }}
+              itemTitle={userStore.userDetails?.name}
+              itemType="user"
               backLink="/users"
               typeImage={userImage}
               dropdownActions={dropdownActions}

+ 1 - 1
src/components/pages/UsersPage/UsersPage.tsx

@@ -121,7 +121,7 @@ class UsersPage extends React.Component<{ history: any }, State> {
               selectionLabel="user"
               loading={userStore.allUsersLoading}
               items={userStore.users}
-              onItemClick={(user: User) => { this.props.history.push(`/user/${user.id}`) }}
+              onItemClick={(user: User) => { this.props.history.push(`/users/${user.id}`) }}
               onReloadButtonClick={() => { this.handleReloadButtonClick() }}
               itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
               renderItemComponent={component => (

+ 11 - 8
src/components/pages/WizardPage/WizardPage.tsx

@@ -35,7 +35,6 @@ import replicaStore from '../../../stores/ReplicaStore'
 import KeyboardManager from '../../../utils/KeyboardManager'
 import { wizardPages, executionOptions, providerTypes } from '../../../constants'
 
-import type { MainItem } from '../../../@types/MainItem'
 import type { Endpoint as EndpointType, StorageBackend } from '../../../@types/Endpoint'
 import type {
   Instance, Nic, Disk, InstanceScript,
@@ -46,6 +45,7 @@ import type { Schedule } from '../../../@types/Schedule'
 import type { WizardPage as WizardPageType } from '../../../@types/WizardData'
 import ObjectUtils from '../../../utils/ObjectUtils'
 import { ProviderTypes } from '../../../@types/Providers'
+import { TransferItem, ReplicaItem } from '../../../@types/MainItem'
 
 const Wrapper = styled.div<any>``
 
@@ -141,27 +141,30 @@ class WizardPage extends React.Component<Props, State> {
     this.handleBackClick()
   }
 
-  async handleCreationSuccess(items: MainItem[]) {
+  async handleCreationSuccess(items: TransferItem[]) {
     const typeLabel = this.state.type.charAt(0).toUpperCase() + this.state.type.substr(1)
     notificationStore.alert(`${typeLabel}${items.length > 1 ? 's' : ''} was succesfully created`, 'success')
     let schedulePromise = Promise.resolve()
 
     if (this.state.type === 'replica') {
       items.forEach(replica => {
+        if (replica.type !== 'replica') {
+          return
+        }
         this.executeCreatedReplica(replica)
         schedulePromise = this.scheduleReplica(replica)
       })
     }
 
     if (items.length === 1) {
-      let location = `/${this.state.type}/`
+      let location = `/${this.state.type}s/${items[0].id}/`
       if (this.state.type === 'replica') {
-        location += 'executions/'
+        location += 'executions'
       } else {
-        location += 'tasks/'
+        location += 'tasks'
       }
       await schedulePromise
-      this.props.history.push(location + items[0].id)
+      this.props.history.push(location)
     } else {
       this.props.history.push(`/${this.state.type}s`)
     }
@@ -590,7 +593,7 @@ class WizardPage extends React.Component<Props, State> {
     return state
   }
 
-  scheduleReplica(replica: MainItem): Promise<void> {
+  scheduleReplica(replica: ReplicaItem): Promise<void> {
     if (wizardStore.schedules.length === 0) {
       return Promise.resolve()
     }
@@ -598,7 +601,7 @@ class WizardPage extends React.Component<Props, State> {
     return scheduleStore.scheduleMultiple(replica.id, wizardStore.schedules)
   }
 
-  executeCreatedReplica(replica: MainItem) {
+  executeCreatedReplica(replica: ReplicaItem) {
     const options = wizardStore.data.destOptions
     let executeNow = true
     if (options && options.execute_now != null) {

+ 3 - 3
src/sources/AssessmentSource.ts

@@ -13,11 +13,11 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
 import type { MigrationInfo } from '../@types/Assessment'
-import type { MainItem } from '../@types/MainItem'
 import Api from '../utils/ApiCaller'
 import configLoader from '../utils/Config'
 import notificationStore from '../stores/NotificationStore'
 import ObjectUtils from '../utils/ObjectUtils'
+import { MigrationItem } from '../@types/MainItem'
 
 class AssessmentSourceUtils {
   static getNetworkMap(data: MigrationInfo) {
@@ -50,7 +50,7 @@ class AssessmentSourceUtils {
 }
 
 class AssessmentSource {
-  static migrate(data: MigrationInfo): Promise<MainItem> {
+  static migrate(data: MigrationInfo): Promise<MigrationItem> {
     const type = data.fieldValues.use_replica ? 'replica' : 'migration'
     const payload: any = {}
     payload[type] = {
@@ -73,7 +73,7 @@ class AssessmentSource {
     }).then(response => response.data[type])
   }
 
-  static migrateMultiple(data: MigrationInfo): Promise<MainItem[]> {
+  static migrateMultiple(data: MigrationInfo): Promise<MigrationItem[]> {
     return Promise.all(data.selectedInstances.map(async instance => {
       const newData = { ...data }
       newData.selectedInstances = [instance]

+ 6 - 6
src/sources/MigrationSource.ts

@@ -19,7 +19,6 @@ import DefaultOptionsSchemaPlugin from '../plugins/endpoint/default/OptionsSchem
 import { sortTasks } from './ReplicaSource'
 
 import Api from '../utils/ApiCaller'
-import type { MainItem } from '../@types/MainItem'
 import type { InstanceScript } from '../@types/Instance'
 import type { Field } from '../@types/Field'
 import type { NetworkMap } from '../@types/Network'
@@ -27,6 +26,7 @@ import type { Endpoint, StorageMap } from '../@types/Endpoint'
 
 import configLoader from '../utils/Config'
 import { Task } from '../@types/Task'
+import { MigrationItem, MigrationItemOptions, MigrationItemDetails } from '../@types/MainItem'
 
 class MigrationSourceUtils {
   static sortTaskUpdates(updates: any[]) {
@@ -52,7 +52,7 @@ class MigrationSourceUtils {
 }
 
 class MigrationSource {
-  async getMigrations(skipLog?: boolean): Promise<MainItem[]> {
+  async getMigrations(skipLog?: boolean): Promise<MigrationItem[]> {
     const response = await Api.send({
       url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/migrations`,
       skipLog,
@@ -62,7 +62,7 @@ class MigrationSource {
     return migrations
   }
 
-  async getMigration(migrationId: string, skipLog?: boolean): Promise<MainItem> {
+  async getMigration(migrationId: string, skipLog?: boolean): Promise<MigrationItemDetails> {
     const response = await Api.send({
       url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/migrations/${migrationId}`,
       skipLog,
@@ -72,7 +72,7 @@ class MigrationSource {
     return migration
   }
 
-  async recreateFullCopy(migration: MainItem): Promise<MainItem> {
+  async recreateFullCopy(migration: MigrationItemOptions): Promise<MigrationItem> {
     const {
       origin_endpoint_id, destination_endpoint_id, destination_environment,
       network_map, instances, storage_mappings, notes,
@@ -125,7 +125,7 @@ class MigrationSource {
     updatedNetworkMappings: NetworkMap[] | null,
     defaultSkipOsMorphing: boolean | null,
     replicationCount?: number | null,
-  }): Promise<MainItem> {
+  }): Promise<MigrationItemDetails> {
     const getValue = (fieldName: string): string | null => {
       const updatedDestEnv = opts.updatedDestEnv && opts.updatedDestEnv[fieldName]
       return updatedDestEnv != null ? updatedDestEnv
@@ -213,7 +213,7 @@ class MigrationSource {
 
   async migrateReplica(
     replicaId: string, options: Field[], userScripts: InstanceScript[],
-  ): Promise<MainItem> {
+  ): Promise<MigrationItem> {
     const payload: any = {
       migration: {
         replica_id: replicaId,

+ 8 - 48
src/sources/NotificationSource.ts

@@ -17,6 +17,7 @@ import moment from 'moment'
 import configLoader from '../utils/Config'
 import Api from '../utils/ApiCaller'
 import type { NotificationItemData, NotificationItem } from '../@types/NotificationItem'
+import { TransferItem, MigrationItem, ReplicaItem } from '../@types/MainItem'
 
 class NotificationStorage {
   static storeName: string = 'seenNotifications'
@@ -69,45 +70,8 @@ class NotificationStorage {
 }
 
 class DataUtils {
-  static getMainInfo(item: any) {
-    if (item.type === 'migration') {
-      return item
-    }
-    if (item.executions && item.executions.length) {
-      const availableExecutions = item.executions.filter((i: any) => !i.deleted_at)
-      if (availableExecutions.length) {
-        availableExecutions.sort((a: any, b: any) => b.number - a.number)
-        return availableExecutions[0]
-      }
-    }
-
-    return item
-  }
-
-  static getUpdatedAt(item: any) {
-    const info = this.getMainInfo(item)
-    return info.updated_at || info.created_at
-  }
-
-  static getItemDescription(item: any) {
-    const type = item.type === 'replica' ? 'Replica' : 'Migration'
-    const mainInfo = this.getMainInfo(item)
-    let description = ''
-    const id = `${mainInfo.id.substr(0, 7)}...`
-    switch (mainInfo.status) {
-      case 'COMPLETED':
-        description = `${type} execution ${id} completed successfully`
-        break
-      case 'ERROR':
-        description = `${type} execution ${id} failed`
-        break
-      case 'RUNNING':
-        description = `${type} execution ${id} running`
-        break
-      default:
-        break
-    }
-    return description
+  static getItemDescription(item: TransferItem) {
+    return `New ${item.type} ${item.id.substr(0, 7)}... status: ${item.last_execution_status.toLowerCase().replace(/_/g, ' ')}`
   }
 }
 
@@ -118,20 +82,18 @@ class NotificationSource {
       Api.send({ url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/replicas`, skipLog: true, quietError: true }),
     ])
 
-    const migrations = migrationsResponse.data.migrations
-    const replicas = replicasResponse.data.replicas
+    const migrations: MigrationItem[] = migrationsResponse.data.migrations
+    const replicas: ReplicaItem[] = replicasResponse.data.replicas
     const apiData = [...migrations, ...replicas]
-    apiData.sort((a, b) => moment(DataUtils.getUpdatedAt(b)).diff(DataUtils.getUpdatedAt(a)))
+    apiData.sort((a, b) => moment(b.updated_at).diff(a.updated_at))
 
     const notificationItems: NotificationItemData[] = apiData.map(item => {
-      const mainInfo = DataUtils.getMainInfo(item)
-
       const newItem: NotificationItemData = {
         id: item.id,
-        status: mainInfo.status,
+        status: item.last_execution_status,
         type: item.type,
         name: item.instances[0],
-        updatedAt: mainInfo.updated_at,
+        updatedAt: item.updated_at,
         description: DataUtils.getItemDescription(item),
       }
       return newItem
@@ -143,13 +105,11 @@ class NotificationSource {
       storageData = NotificationStorage.loadSeen() || []
     }
     notificationItems.forEach(item => {
-      // eslint-disable-next-line no-param-reassign
       item.unseen = true
 
       storageData?.forEach(storageItem => {
         if (storageItem.id === item.id
           && storageItem.status === item.status && storageItem.updatedAt === item.updatedAt) {
-          // eslint-disable-next-line no-param-reassign
           item.unseen = false
         }
       })

+ 69 - 58
src/sources/ReplicaSource.ts

@@ -18,14 +18,14 @@ import Api from '../utils/ApiCaller'
 import { OptionsSchemaPlugin } from '../plugins/endpoint'
 
 import configLoader from '../utils/Config'
-import type { MainItem, UpdateData } from '../@types/MainItem'
-import type { Execution } from '../@types/Execution'
+import type { UpdateData, ReplicaItem, ReplicaItemDetails } from '../@types/MainItem'
+import type { Execution, ExecutionTasks } from '../@types/Execution'
 import type { Endpoint } from '../@types/Endpoint'
 import type { Task, ProgressUpdate } from '../@types/Task'
 import type { Field } from '../@types/Field'
 
 export const sortTasks = (
-  tasks: Task[], taskUpdatesSortFunction: (updates: ProgressUpdate[]) => void,
+  tasks?: Task[], taskUpdatesSortFunction?: (updates: ProgressUpdate[]) => void,
 ) => {
   if (!tasks) {
     return
@@ -34,6 +34,9 @@ export const sortTasks = (
   let buffer: Task[] = []
   let runningBuffer: Task[] = []
   let completedBuffer: Task[] = []
+  if (!taskUpdatesSortFunction) {
+    return
+  }
   tasks.forEach(task => {
     taskUpdatesSortFunction(task.progress_updates)
     buffer.push(task)
@@ -63,82 +66,78 @@ export const sortTasks = (
 }
 
 class ReplicaSourceUtils {
-  static filterDeletedExecutionsInReplicas(replicas: any[]) {
-    return replicas.map((replica: { executions: any }) => {
-      // eslint-disable-next-line no-param-reassign
-      replica.executions = ReplicaSourceUtils.filterDeletedExecutions(replica.executions)
-      return replica
-    })
-  }
-
-  static filterDeletedExecutions(executions: any[]) {
+  static filterDeletedExecutions(executions?: Execution[]) {
     if (!executions || !executions.length) {
-      return executions
+      return []
     }
 
-    return executions.filter((execution: { deleted_at: null }) => execution.deleted_at == null)
+    return executions.filter(execution => execution.deleted_at == null)
   }
 
-  static sortReplicas(replicas: any[]) {
-    replicas.forEach((replica: { executions: any }) => {
-      ReplicaSourceUtils.sortExecutionsAndTasks(replica.executions)
-    })
-
-    replicas.sort(
-      (a: { executions: string | any[]; updated_at: any; created_at: any },
-        b: { executions: string | any[]; updated_at: any; created_at: any }) => {
-        const aLastExecution = a.executions && a.executions.length
-          ? a.executions[a.executions.length - 1] : null
-        const bLastExecution = b.executions && b.executions.length
-          ? b.executions[b.executions.length - 1] : null
-        const aLastTime = aLastExecution
-          ? aLastExecution.updated_at || aLastExecution.created_at : null
-        const bLastTime = bLastExecution
-          ? bLastExecution.updated_at || bLastExecution.created_at : null
-        const aTime = aLastTime || a.updated_at || a.created_at
-        const bTime = bLastTime || b.updated_at || b.created_at
-        return moment(bTime).diff(moment(aTime))
-      },
-    )
-  }
-
-  static sortExecutions(executions: any[]) {
-    if (executions) {
-      executions.sort((a: { number: number }, b: { number: number }) => a.number - b.number)
-    }
+  static sortReplicas(replicas: ReplicaItem[]) {
+    replicas.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
   }
 
-  static sortExecutionsAndTasks(executions: any[]) {
-    this.sortExecutions(executions)
-    executions.forEach((execution: { tasks: Task[] }) => {
-      sortTasks(execution.tasks, ReplicaSourceUtils.sortTaskUpdates)
-    })
+  static sortExecutions(executions: Execution[]) {
+    executions.sort((a, b) => a.number - b.number)
   }
 
-  static sortTaskUpdates(updates: any[]) {
+  static sortTaskUpdates(updates: ProgressUpdate[]) {
     if (!updates) {
       return
     }
     updates
-      .sort((a: any, b: any) => moment(a.created_at)
+      .sort((a, b) => moment(a.created_at)
         .toDate().getTime() - moment(b.created_at).toDate().getTime())
   }
 }
 
 class ReplicaSource {
-  async getReplicas(skipLog?: boolean, quietError?: boolean): Promise<MainItem[]> {
+  async getReplicas(skipLog?: boolean, quietError?: boolean): Promise<ReplicaItem[]> {
     const response = await Api.send({
       url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/replicas`,
       skipLog,
       quietError,
     })
-    let replicas = response.data.replicas
-    replicas = ReplicaSourceUtils.filterDeletedExecutionsInReplicas(replicas)
+    const replicas: ReplicaItem[] = response.data.replicas
     ReplicaSourceUtils.sortReplicas(replicas)
     return replicas
   }
 
-  async execute(replicaId: string, fields?: Field[]): Promise<Execution> {
+  async getReplicaDetails(options: {
+    replicaId: string, polling?: boolean
+  }): Promise<ReplicaItemDetails> {
+    const { replicaId, polling } = options
+
+    const response = await Api.send({
+      url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/replicas/${replicaId}`,
+      skipLog: polling,
+    })
+    const replica: ReplicaItemDetails = response.data.replica
+    replica.executions = ReplicaSourceUtils.filterDeletedExecutions(replica.executions)
+    ReplicaSourceUtils.sortExecutions(replica.executions)
+    return replica
+  }
+
+  async getExecutionTasks(options: {
+    replicaId: string,
+    executionId?: string,
+    polling?: boolean,
+  }): Promise<ExecutionTasks> {
+    const {
+      replicaId, executionId, polling,
+    } = options
+
+    const response = await Api.send({
+      url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/replicas/${replicaId}/executions/${executionId}`,
+      skipLog: polling,
+    })
+    const execution: ExecutionTasks = response.data.execution
+    sortTasks(execution.tasks, ReplicaSourceUtils.sortTaskUpdates)
+    return execution
+  }
+
+  async execute(replicaId: string, fields?: Field[]): Promise<ExecutionTasks> {
     const payload: any = { execution: { shutdown_instances: false } }
     if (fields) {
       fields.forEach(f => {
@@ -150,24 +149,36 @@ class ReplicaSource {
       method: 'POST',
       data: payload,
     })
-    const execution = response.data.execution
+    const execution: ExecutionTasks = response.data.execution
     sortTasks(execution.tasks, ReplicaSourceUtils.sortTaskUpdates)
     return execution
   }
 
   async cancelExecution(
-    replicaId: string, executionId: string, force?: boolean | null,
+    options: { replicaId: string, executionId?: string, force?: boolean },
   ): Promise<string> {
     const data: any = { cancel: null }
-    if (force) {
+    if (options.force) {
       data.cancel = { force: true }
     }
+
+    let lastExecutionId = options.executionId
+
+    if (!lastExecutionId) {
+      const replicaDetails = await this.getReplicaDetails({ replicaId: options.replicaId })
+      const lastExecution = replicaDetails.executions[replicaDetails.executions.length - 1]
+      if (lastExecution.status !== 'RUNNING') {
+        return options.replicaId
+      }
+      lastExecutionId = lastExecution.id
+    }
+
     await Api.send({
-      url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/replicas/${replicaId}/executions/${executionId}/actions`,
+      url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/replicas/${options.replicaId}/executions/${lastExecutionId}/actions`,
       method: 'POST',
       data,
     })
-    return replicaId
+    return options.replicaId
   }
 
   async deleteExecution(replicaId: string, executionId: string): Promise<string> {
@@ -196,7 +207,7 @@ class ReplicaSource {
   }
 
   async update(
-    replica: MainItem,
+    replica: ReplicaItem,
     destinationEndpoint: Endpoint,
     updateData: UpdateData,
     defaultStorage: string | null | undefined,

+ 6 - 2
src/sources/UserSource.ts

@@ -171,8 +171,12 @@ class UserSource {
     return response.data.user
   }
 
-  async getAllUsers(skipLog?: boolean): Promise<User[]> {
-    const response = await Api.send({ url: `${configLoader.config.servicesUrls.keystone}/users`, skipLog })
+  async getAllUsers(skipLog?: boolean, quietError?: boolean): Promise<User[]> {
+    const response = await Api.send({
+      url: `${configLoader.config.servicesUrls.keystone}/users`,
+      skipLog,
+      quietError,
+    })
     let users: User[] = response.data.users
     await utils.waitFor(() => Boolean(configLoader.config))
     users = users.filter(u => !configLoader.config.hiddenUsers.find(hu => hu === u.name))

+ 3 - 3
src/sources/WizardSource.ts

@@ -20,9 +20,9 @@ import configLoader from '../utils/Config'
 
 import type { WizardData } from '../@types/WizardData'
 import type { StorageMap } from '../@types/Endpoint'
-import type { MainItem } from '../@types/MainItem'
 import type { InstanceScript } from '../@types/Instance'
 import DefaultOptionsSchemaParser from '../plugins/endpoint/default/OptionsSchemaPlugin'
+import { TransferItem } from '../@types/MainItem'
 
 class WizardSource {
   async create(
@@ -31,7 +31,7 @@ class WizardSource {
     defaultStorage: string | null,
     storageMap: StorageMap[],
     uploadedUserScripts: InstanceScript[],
-  ): Promise<MainItem> {
+  ): Promise<TransferItem> {
     const sourceParser = data.source
       ? OptionsSchemaPlugin.for(data.source.type) : DefaultOptionsSchemaParser
     const destParser = data.target
@@ -87,7 +87,7 @@ class WizardSource {
     const mainItems = await Promise.all(data.selectedInstances.map(async instance => {
       const newData = { ...data }
       newData.selectedInstances = [instance]
-      let mainItem: MainItem | null = null
+      let mainItem: TransferItem | null = null
       try {
         mainItem = await this.create(type, newData, defaultStorage, storageMap, uploadedUserScripts)
       } finally {

+ 2 - 2
src/stores/AssessmentStore.ts

@@ -17,7 +17,7 @@ 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'
+import { MigrationItem } from '../@types/MainItem'
 
 class AssessmentStore {
   @observable selectedEndpoint: Endpoint | null = null
@@ -26,7 +26,7 @@ class AssessmentStore {
 
   @observable migrating: boolean = false
 
-  @observable migrations: MainItem[] = []
+  @observable migrations: MigrationItem[] = []
 
   @action updateSelectedEndpoint(endpoint: Endpoint) {
     this.selectedEndpoint = endpoint

+ 3 - 3
src/stores/InstanceStore.ts

@@ -267,7 +267,7 @@ class InstanceStore {
           runInAction(() => {
             this.instancesDetails = this.instancesDetails.filter(id => (id.name || id.instance_name || '') !== name)
             this.instancesDetails.push(instance)
-            this.instancesDetails.sort(n => (n.name || n.instance_name || '')
+            this.instancesDetails = this.instancesDetails.slice().sort(n => (n.name || n.instance_name || '')
               .localeCompare(n.name || n.instance_name || ''))
           })
         }))
@@ -312,7 +312,7 @@ class InstanceStore {
         ...this.instancesDetails,
         instance,
       ]
-      this.instancesDetails
+      this.instancesDetails = this.instancesDetails.slice()
         .sort((a, b) => (a.instance_name || a.name).localeCompare((b.instance_name || b.name)))
     })
   }
@@ -381,7 +381,7 @@ class InstanceStore {
             ]
           })
           if (this.instancesDetailsRemaining === 0) {
-            this.instancesDetails
+            this.instancesDetails = this.instancesDetails.slice()
               .sort((a, b) => (a.instance_name || a.name)
                 .localeCompare((b.instance_name || b.name)))
             resolve()

+ 10 - 16
src/stores/MigrationStore.ts

@@ -14,16 +14,18 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import { observable, action, runInAction } from 'mobx'
 
-import type { MainItem, UpdateData } from '../@types/MainItem'
+import type {
+  UpdateData, MigrationItem, MigrationItemDetails, MigrationItemOptions,
+} from '../@types/MainItem'
 import type { Field } from '../@types/Field'
 import type { Endpoint } from '../@types/Endpoint'
 import type { InstanceScript } from '../@types/Instance'
 import MigrationSource from '../sources/MigrationSource'
 
 class MigrationStore {
-  @observable migrations: MainItem[] = []
+  @observable migrations: MigrationItem[] = []
 
-  @observable migrationDetails: MainItem | null = null
+  @observable migrationDetails: MigrationItemDetails | null = null
 
   @observable loading: boolean = true
 
@@ -39,15 +41,7 @@ class MigrationStore {
     try {
       const migrations = await MigrationSource.getMigrations(options && options.skipLog)
       runInAction(() => {
-        this.migrations = migrations.map(migration => {
-          const oldMigration = this.migrations.find(r => r.id === migration.id)
-          if (oldMigration) {
-            // eslint-disable-next-line no-param-reassign
-            migration.executions = oldMigration.executions
-          }
-
-          return migration
-        })
+        this.migrations = migrations
         this.loading = false
         this.migrationsLoaded = true
       })
@@ -57,7 +51,7 @@ class MigrationStore {
     }
   }
 
-  getDefaultSkipOsMorphing(migration: MainItem | null) {
+  getDefaultSkipOsMorphing(migration: MigrationItemDetails | null) {
     const tasks = migration && migration.tasks
     if (tasks && !tasks.find(t => t.task_type === 'OS_MORPHING')) {
       return true
@@ -65,19 +59,19 @@ class MigrationStore {
     return null
   }
 
-  @action async recreateFullCopy(migration: MainItem) {
+  @action async recreateFullCopy(migration: MigrationItemOptions) {
     return MigrationSource.recreateFullCopy(migration)
   }
 
   @action async recreate(
-    migration: MainItem,
+    migration: MigrationItemDetails,
     sourceEndpoint: Endpoint,
     destEndpoint: Endpoint,
     updateData: UpdateData,
     defaultStorage: string | null | undefined,
     updatedDefaultStorage: string | null | undefined,
     replicationCount: number | null | undefined,
-  ): Promise<MainItem> {
+  ): Promise<MigrationItemDetails> {
     const migrationResult = await MigrationSource.recreate({
       sourceEndpoint,
       destEndpoint,

+ 127 - 62
src/stores/ReplicaStore.ts

@@ -13,17 +13,21 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
 import { observable, action, runInAction } from 'mobx'
-import moment from 'moment'
 
 import notificationStore from './NotificationStore'
 import ReplicaSource from '../sources/ReplicaSource'
-import type { MainItem, UpdateData } from '../@types/MainItem'
-import type { Execution } from '../@types/Execution'
+import type {
+  UpdateData, ReplicaItem, ReplicaItemDetails,
+} from '../@types/MainItem'
+import type { Execution, ExecutionTasks } from '../@types/Execution'
 import type { Endpoint } from '../@types/Endpoint'
 import type { Field } from '../@types/Field'
 
 class ReplicaStoreUtils {
-  static getNewReplica(replicaDetails: MainItem, execution: Execution): MainItem {
+  static getNewReplica(
+    replicaDetails: ReplicaItemDetails,
+    execution: Execution,
+  ): ReplicaItemDetails {
     if (replicaDetails.executions) {
       return {
         ...replicaDetails,
@@ -38,39 +42,27 @@ class ReplicaStoreUtils {
   }
 }
 
-const checkAddExecution = (
-  replicas: MainItem[],
-  addExecution: { replicaId: string, execution: Execution } | null,
-) => {
-  const usableAddExecution = addExecution
-  if (!usableAddExecution) {
-    return
-  }
-  const executionTime = moment.utc(usableAddExecution.execution.created_at)
-    .local().toDate().getTime()
-  if (new Date().getTime() - executionTime > 5000) {
-    return
-  }
-  const replica = replicas.find(r => r.id === usableAddExecution.replicaId)
-  if (!replica) {
-    return
-  }
-  const execution = replica.executions.find(e => e.id === usableAddExecution.execution.id)
-  if (execution) {
-    return
-  }
-  replica.executions.push(usableAddExecution.execution)
-}
-
 class ReplicaStore {
-  @observable replicas: MainItem[] = []
+  @observable replicas: ReplicaItem[] = []
 
   @observable loading: boolean = false
 
+  @observable replicaDetails: ReplicaItemDetails | null = null
+
+  @observable replicaDetailsLoading: boolean = false
+
+  @observable executionsTasks: ExecutionTasks[] = []
+
+  @observable executionsTasksLoading: boolean = false
+
   @observable backgroundLoading: boolean = false
 
   @observable startingExecution: boolean = false
 
+  @observable replicasWithDisks: ReplicaItemDetails[] = []
+
+  @observable replicasWithDisksLoading: boolean = false
+
   replicasLoaded: boolean = false
 
   addExecution: { replicaId: string, execution: Execution } | null = null
@@ -87,14 +79,35 @@ class ReplicaStore {
     try {
       const replicas = await ReplicaSource
         .getReplicas(options && options.skipLog, options && options.quietError)
-      checkAddExecution(replicas, this.addExecution)
       this.getReplicasSuccess(replicas)
     } finally {
       this.getReplicasDone()
     }
   }
 
-  @action getReplicasSuccess(replicas: MainItem[]) {
+  @action async getReplicaDetails(options: {
+    replicaId: string, showLoading?: boolean, polling?: boolean,
+  }) {
+    const { replicaId, showLoading, polling } = options
+
+    if (showLoading) {
+      this.replicaDetailsLoading = true
+    }
+
+    try {
+      const replica = await ReplicaSource.getReplicaDetails({ replicaId, polling })
+
+      runInAction(() => {
+        this.replicaDetails = replica
+      })
+    } finally {
+      runInAction(() => {
+        this.replicaDetailsLoading = false
+      })
+    }
+  }
+
+  @action getReplicasSuccess(replicas: ReplicaItem[]) {
     this.replicasLoaded = true
     this.replicas = replicas
   }
@@ -104,32 +117,73 @@ class ReplicaStore {
     this.backgroundLoading = false
   }
 
-  @action async execute(replicaId: string, fields?: Field[]): Promise<void> {
-    const replica = this.replicas.find(r => r.id === replicaId)
-    if (replica && replica.executions && replica.executions.length === 0) {
-      this.startingExecution = true
+  private currentlyLoadingExecution: string = ''
+
+  @action async getExecutionTasks(
+    options: {
+      replicaId: string,
+      executionId?: string,
+      polling?: boolean,
+    },
+  ) {
+    const {
+      replicaId, executionId, polling,
+    } = options
+
+    if (!polling && this.currentlyLoadingExecution === executionId) {
+      return
     }
+    this.currentlyLoadingExecution = polling ? this.currentlyLoadingExecution : executionId || ''
+    if (!this.currentlyLoadingExecution) {
+      return
+    }
+
+    if (!this.executionsTasks.find(e => e.id === this.currentlyLoadingExecution)) {
+      this.executionsTasksLoading = true
+    }
+
+    try {
+      const executionTasks = await ReplicaSource.getExecutionTasks({
+        replicaId, executionId: this.currentlyLoadingExecution, polling,
+      })
+      runInAction(() => {
+        this.executionsTasks = [
+          ...this.executionsTasks.filter(e => e.id !== this.currentlyLoadingExecution),
+          executionTasks,
+        ]
+      })
+    } catch (err) {
+      console.error(err)
+    } finally {
+      runInAction(() => {
+        this.executionsTasksLoading = false
+      })
+    }
+  }
+
+  @action async execute(replicaId: string, fields?: Field[]): Promise<void> {
+    this.startingExecution = true
+
     const execution = await ReplicaSource.execute(replicaId, fields)
     this.executeSuccess(replicaId, execution)
   }
 
   @action executeSuccess(replicaId: string, execution: Execution) {
-    this.addExecution = { replicaId, execution }
-    const replicasItemIndex = this.replicas.findIndex(r => r.id === replicaId)
-
-    if (replicasItemIndex > -1) {
+    if (this.replicaDetails?.id === replicaId) {
       const updatedReplica = ReplicaStoreUtils
-        .getNewReplica(this.replicas[replicasItemIndex], execution)
-      this.replicas[replicasItemIndex] = updatedReplica
+        .getNewReplica(this.replicaDetails, execution)
+      this.replicaDetails = updatedReplica
     }
+    this.getExecutionTasks({ replicaId, executionId: execution.id })
+
     this.startingExecution = false
   }
 
   async cancelExecution(
-    replicaId: string, executionId: string, force?: boolean | null,
+    options: {replicaId: string, executionId?: string, force?: boolean},
   ): Promise<void> {
-    await ReplicaSource.cancelExecution(replicaId, executionId, force)
-    if (force) {
+    await ReplicaSource.cancelExecution(options)
+    if (options.force) {
       notificationStore.alert('Force cancelled', 'success')
     } else {
       notificationStore.alert('Cancelled', 'success')
@@ -144,12 +198,12 @@ class ReplicaStore {
   @action deleteExecutionSuccess(replicaId: string, executionId: string) {
     let executions = []
 
-    const replicasItemIndex = this.replicas ? this.replicas.findIndex(r => r.id === replicaId) : -1
-
-    if (replicasItemIndex > -1) {
-      executions = [...this.replicas[replicasItemIndex].executions
-        .filter(e => e.id !== executionId)]
-      this.replicas[replicasItemIndex].executions = executions
+    if (this.replicaDetails?.id === replicaId) {
+      executions = [...this.replicaDetails.executions.filter(e => e.id !== executionId)]
+      this.replicaDetails.executions = executions
+    }
+    if (executionId === this.currentlyLoadingExecution) {
+      this.currentlyLoadingExecution = ''
     }
   }
 
@@ -164,17 +218,15 @@ class ReplicaStore {
   }
 
   @action deleteDisksSuccess(replicaId: string, execution: Execution) {
-    const replicasItemIndex = this.replicas.findIndex(r => r.id === replicaId)
-
-    if (replicasItemIndex > -1) {
+    if (this.replicaDetails?.id === replicaId) {
       const updatedReplica = ReplicaStoreUtils
-        .getNewReplica(this.replicas[replicasItemIndex], execution)
-      this.replicas[replicasItemIndex] = updatedReplica
+        .getNewReplica(this.replicaDetails, execution)
+      this.replicaDetails = updatedReplica
     }
   }
 
   async update(
-    replica: MainItem,
+    replica: ReplicaItemDetails,
     destinationEndpoint: Endpoint,
     updateData: UpdateData,
     defaultStorage: string | null | undefined,
@@ -189,12 +241,7 @@ class ReplicaStore {
     )
   }
 
-  getReplicasWithDisks(replicas: MainItem[]): MainItem[] {
-    const result = replicas.filter(r => this.hasReplicaDisks(r))
-    return result
-  }
-
-  hasReplicaDisks(replica?: MainItem | null): boolean {
+  testReplicaHasDisks(replica: ReplicaItemDetails | null) {
     if (!replica || !replica.executions || replica.executions.length === 0) {
       return false
     }
@@ -207,6 +254,24 @@ class ReplicaStore {
     }
     return true
   }
+
+  @action
+  async loadHaveReplicasDisks(replicas: ReplicaItem[]) {
+    this.replicasWithDisksLoading = true
+
+    try {
+      const replicaDetails = await Promise.all(replicas
+        .map(replica => ReplicaSource.getReplicaDetails({ replicaId: replica.id })))
+
+      runInAction(() => {
+        this.replicasWithDisks = replicaDetails.filter(r => this.testReplicaHasDisks(r))
+      })
+    } finally {
+      runInAction(() => {
+        this.replicasWithDisksLoading = false
+      })
+    }
+  }
 }
 
 export default new ReplicaStore()

+ 11 - 3
src/stores/UserStore.ts

@@ -152,12 +152,20 @@ class UserStore {
     }
   }
 
-  @action async getAllUsers(options?: { showLoading?: boolean, skipLog?: boolean }): Promise<void> {
-    if (options && options.showLoading) this.allUsersLoading = true
+  @action async getAllUsers(options?: {
+    showLoading?: boolean,
+    skipLog?: boolean,
+    quietError?: boolean,
+  }): Promise<void> {
+    if (options?.showLoading) this.allUsersLoading = true
 
     try {
-      const users = await UserSource.getAllUsers(options && options.skipLog)
+      const users = await UserSource.getAllUsers(options?.skipLog, options?.quietError)
       runInAction(() => { this.users = users })
+    } catch (err) {
+      if (err.data?.error?.code !== 403) {
+        throw err
+      }
     } finally {
       runInAction(() => { this.allUsersLoading = false })
     }

+ 4 - 4
src/stores/WizardStore.ts

@@ -15,7 +15,6 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import { observable, action, runInAction } from 'mobx'
 
 import type { WizardData, WizardPage } from '../@types/WizardData'
-import type { MainItem } from '../@types/MainItem'
 import type { Instance, InstanceScript } from '../@types/Instance'
 import type { Field } from '../@types/Field'
 import type { NetworkMap } from '../@types/Network'
@@ -24,6 +23,7 @@ import type { Schedule } from '../@types/Schedule'
 import { wizardPages } from '../constants'
 import source from '../sources/WizardSource'
 import notificationStore from './NotificationStore'
+import { TransferItem } from '../@types/MainItem'
 
 const updateOptions = (
   oldOptions: { [prop: string]: any } | null | undefined,
@@ -64,11 +64,11 @@ class WizardStore {
 
   @observable currentPage: WizardPage = wizardPages[0]
 
-  @observable createdItem: MainItem | null = null
+  @observable createdItem: TransferItem | null = null
 
   @observable creatingItem: boolean = false
 
-  @observable createdItems: Array<MainItem | null> | null = null
+  @observable createdItems: Array<TransferItem | null> | null = null
 
   @observable creatingItems: boolean = false
 
@@ -217,7 +217,7 @@ class WizardStore {
     this.creatingItem = true
 
     try {
-      const item: MainItem = await source.create(
+      const item: TransferItem = await source.create(
         type, data, defaultStorage, storageMap, uploadedUserScripts,
       )
       runInAction(() => { this.createdItem = item })

+ 2 - 2
src/utils/DateUtils.ts

@@ -15,12 +15,12 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import moment from 'moment'
 
 class DateUtils {
-  static getLocalTime(rawDate: Date | moment.Moment | undefined | null): moment.Moment {
+  static getLocalTime(rawDate: moment.MomentInput): moment.Moment {
     const usableRawDate = rawDate || undefined
     return moment(usableRawDate).add(-new Date().getTimezoneOffset(), 'minutes')
   }
 
-  static getUtcTime(rawDate: Date | moment.Moment | undefined | null): moment.Moment {
+  static getUtcTime(rawDate: moment.MomentInput): moment.Moment {
     const usableRawDate = rawDate || undefined
     return moment(usableRawDate).add(new Date().getTimezoneOffset(), 'minutes')
   }