]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Extract/Refactor Task merge 23555/head
authorTiago Melo <tspmelo@gmail.com>
Mon, 13 Aug 2018 15:36:32 +0000 (16:36 +0100)
committerTiago Melo <tspmelo@gmail.com>
Thu, 30 Aug 2018 14:15:27 +0000 (15:15 +0100)
Signed-off-by: Stephan Müller <smueller@suse.com>
Signed-off-by: Tiago Melo <tmelo@suse.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-model.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts

index 636be3ae7d702b0a8f9a502bf54ff6f849aa66c4..c0c5463fe6450c5f2b544c268ade6ce4910e4e8b 100644 (file)
        heading="Snapshots">
     <cd-rbd-snapshot-list [snapshots]="selectedItem.snapshots"
                           [poolName]="selectedItem.pool_name"
-                          [rbdName]="selectedItem.name"
-                          [executingTasks]="selectedItem.executingTasks"></cd-rbd-snapshot-list>
+                          [rbdName]="selectedItem.name"></cd-rbd-snapshot-list>
   </tab>
 </tabset>
index 33a3a2d6a6e66c96a27f421f4348a5f5989f54c6..24b28a9dfcaa61167489e2141e1b7c0d707a71e8 100644 (file)
@@ -10,31 +10,30 @@ import {
   TabsModule,
   TooltipModule
 } from 'ngx-bootstrap';
+import { BehaviorSubject, of } from 'rxjs';
 
 import { configureTestBed } from '../../../../testing/unit-test-helper';
+import { RbdService } from '../../../shared/api/rbd.service';
 import { ComponentsModule } from '../../../shared/components/components.module';
 import { ViewCacheStatus } from '../../../shared/enum/view-cache-status.enum';
+import { ExecutingTask } from '../../../shared/models/executing-task';
 import { SummaryService } from '../../../shared/services/summary.service';
+import { TaskListService } from '../../../shared/services/task-list.service';
 import { SharedModule } from '../../../shared/shared.module';
 import { RbdDetailsComponent } from '../rbd-details/rbd-details.component';
 import { RbdSnapshotListComponent } from '../rbd-snapshot-list/rbd-snapshot-list.component';
 import { RbdListComponent } from './rbd-list.component';
