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

Add support for data restructuring in API lists

The API no longer returns everything (like executions and tasks) for
each item when listing replicas or migrations.

The executions are only loaded when opening a replica.
The tasks are loaded only for the selected execution, or in the case of
a migration, when opening a migration.

Some components relied on execution data being present when listing
replicas and migrations. Those components have been rewritten to use
alternative data available in the lists. Among the affected components,
the important ones are the notifications, the Timeline module inside the
Dashboard page, the replicas and migrations list item renderer.
Sergiu Miclea 5 лет назад
Родитель
Сommit
85992d2eb3
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')
   }