+import { RbdModel } from './rbd-model';
 
 describe('RbdListComponent', () => {
-  let component: RbdListComponent;
   let fixture: ComponentFixture<RbdListComponent>;
+  let component: RbdListComponent;
+  let summaryService: SummaryService;
+  let rbdService: RbdService;
 
-  class SummaryServiceMock extends SummaryService {
-    data: any;
-
-    raiseError() {
-      this.summaryDataSource.error(undefined);
-    }
-
-    refresh() {
-      this.summaryDataSource.next(this.data);
-    }
-  }
+  const refresh = (data) => {
+    summaryService['summaryDataSource'].next(data);
+  };
 
   configureTestBed({
     imports: [
@@ -50,12 +49,18 @@ describe('RbdListComponent', () => {
       HttpClientTestingModule
     ],
     declarations: [RbdListComponent, RbdDetailsComponent, RbdSnapshotListComponent],
-    providers: [{ provide: SummaryService, useClass: SummaryServiceMock }]
+    providers: [SummaryService, TaskListService, RbdService]
   });
 
   beforeEach(() => {
     fixture = TestBed.createComponent(RbdListComponent);
     component = fixture.componentInstance;
+    summaryService = TestBed.get(SummaryService);
+    rbdService = TestBed.get(RbdService);
+
+    // this is needed because summaryService isn't being reseted after each test.
+    summaryService['summaryDataSource'] = new BehaviorSubject(null);
+    summaryService['summaryData$'] = summaryService['summaryDataSource'].asObservable();
   });
 
   it('should create', () => {
@@ -63,32 +68,111 @@ describe('RbdListComponent', () => {
   });
 
   describe('after ngOnInit', () => {
-    let summaryService: SummaryServiceMock;
-
     beforeEach(() => {
-      summaryService = TestBed.get(SummaryService);
-      summaryService.data = undefined;
       fixture.detectChanges();
+      spyOn(rbdService, 'list').and.callThrough();
     });
 
     it('should load images on init', () => {
-      spyOn(component, 'loadImages');
-      summaryService.data = {};
-      summaryService.refresh();
-      expect(component.loadImages).toHaveBeenCalled();
+      refresh({});
+      expect(rbdService.list).toHaveBeenCalled();
     });
 
     it('should not load images on init because no data', () => {
-      spyOn(component, 'loadImages');
-      summaryService.refresh();
-      expect(component.loadImages).not.toHaveBeenCalled();
+      refresh(undefined);
+      expect(rbdService.list).not.toHaveBeenCalled();
     });
 
     it('should call error function on init when summary service fails', () => {
       spyOn(component.table, 'reset');
-      summaryService.raiseError();
+      summaryService['summaryDataSource'].error(undefined);
       expect(component.table.reset).toHaveBeenCalled();
       expect(component.viewCacheStatusList).toEqual([{ status: ViewCacheStatus.ValueException }]);
     });
   });
+
+  describe('handling of executing tasks', () => {
+    let images: RbdModel[];
+
+    const addImage = (name) => {
+      const model = new RbdModel();
+      model.id = '-1';
+      model.name = name;
+      model.pool_name = 'rbd';
+      images.push(model);
+    };
+
+    const addTask = (name: string, image_name: string) => {
+      const task = new ExecutingTask();
+      task.name = name;
+      task.metadata = {
+        pool_name: 'rbd',
+        image_name: image_name,
+        child_pool_name: 'rbd',
+        child_image_name: 'd',
+        dest_pool_name: 'rbd',
+        dest_image_name: 'd'
+      };
+      summaryService.addRunningTask(task);
+    };
+
+    const expectImageTasks = (image: RbdModel, executing: string) => {
+      expect(image.cdExecuting).toEqual(executing);
+    };
+
+    beforeEach(() => {
+      images = [];
+      addImage('a');
+      addImage('b');
+      addImage('c');
+      component.images = images;
+      refresh({ executing_tasks: [], finished_tasks: [] });
+      spyOn(rbdService, 'list').and.callFake(() =>
+        of([{ poool_name: 'rbd', status: 1, value: images }])
+      );
+      fixture.detectChanges();
+    });
+
+    it('should gets all images without tasks', () => {
+      expect(component.images.length).toBe(3);
+      expect(component.images.every((image) => !image.cdExecuting)).toBeTruthy();
+    });
+
+    it('should add a new image from a task', () => {
+      addTask('rbd/create', 'd');
+      expect(component.images.length).toBe(4);
+      expectImageTasks(component.images[0], undefined);
+      expectImageTasks(component.images[1], undefined);
+      expectImageTasks(component.images[2], undefined);
+      expectImageTasks(component.images[3], 'Creating');
+    });
+
+    it('should show when a image is being cloned', () => {
+      addTask('rbd/clone', 'd');
+      expect(component.images.length).toBe(4);
+      expectImageTasks(component.images[0], undefined);
+      expectImageTasks(component.images[1], undefined);
+      expectImageTasks(component.images[2], undefined);
+      expectImageTasks(component.images[3], 'Cloning');
+    });
+
+    it('should show when a image is being copied', () => {
+      addTask('rbd/copy', 'd');
+      expect(component.images.length).toBe(4);
+      expectImageTasks(component.images[0], undefined);
+      expectImageTasks(component.images[1], undefined);
+      expectImageTasks(component.images[2], undefined);
+      expectImageTasks(component.images[3], 'Copying');
+    });
+
+    it('should show when an existing image is being modified', () => {
+      addTask('rbd/edit', 'a');
+      addTask('rbd/delete', 'b');
+      addTask('rbd/flatten', 'c');
+      expect(component.images.length).toBe(3);
+      expectImageTasks(component.images[0], 'Updating');
+      expectImageTasks(component.images[1], 'Deleting');
+      expectImageTasks(component.images[2], 'Flattening');
+    });
+  });
 });
index e93f5f6402096167d576302b91cdbcf09f0925b7..1c14a8b6ebdba002ed065780ac26b0a0954d4a82 100644 (file)
@@ -1,8 +1,7 @@
-import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
 
 import * as _ from 'lodash';
 import { BsModalRef, BsModalService } from 'ngx-bootstrap';
-import { Subscription } from 'rxjs';
 
 import { RbdService } from '../../../shared/api/rbd.service';
 import { ConfirmationModalComponent } from '../../../shared/components/confirmation-modal/confirmation-modal.component';
@@ -12,13 +11,12 @@ import { CellTemplate } from '../../../shared/enum/cell-template.enum';
 import { ViewCacheStatus } from '../../../shared/enum/view-cache-status.enum';
 import { CdTableColumn } from '../../../shared/models/cd-table-column';
 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
-import { ExecutingTask } from '../../../shared/models/executing-task';
 import { FinishedTask } from '../../../shared/models/finished-task';
 import { Permission } from '../../../shared/models/permissions';
 import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
 import { DimlessPipe } from '../../../shared/pipes/dimless.pipe';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
-import { SummaryService } from '../../../shared/services/summary.service';
+import { TaskListService } from '../../../shared/services/task-list.service';
 import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
 import { RbdParentModel } from '../rbd-form/rbd-parent.model';
 import { RbdModel } from './rbd-model';
@@ -26,9 +24,10 @@ import { RbdModel } from './rbd-model';
 @Component({
   selector: 'cd-rbd-list',
   templateUrl: './rbd-list.component.html',
-  styleUrls: ['./rbd-list.component.scss']
+  styleUrls: ['./rbd-list.component.scss'],
+  providers: [TaskListService]
 })
-export class RbdListComponent implements OnInit, OnDestroy {
+export class RbdListComponent implements OnInit {
   @ViewChild(TableComponent)
   table: TableComponent;
   @ViewChild('usageTpl')
@@ -47,18 +46,33 @@ export class RbdListComponent implements OnInit, OnDestroy {
   viewCacheStatusList: any[];
   selection = new CdTableSelection();
 
-  summaryDataSubscription: Subscription;
-
   modalRef: BsModalRef;
 
+  builders = {
+    'rbd/create': (metadata) =>
+      this.createRbdFromTask(metadata['pool_name'], metadata['image_name']),
+    'rbd/clone': (metadata) =>
+      this.createRbdFromTask(metadata['child_pool_name'], metadata['child_image_name']),
+    'rbd/copy': (metadata) =>
+      this.createRbdFromTask(metadata['dest_pool_name'], metadata['dest_image_name'])
+  };
+
+  private createRbdFromTask(pool: string, name: string): RbdModel {
+    const model = new RbdModel();
+    model.id = '-1';
+    model.name = name;
+    model.pool_name = pool;
+    return model;
+  }
+
   constructor(
     private authStorageService: AuthStorageService,
     private rbdService: RbdService,
     private dimlessBinaryPipe: DimlessBinaryPipe,
     private dimlessPipe: DimlessPipe,
-    private summaryService: SummaryService,
     private modalService: BsModalService,
-    private taskWrapper: TaskWrapperService
+    private taskWrapper: TaskWrapperService,
+    private taskListService: TaskListService
   ) {
     this.permission = this.authStorageService.getPermissions().rbdImage;
   }
@@ -119,132 +133,62 @@ export class RbdListComponent implements OnInit, OnDestroy {
       }
     ];
 
-    this.summaryDataSubscription = this.summaryService.subscribe(
-      (data: any) => {
-        if (data) {
-          this.loadImages(data.executing_tasks);
-        }
-      },
-      () => {
-        this.table.reset(); // Disable loading indicator.
-        this.viewCacheStatusList = [{ status: ViewCacheStatus.ValueException }];
-      }
+    this.taskListService.init(
+      () => this.rbdService.list(),
+      (resp) => this.prepareResponse(resp),
+      (images) => (this.images = images),
+      () => this.onFetchError(),
+      this.taskFilter,
+      this.itemFilter,
+      this.builders
     );
   }
 
-  ngOnDestroy() {
-    if (this.summaryDataSubscription) {
-      this.summaryDataSubscription.unsubscribe();
-    }
+  onFetchError() {
+    this.table.reset(); // Disable loading indicator.
+    this.viewCacheStatusList = [{ status: ViewCacheStatus.ValueException }];
   }
 
-  loadImages(executingTasks) {
-    this.rbdService.list().subscribe(
-      (resp: any[]) => {
-        let images = [];
-        const viewCacheStatusMap = {};
-        resp.forEach((pool) => {
-          if (_.isUndefined(viewCacheStatusMap[pool.status])) {
-            viewCacheStatusMap[pool.status] = [];
-          }
-          viewCacheStatusMap[pool.status].push(pool.pool_name);
-          images = images.concat(pool.value);
-        });
-        const viewCacheStatusList = [];
-        _.forEach(viewCacheStatusMap, (value: any, key) => {
-          viewCacheStatusList.push({
-            status: parseInt(key, 10),
-            statusFor:
-              (value.length > 1 ? 'pools ' : 'pool ') +
-              '<strong>' +
-              value.join('</strong>, <strong>') +
-              '</strong>'
-          });
-        });
-        this.viewCacheStatusList = viewCacheStatusList;
-        images.forEach((image) => {
-          image.executingTasks = this._getExecutingTasks(
-            executingTasks,
-            image.pool_name,
-            image.name
-          );
-        });
-        this.images = this.merge(images, executingTasks);
-      },
-      () => {
-        this.table.reset(); // Disable loading indicator.
-        this.viewCacheStatusList = [{ status: ViewCacheStatus.ValueException }];
-      }
-    );
-  }
-
-  _getExecutingTasks(executingTasks: ExecutingTask[], poolName, imageName): ExecutingTask[] {
-    const result: ExecutingTask[] = [];
-    executingTasks.forEach((executingTask) => {
-      if (
-        executingTask.name === 'rbd/snap/create' ||
-        executingTask.name === 'rbd/snap/delete' ||
-        executingTask.name === 'rbd/snap/edit' ||
-        executingTask.name === 'rbd/snap/rollback'
-      ) {
-        if (
-          poolName === executingTask.metadata['pool_name'] &&
-          imageName === executingTask.metadata['image_name']
-        ) {
-          result.push(executingTask);
-        }
+  prepareResponse(resp: any[]): any[] {
+    let images = [];
+    const viewCacheStatusMap = {};
+    resp.forEach((pool) => {
+      if (_.isUndefined(viewCacheStatusMap[pool.status])) {
+        viewCacheStatusMap[pool.status] = [];
       }
+      viewCacheStatusMap[pool.status].push(pool.pool_name);
+      images = images.concat(pool.value);
     });
-    return result;
-  }
-
-  private merge(rbds: RbdModel[], executingTasks: ExecutingTask[] = []) {
-    const resultRBDs = _.clone(rbds);
-    executingTasks.forEach((executingTask) => {
-      const rbdExecuting = resultRBDs.find((rbd) => {
-        return (
-          rbd.pool_name === executingTask.metadata['pool_name'] &&
-          rbd.name === executingTask.metadata['image_name']
-        );
+    const viewCacheStatusList = [];
+    _.forEach(viewCacheStatusMap, (value: any, key) => {
+      viewCacheStatusList.push({
+        status: parseInt(key, 10),
+        statusFor:
+          (value.length > 1 ? 'pools ' : 'pool ') +
+          '<strong>' +
+          value.join('</strong>, <strong>') +
+          '</strong>'
       });
-      if (rbdExecuting) {
-        if (executingTask.name === 'rbd/delete') {
-          rbdExecuting.cdExecuting = 'deleting';
-        } else if (executingTask.name === 'rbd/edit') {
-          rbdExecuting.cdExecuting = 'updating';
-        } else if (executingTask.name === 'rbd/flatten') {
-          rbdExecuting.cdExecuting = 'flattening';
-        }
-      } else if (executingTask.name === 'rbd/create') {
-        const rbdModel = new RbdModel();
-        rbdModel.name = executingTask.metadata['image_name'];
-        rbdModel.pool_name = executingTask.metadata['pool_name'];
-        rbdModel.cdExecuting = 'creating';
-        this.pushIfNotExists(resultRBDs, rbdModel);
-      } else if (executingTask.name === 'rbd/clone') {
-        const rbdModel = new RbdModel();
-        rbdModel.name = executingTask.metadata['child_image_name'];
-        rbdModel.pool_name = executingTask.metadata['child_pool_name'];
-        rbdModel.cdExecuting = 'cloning';
-        this.pushIfNotExists(resultRBDs, rbdModel);
-      } else if (executingTask.name === 'rbd/copy') {
-        const rbdModel = new RbdModel();
-        rbdModel.name = executingTask.metadata['dest_image_name'];
-        rbdModel.pool_name = executingTask.metadata['dest_pool_name'];
-        rbdModel.cdExecuting = 'copying';
-        this.pushIfNotExists(resultRBDs, rbdModel);
-      }
     });
-    return resultRBDs;
+    this.viewCacheStatusList = viewCacheStatusList;
+    return images;
   }
 
-  private pushIfNotExists(resultRBDs: RbdModel[], rbdModel: RbdModel) {
-    const exists = resultRBDs.some((resultRBD) => {
-      return resultRBD.name === rbdModel.name && resultRBD.pool_name === rbdModel.pool_name;
-    });
-    if (!exists) {
-      resultRBDs.push(rbdModel);
-    }
+  itemFilter(entry, task) {
+    return (
+      entry.pool_name === task.metadata['pool_name'] && entry.name === task.metadata['image_name']
+    );
+  }
+
+  taskFilter(task) {
+    return [
+      'rbd/clone',
+      'rbd/copy',
+      'rbd/create',
+      'rbd/delete',
+      'rbd/edit',
+      'rbd/flatten'
+    ].includes(task.name);
   }
 
   updateSelection(selection: CdTableSelection) {
index 8be8dafdb1a512091febc26d20341528c27ec852..92a77bdd6eaa4cf3e50d9dca726120cd2505c3bb 100644 (file)
@@ -1,4 +1,5 @@
 export class RbdModel {
+  id: string;
   name: string;
   pool_name: string;
 
index 09420cca7161e1e5f863b6cc02b575255e846268..a2afd1e58f18d3224324ff2fcd1c82acc08403fa 100644 (file)
@@ -11,16 +11,21 @@ import { ApiModule } from '../../../shared/api/api.module';
 import { RbdService } from '../../../shared/api/rbd.service';
 import { ComponentsModule } from '../../../shared/components/components.module';
 import { DataTableModule } from '../../../shared/datatable/datatable.module';
+import { ExecutingTask } from '../../../shared/models/executing-task';
 import { Permissions } from '../../../shared/models/permissions';
 import { PipesModule } from '../../../shared/pipes/pipes.module';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
 import { NotificationService } from '../../../shared/services/notification.service';
 import { ServicesModule } from '../../../shared/services/services.module';
+import { SummaryService } from '../../../shared/services/summary.service';
+import { TaskListService } from '../../../shared/services/task-list.service';
 import { RbdSnapshotListComponent } from './rbd-snapshot-list.component';
+import { RbdSnapshotModel } from './rbd-snapshot.model';
 
 describe('RbdSnapshotListComponent', () => {
   let component: RbdSnapshotListComponent;
   let fixture: ComponentFixture<RbdSnapshotListComponent>;
+  let summaryService: SummaryService;
 
   const fakeAuthStorageService = {
     isLoggedIn: () => {
@@ -44,12 +49,17 @@ describe('RbdSnapshotListComponent', () => {
       RouterTestingModule,
       PipesModule
     ],
-    providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }]
+    providers: [
+      { provide: AuthStorageService, useValue: fakeAuthStorageService },
+      SummaryService,
+      TaskListService
+    ]
   });
 
   beforeEach(() => {
     fixture = TestBed.createComponent(RbdSnapshotListComponent);
     component = fixture.componentInstance;
+    summaryService = TestBed.get(SummaryService);
     fixture.detectChanges();
   });
 
@@ -76,7 +86,9 @@ describe('RbdSnapshotListComponent', () => {
         null,
         rbdService,
         null,
-        notificationService
+        notificationService,
+        null,
+        null
       );
       spyOn(rbdService, 'deleteSnapshot').and.returnValue(observableThrowError({ status: 500 }));
       spyOn(notificationService, 'notifyTask').and.stub();
@@ -93,4 +105,71 @@ describe('RbdSnapshotListComponent', () => {
       expect(called).toBe(true);
     }));
   });
+
+  describe('handling of executing tasks', () => {
+    let snapshots: RbdSnapshotModel[];
+
+    const addSnapshot = (name) => {
+      const model = new RbdSnapshotModel();
+      model.id = 1;
+      model.name = name;
+      snapshots.push(model);
+    };
+
+    const addTask = (task_name: string, snapshot_name: string) => {
+      const task = new ExecutingTask();
+      task.name = task_name;
+      task.metadata = {
+        pool_name: 'rbd',
+        image_name: 'foo',
+        snapshot_name: snapshot_name
+      };
+      summaryService.addRunningTask(task);
+    };
+
+    const expectImageTasks = (snapshot: RbdSnapshotModel, executing: string) => {
+      expect(snapshot.cdExecuting).toEqual(executing);
+    };
+
+    const refresh = (data) => {
+      summaryService['summaryDataSource'].next(data);
+    };
+
+    beforeEach(() => {
+      snapshots = [];
+      addSnapshot('a');
+      addSnapshot('b');
+      addSnapshot('c');
+      component.snapshots = snapshots;
+      component.poolName = 'rbd';
+      component.rbdName = 'foo';
+      refresh({ executing_tasks: [], finished_tasks: [] });
+      component.ngOnChanges();
+      fixture.detectChanges();
+    });
+
+    it('should gets all snapshots without tasks', () => {
+      expect(component.snapshots.length).toBe(3);
+      expect(component.snapshots.every((image) => !image.cdExecuting)).toBeTruthy();
+    });
+
+    it('should add a new image from a task', () => {
+      addTask('rbd/snap/create', 'd');
+      expect(component.snapshots.length).toBe(4);
+      expectImageTasks(component.snapshots[0], undefined);
+      expectImageTasks(component.snapshots[1], undefined);
+      expectImageTasks(component.snapshots[2], undefined);
+      expectImageTasks(component.snapshots[3], 'Creating');
+    });
+
+    it('should show when an existing image is being modified', () => {
+      addTask('rbd/snap/edit', 'a');
+      addTask('rbd/snap/delete', 'b');
+      addTask('rbd/snap/rollback', 'c');
+      expect(component.snapshots.length).toBe(3);
+      expectImageTasks(component.snapshots[0], 'Updating');
+      expectImageTasks(component.snapshots[1], 'Deleting');
+      expectImageTasks(component.snapshots[2], 'Rolling back');
+    });
+  });
 });
index af27a265b88aa3d47bf00620229e00506b3aaddc..14b1168bae735350893257d8a48488da8a26e15e 100644 (file)
@@ -2,6 +2,7 @@ import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@an
 
 import * as _ from 'lodash';
 import { BsModalRef, BsModalService } from 'ngx-bootstrap';
+import { of } from 'rxjs';
 
 import { RbdService } from '../../../shared/api/rbd.service';
 import { ConfirmationModalComponent } from '../../../shared/components/confirmation-modal/confirmation-modal.component';
@@ -16,6 +17,8 @@ import { CdDatePipe } from '../../../shared/pipes/cd-date.pipe';
 import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
 import { NotificationService } from '../../../shared/services/notification.service';
+import { SummaryService } from '../../../shared/services/summary.service';
+import { TaskListService } from '../../../shared/services/task-list.service';
 import { TaskManagerService } from '../../../shared/services/task-manager.service';
 import { RbdSnapshotFormComponent } from '../rbd-snapshot-form/rbd-snapshot-form.component';
 import { RbdSnapshotModel } from './rbd-snapshot.model';
@@ -23,7 +26,8 @@ import { RbdSnapshotModel } from './rbd-snapshot.model';
 @Component({
   selector: 'cd-rbd-snapshot-list',
   templateUrl: './rbd-snapshot-list.component.html',
-  styleUrls: ['./rbd-snapshot-list.component.scss']
+  styleUrls: ['./rbd-snapshot-list.component.scss'],
+  providers: [TaskListService]
 })
 export class RbdSnapshotListComponent implements OnInit, OnChanges {
   @Input()
@@ -32,9 +36,6 @@ export class RbdSnapshotListComponent implements OnInit, OnChanges {
   poolName: string;
   @Input()
   rbdName: string;
-  @Input()
-  executingTasks: ExecutingTask[] = [];
-
   @ViewChild('nameTpl')
   nameTpl: TemplateRef<any>;
   @ViewChild('protectTpl')
@@ -52,6 +53,14 @@ export class RbdSnapshotListComponent implements OnInit, OnChanges {
 
   selection = new CdTableSelection();
 
+  builders = {
+    'rbd/snap/create': (metadata) => {
+      const model = new RbdSnapshotModel();
+      model.name = metadata['snapshot_name'];
+      return model;
+    }
+  };
+
   constructor(
     private authStorageService: AuthStorageService,
     private modalService: BsModalService,
@@ -59,7 +68,9 @@ export class RbdSnapshotListComponent implements OnInit, OnChanges {
     private cdDatePipe: CdDatePipe,
     private rbdService: RbdService,
     private taskManagerService: TaskManagerService,
-    private notificationService: NotificationService
+    private notificationService: NotificationService,
+    private summaryService: SummaryService,
+    private taskListService: TaskListService
   ) {
     this.permission = this.authStorageService.getPermissions().rbdImage;
   }
@@ -103,40 +114,29 @@ export class RbdSnapshotListComponent implements OnInit, OnChanges {
   }
 
   ngOnChanges() {
-    this.data = this.merge(this.snapshots, this.executingTasks);
-  }
+    const itemFilter = (entry, task) => {
+      return entry.name === task.metadata['snapshot_name'];
+    };
 
-  private merge(snapshots: RbdSnapshotModel[], executingTasks: ExecutingTask[] = []) {
-    const resultSnapshots = _.clone(snapshots);
-    executingTasks.forEach((executingTask) => {
-      const snapshotExecuting = resultSnapshots.find((snapshot) => {
-        return snapshot.name === executingTask.metadata['snapshot_name'];
-      });
-      if (snapshotExecuting) {
-        if (executingTask.name === 'rbd/snap/delete') {
-          snapshotExecuting.cdExecuting = 'deleting';
-        } else if (executingTask.name === 'rbd/snap/edit') {
-          snapshotExecuting.cdExecuting = 'updating';
-        } else if (executingTask.name === 'rbd/snap/rollback') {
-          snapshotExecuting.cdExecuting = 'rolling back';
-        }
-      } else if (executingTask.name === 'rbd/snap/create') {
-        const rbdSnapshotModel = new RbdSnapshotModel();
-        rbdSnapshotModel.name = executingTask.metadata['snapshot_name'];
-        rbdSnapshotModel.cdExecuting = 'creating';
-        this.pushIfNotExists(resultSnapshots, rbdSnapshotModel);
-      }
-    });
-    return resultSnapshots;
-  }
+    const taskFilter = (task) => {
+      return (
+        ['rbd/snap/create', 'rbd/snap/delete', 'rbd/snap/edit', 'rbd/snap/rollback'].includes(
+          task.name
+        ) &&
+        this.poolName === task.metadata['pool_name'] &&
+        this.rbdName === task.metadata['image_name']
+      );
+    };
 
-  private pushIfNotExists(resultSnapshots: RbdSnapshotModel[], rbdSnapshotModel: RbdSnapshotModel) {
-    const exists = resultSnapshots.some((resultSnapshot) => {
-      return resultSnapshot.name === rbdSnapshotModel.name;
-    });
-    if (!exists) {
-      resultSnapshots.push(rbdSnapshotModel);
-    }
+    this.taskListService.init(
+      () => of(this.snapshots),
+      null,
+      (items) => (this.data = items),
+      () => (this.data = this.snapshots),
+      taskFilter,
+      itemFilter,
+      this.builders
+    );
   }
 
   private openSnapshotModal(taskName: string, oldSnapshotName: string = null) {
@@ -149,8 +149,12 @@ export class RbdSnapshotListComponent implements OnInit, OnChanges {
     this.modalRef.content.onSubmit.subscribe((snapshotName: string) => {
       const executingTask = new ExecutingTask();
       executingTask.name = taskName;
-      executingTask.metadata = { snapshot_name: snapshotName };
-      this.executingTasks.push(executingTask);
+      executingTask.metadata = {
+        image_name: this.rbdName,
+        pool_name: this.poolName,
+        snapshot_name: snapshotName
+      };
+      this.summaryService.addRunningTask(executingTask);
       this.ngOnChanges();
     });
   }
@@ -180,7 +184,7 @@ export class RbdSnapshotListComponent implements OnInit, OnChanges {
         const executingTask = new ExecutingTask();
         executingTask.name = finishedTask.name;
         executingTask.metadata = finishedTask.metadata;
-        this.executingTasks.push(executingTask);
+        this.summaryService.addRunningTask(executingTask);
         this.ngOnChanges();
         this.taskManagerService.subscribe(
           finishedTask.name,
@@ -206,7 +210,7 @@ export class RbdSnapshotListComponent implements OnInit, OnChanges {
         const executingTask = new ExecutingTask();
         executingTask.name = finishedTask.name;
         executingTask.metadata = finishedTask.metadata;
-        this.executingTasks.push(executingTask);
+        this.summaryService.addRunningTask(executingTask);
         this.modalRef.hide();
         this.ngOnChanges();
         this.taskManagerService.subscribe(
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.spec.ts
new file mode 100644 (file)
index 0000000..fa7966e
--- /dev/null
@@ -0,0 +1,129 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { of } from 'rxjs';
+
+import { configureTestBed } from '../../../testing/unit-test-helper';
+import { ExecutingTask } from '../models/executing-task';
+import { SummaryService } from './summary.service';
+import { TaskListService } from './task-list.service';
+import { TaskMessageService } from './task-message.service';
+
+describe('TaskListService', () => {
+  let service: TaskListService;
+  let summaryService: SummaryService;
+  let taskMessageService: TaskMessageService;
+
+  let reset: boolean;
+  let list: any[];
+  let apiResp: any;
+  let tasks: any[];
+
+  const addItem = (name) => {
+    apiResp.push({ name: name });
+  };
+
+  configureTestBed({
+    providers: [TaskListService, TaskMessageService, SummaryService],
+    imports: [HttpClientTestingModule, RouterTestingModule]
+  });
+
+  beforeEach(() => {
+    service = TestBed.get(TaskListService);
+    summaryService = TestBed.get(SummaryService);
+    taskMessageService = TestBed.get(TaskMessageService);
+    summaryService['summaryDataSource'].next({ executing_tasks: [] });
+
+    taskMessageService.messages['test/create'] = taskMessageService.messages['rbd/create'];
+    taskMessageService.messages['test/edit'] = taskMessageService.messages['rbd/edit'];
+    taskMessageService.messages['test/delete'] = taskMessageService.messages['rbd/delete'];
+
+    reset = false;
+    tasks = [];
+    apiResp = [];
+    list = [];
+    addItem('a');
+    addItem('b');
+    addItem('c');
+
+    service.init(
+      () => of(apiResp),
+      undefined,
+      (updatedList) => (list = updatedList),
+      () => (reset = true),
+      (task) => task.name.startsWith('test'),
+      (item, task) => item.name === task.metadata['name'],
+      {
+        default: (task) => ({ name: task.metadata['name'] })
+      }
+    );
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+
+  const addTask = (name: string, itemName: string) => {
+    const task = new ExecutingTask();
+    task.name = name;
+    task.metadata = { name: itemName };
+    tasks.push(task);
+    summaryService.addRunningTask(task);
+  };
+
+  const expectItemTasks = (item: any, executing: string) => {
+    expect(item.cdExecuting).toBe(executing);
+  };
+
+  it('gets all items without any executing items', () => {
+    expect(list.length).toBe(3);
+    expect(list.every((item) => !item.cdExecuting)).toBeTruthy();
+  });
+
+  it('gets an item from a task during creation', () => {
+    addTask('test/create', 'd');
+    expect(list.length).toBe(4);
+    expectItemTasks(list[3], 'Creating');
+  });
+
+  it('gets all items with one executing items', () => {
+    addTask('test/create', 'a');
+    expect(list.length).toBe(3);
+    expectItemTasks(list[0], 'Creating');
+    expectItemTasks(list[1], undefined);
+    expectItemTasks(list[2], undefined);
+  });
+
+  it('gets all items with multiple executing items', () => {
+    addTask('test/create', 'a');
+    addTask('test/edit', 'a');
+    addTask('test/delete', 'a');
+    addTask('test/edit', 'b');
+    addTask('test/delete', 'b');
+    addTask('test/delete', 'c');
+    expect(list.length).toBe(3);
+    expectItemTasks(list[0], 'Creating, Updating, Deleting');
+    expectItemTasks(list[1], 'Updating, Deleting');
+    expectItemTasks(list[2], 'Deleting');
+  });
+
+  it('gets all items with multiple executing tasks (not only item tasks', () => {
+    addTask('rbd/create', 'a');
+    addTask('rbd/edit', 'a');
+    addTask('test/delete', 'a');
+    addTask('test/edit', 'b');
+    addTask('rbd/delete', 'b');
+    addTask('rbd/delete', 'c');
+    expect(list.length).toBe(3);
+    expectItemTasks(list[0], 'Deleting');
+    expectItemTasks(list[1], 'Updating');
+    expectItemTasks(list[2], undefined);
+  });
+
+  it('should call ngOnDestroy', () => {
+    expect(service.summaryDataSubscription.closed).toBeFalsy();
+    service.ngOnDestroy();
+    expect(service.summaryDataSubscription.closed).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.ts
new file mode 100644 (file)
index 0000000..6b9e355
--- /dev/null
@@ -0,0 +1,101 @@
+import { Injectable, OnDestroy } from '@angular/core';
+
+import { Observable, Subscription } from 'rxjs';
+
+import { ExecutingTask } from '../models/executing-task';
+import { SummaryService } from './summary.service';
+import { TaskMessageService } from './task-message.service';
+
+@Injectable()
+export class TaskListService implements OnDestroy {
+  summaryDataSubscription: Subscription;
+
+  getUpdate: () => Observable<object>;
+  preProcessing: (_: any) => any[];
+  setList: (_: any[]) => void;
+  onFetchError: (error: any) => void;
+  taskFilter: (task: ExecutingTask) => boolean;
+  itemFilter: (item, task: ExecutingTask) => boolean;
+  builders: object;
+
+  constructor(
+    private taskMessageService: TaskMessageService,
+    private summaryService: SummaryService
+  ) {}
+
+  /**
+   * @param {() => Observable<object>} getUpdate Method that calls the api and
+   * returns that without subscribing.
+   * @param {(_: any) => any[]} preProcessing Method executed before merging
+   * Tasks with Items
+   * @param {(_: any[]) => void} setList  Method used to update array of item in the component.
+   * @param {(error: any) => void} onFetchError Method called when there were
+   * problems while fetching data.
+   * @param {(task: ExecutingTask) => boolean} taskFilter callback used in tasks_array.filter()
+   * @param {(item, task: ExecutingTask) => boolean} itemFilter callback used in
+   * items_array.filter()
+   * @param {object} builders
+   * object with builders for each type of task.
+   * You can also use a 'default' one.
+   * @memberof TaskListService
+   */
+  init(
+    getUpdate: () => Observable<object>,
+    preProcessing: (_: any) => any[],
+    setList: (_: any[]) => void,
+    onFetchError: (error: any) => void,
+    taskFilter: (task: ExecutingTask) => boolean,
+    itemFilter: (item, task: ExecutingTask) => boolean,
+    builders: object
+  ) {
+    this.getUpdate = getUpdate;
+    this.preProcessing = preProcessing;
+    this.setList = setList;
+    this.onFetchError = onFetchError;
+    this.taskFilter = taskFilter;
+    this.itemFilter = itemFilter;
+    this.builders = builders;
+
+    this.summaryDataSubscription = this.summaryService.subscribe((tasks: any) => {
+      if (tasks) {
+        this.getUpdate().subscribe((resp: any) => {
+          this.updateData(resp, tasks.executing_tasks.filter(this.taskFilter));
+        }, this.onFetchError);
+      }
+    }, this.onFetchError);
+  }
+
+  private updateData(resp: any, tasks: ExecutingTask[]) {
+    const data: any[] = this.preProcessing ? this.preProcessing(resp) : resp;
+    this.addMissing(data, tasks);
+    data.forEach((item) => {
+      const executingTasks = tasks.filter((task) => this.itemFilter(item, task));
+      item.cdExecuting = this.getTaskAction(executingTasks);
+    });
+    this.setList(data);
+  }
+
+  private addMissing(data: any[], tasks: ExecutingTask[]) {
+    const defaultBuilder = this.builders['default'];
+    tasks.forEach((task) => {
+      const existing = data.find((item) => this.itemFilter(item, task));
+      const builder = this.builders[task.name];
+      if (!existing && (builder || defaultBuilder)) {
+        data.push(builder ? builder(task.metadata) : defaultBuilder(task));
+      }
+    });
+  }
+
+  private getTaskAction(tasks: ExecutingTask[]): string {
+    if (tasks.length === 0) {
+      return;
+    }
+    return tasks.map((task) => this.taskMessageService.getRunningText(task)).join(', ');
+  }
+
+  ngOnDestroy() {
+    if (this.summaryDataSubscription) {
+      this.summaryDataSubscription.unsubscribe();
+    }
+  }
+}
index 444f274cc78ce4173fdf76f748cbd0a1bf807604..cb4308da0b04c81225bfe85517e0d4ce77834fa8 100644 (file)
@@ -152,4 +152,8 @@ export class TaskMessageService {
   getRunningTitle(task: Task) {
     return this._getTaskTitle(task).running(task.metadata);
   }
+
+  getRunningText(task: Task) {
+    return this._getTaskTitle(task).operation.running;
+  }
 }