]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Asynchronous tasks front-end 20962/head
authorRicardo Marques <rimarques@suse.com>
Mon, 19 Mar 2018 15:06:51 +0000 (15:06 +0000)
committerRicardo Marques <rimarques@suse.com>
Thu, 12 Apr 2018 11:13:17 +0000 (12:13 +0100)
Signed-off-by: Ricardo Marques <rimarques@suse.com>
19 files changed:
src/pybind/mgr/dashboard/HACKING.rst
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/executing-task.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/finished-task.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/task-exception.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/task.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-interceptor.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/services.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.ts [new file with mode: 0644]

index b17c17b2deeb5fd87a9fe9647f5fa33c91a740d4..a5084f2dd2e06f00504181d1aa4913e90693275f 100644 (file)
@@ -838,3 +838,70 @@ updates its progress:
           task = TaskManager.run("dummy/task", {}, self._dummy)
           return task.wait(5)  # wait for five seconds
 
+
+How to deal with asynchronous tasks in the front-end?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+All executing and most recently finished asynchronous tasks are displayed on the
+"Backgroud-Tasks" menu.
+
+The front-end developer should provide a description, success message and error
+messages for each task on ``TaskManagerMessageService.messages``.
+This messages can make use of the task metadata to provide more personalized messages.
+
+When submitting an asynchronous task, the developer should provide a callback
+that will be automatically triggered after the execution of that task.
+This can be done by using the ``TaskManagerService.subscribe``.
+
+Most of the times, all we want to do after a task completes the execution, is
+displaying a notification message based on the execution result. The
+``NotificationService.notifyTask`` will use the messages from
+``TaskManagerMessageService`` to display a success / error message based on the
+execution result of a task.
+
+Usage example:
+
+.. code-block:: javascript
+
+  export class TaskManagerMessageService {
+
+    messages = {
+      // Messages for 'rbd/create' task
+      'rbd/create': new TaskManagerMessage(
+        // Description
+        (metadata) => `Create RBD '${metadata.pool_name}/${metadata.image_name}'`,
+        // Success message
+        (metadata) => `RBD '${metadata.pool_name}/${metadata.image_name}'
+                       have been created successfully`,
+        // Error messages
+        (metadata) => {
+          return {
+            '17': `Name '${metadata.pool_name}/${metadata.image_name}' is already
+                   in use.`
+          };
+        }
+      ),
+      // ...
+    };
+
+    // ...
+  }
+
+  export class RBDFormComponent {
+    // ...
+
+    submit() {
+      // ...
+      this.rbdService.create(request).then((resp) => {
+        // Subscribe the submitted task
+        this.taskManagerService.subscribe('rbd/create',
+          {'pool_name': request.pool_name, 'rbd_name': request.name},
+          // Callback that will be invoked after task is finished
+          (finishedTask: FinishedTask) => {
+            // Will display a notification message (success or error)
+            this.notificationService.notifyTask(finishedTask, finishedTask.ret_value.success);
+          });
+        // ...
+      })
+    }
+  }
index 6c11b05fd07d0a5bdb358a118b023eeea166dd0b..6965506256b6745c648315d251acf94133fd2349 100644 (file)
@@ -10,6 +10,7 @@ import { SharedModule } from '../../shared/shared.module';
 import { AuthModule } from '../auth/auth.module';
 import { NavigationComponent } from './navigation/navigation.component';
 import { NotificationsComponent } from './notifications/notifications.component';
+import { TaskManagerComponent } from './task-manager/task-manager.component';
 
 @NgModule({
   imports: [
@@ -21,7 +22,7 @@ import { NotificationsComponent } from './notifications/notifications.component'
     SharedModule,
     RouterModule
   ],
-  declarations: [NavigationComponent, NotificationsComponent],
+  declarations: [NavigationComponent, NotificationsComponent, TaskManagerComponent],
   exports: [NavigationComponent]
 })
 export class NavigationModule {}
index a00e7cef6fe573dceb5cae13abab0a44a7fbc5f4..99bf8380912ca753a1d50ab2be32355217f4e310 100644 (file)
     <!-- /.navbar-primary -->
 
     <ul class="nav navbar-nav navbar-utility">
+      <li>
+        <cd-task-manager class="oa-navbar"></cd-task-manager>
+      </li>
       <li>
         <cd-notifications class="oa-navbar"></cd-notifications>
       </li>
-
       <li class="tc_logout">
         <cd-logout class="oa-navbar"></cd-logout>
       </li>
index e2df965f70c4b3c838cb150b9cd9817563face83..c54bab6eeafc43ca01c303e1b96a164f1559106e 100644 (file)
@@ -8,13 +8,14 @@ import { NotificationService } from '../../../shared/services/notification.servi
 import { SharedModule } from '../../../shared/shared.module';
 import { LogoutComponent } from '../../auth/logout/logout.component';
 import { NotificationsComponent } from '../notifications/notifications.component';
+import { TaskManagerComponent } from '../task-manager/task-manager.component';
 import { NavigationComponent } from './navigation.component';
 
 describe('NavigationComponent', () => {
   let component: NavigationComponent;
   let fixture: ComponentFixture<NavigationComponent>;
 
-  const fakeService = new NotificationService(null);
+  const fakeService = new NotificationService(null, null);
 
   beforeEach(
     async(() => {
@@ -25,7 +26,12 @@ describe('NavigationComponent', () => {
           HttpClientTestingModule,
           PopoverModule.forRoot()
         ],
-        declarations: [NavigationComponent, NotificationsComponent, LogoutComponent],
+        declarations: [
+          NavigationComponent,
+          NotificationsComponent,
+          LogoutComponent,
+          TaskManagerComponent
+        ],
         providers: [{ provide: NotificationService, useValue: fakeService }]
       }).compileComponents();
     })
index b4043fc427d919d8fc6bd6b760b78beeed148b1b..361167a7e7a4befcaf62c5e438decd43b9df049a 100644 (file)
@@ -9,7 +9,7 @@ describe('NotificationsComponent', () => {
   let component: NotificationsComponent;
   let fixture: ComponentFixture<NotificationsComponent>;
 
-  const fakeService = new NotificationService(null);
+  const fakeService = new NotificationService(null, null);
 
   beforeEach(
     async(() => {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.html
new file mode 100644 (file)
index 0000000..c58c678
--- /dev/null
@@ -0,0 +1,77 @@
+<ng-template #popTemplate>
+  <!-- Executing -->
+  <div *ngIf="executingTasks.length > 0">
+    <div class="separator">
+      EXECUTING
+    </div>
+    <hr>
+    <div *ngFor="let executingTask of executingTasks">
+      <table>
+        <tr>
+          <td class="icon-col">
+            <i class="fa fa-spinner fa-spin fa-fw" aria-hidden="true"></i>
+          </td>
+          <td>{{ executingTask.description }}</td>
+          <td class="text-right italic" *ngIf="executingTask.progress"><span>{{ executingTask.progress }} %</span></td>
+        </tr>
+        <tr>
+          <td colspan="3" class="text-right">
+            <span class="date">{{ executingTask.begin_time | cdDate }}</span></td>
+        </tr>
+      </table>
+      <hr>
+    </div>
+  </div>
+  <!-- Finished -->
+  <div *ngIf="finishedTasks.length > 0">
+    <div class="separator">
+      FINISHED
+    </div>
+    <hr>
+    <div *ngFor="let finishedTask of finishedTasks">
+      <table>
+        <tr>
+          <td class="icon-col">
+            <span *ngIf="!finishedTask.errorMessage">
+              <i class="fa fa-check text-success"></i>
+            </span>
+            <span *ngIf="finishedTask.errorMessage">
+              <i class="fa fa-exclamation-triangle text-danger"></i>
+            </span>
+          </td>
+          <td colspan="2">
+            {{ finishedTask.description }}
+          </td>
+        </tr>
+        <tr>
+          <td></td>
+          <td>
+            <span *ngIf="finishedTask.errorMessage" class="text-danger">
+              {{ finishedTask.errorMessage }}
+            </span>
+          </td>
+        </tr>
+        <tr>
+          <td colspan="2" class="text-right">
+            <span class="date">{{ finishedTask.end_time | cdDate }}</span>
+          </td>
+        </tr>
+      </table>
+      <hr>
+    </div>
+  </div>
+  <!-- Empty -->
+  <div *ngIf="executingTasks.length === 0 && finishedTasks.length === 0">
+    <div class="message">
+      There are no background tasks.
+    </div>
+  </div>
+</ng-template>
+<a [popover]="popTemplate"
+   placement="bottom"
+   container="body"
+   outsideClick="true">
+  <i class="fa"
+     [ngClass]="icon"></i>
+  Background-Tasks<span *ngIf="executingTasks.length > 0"> ({{ executingTasks.length }})</span>
+</a>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.scss
new file mode 100644 (file)
index 0000000..70cfd4a
--- /dev/null
@@ -0,0 +1 @@
+@import '../../../../styles/popover.scss';
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.spec.ts
new file mode 100644 (file)
index 0000000..01aa7f0
--- /dev/null
@@ -0,0 +1,34 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { PopoverModule } from 'ngx-bootstrap';
+
+import { SharedModule } from '../../../shared/shared.module';
+import { TaskManagerComponent } from './task-manager.component';
+
+describe('TaskManagerComponent', () => {
+  let component: TaskManagerComponent;
+  let fixture: ComponentFixture<TaskManagerComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      imports: [
+        SharedModule,
+        PopoverModule.forRoot(),
+        HttpClientTestingModule
+      ],
+      declarations: [ TaskManagerComponent ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(TaskManagerComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.ts
new file mode 100644 (file)
index 0000000..d9770b1
--- /dev/null
@@ -0,0 +1,48 @@
+import { Component, OnInit } from '@angular/core';
+
+import { ExecutingTask } from '../../../shared/models/executing-task';
+import { FinishedTask } from '../../../shared/models/finished-task';
+import { SummaryService } from '../../../shared/services/summary.service';
+import { TaskManagerMessageService } from '../../../shared/services/task-manager-message.service';
+
+@Component({
+  selector: 'cd-task-manager',
+  templateUrl: './task-manager.component.html',
+  styleUrls: ['./task-manager.component.scss']
+})
+export class TaskManagerComponent implements OnInit {
+
+  executingTasks: Array<ExecutingTask> = [];
+  finishedTasks: Array<FinishedTask> = [];
+
+  icon = 'fa-hourglass-o';
+
+  constructor(private summaryService: SummaryService,
+              private taskManagerMessageService: TaskManagerMessageService) {
+  }
+
+  ngOnInit() {
+    const icons = ['fa-hourglass-o', 'fa-hourglass-start', 'fa-hourglass-half', 'fa-hourglass-end'];
+    let iconIndex = 0;
+    this.summaryService.summaryData$.subscribe((data: any) => {
+      this.executingTasks = data.executing_tasks;
+      this.finishedTasks = data.finished_tasks;
+      for (const excutingTask of this.executingTasks) {
+        excutingTask.description = this.taskManagerMessageService.getDescription(excutingTask);
+      }
+      for (const finishedTask of this.finishedTasks) {
+        finishedTask.description = this.taskManagerMessageService.getDescription(finishedTask);
+        if (finishedTask.success === false) {
+          finishedTask.errorMessage = this.taskManagerMessageService.getErrorMessage(finishedTask);
+        }
+      }
+      if (this.executingTasks.length > 0) {
+        iconIndex = (iconIndex + 1) % icons.length;
+      } else {
+        iconIndex = 0;
+      }
+      this.icon = icons[iconIndex];
+    });
+  }
+
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/executing-task.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/executing-task.ts
new file mode 100644 (file)
index 0000000..27dc596
--- /dev/null
@@ -0,0 +1,6 @@
+import { Task } from './task';
+
+export class ExecutingTask extends Task {
+  begin_time: number;
+  progress: number;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/finished-task.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/finished-task.ts
new file mode 100644 (file)
index 0000000..3749faf
--- /dev/null
@@ -0,0 +1,14 @@
+import { Task } from './task';
+import { TaskException } from './task-exception';
+
+export class FinishedTask extends Task {
+  begin_time: number;
+  end_time: number;
+  exception: TaskException;
+  latency: number;
+  progress: number;
+  ret_value: any;
+  success: boolean;
+
+  errorMessage: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task-exception.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task-exception.ts
new file mode 100644 (file)
index 0000000..088cbbc
--- /dev/null
@@ -0,0 +1,6 @@
+export class TaskException {
+  status: number;
+  errno: number;
+  component: string;
+  detail: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task.ts
new file mode 100644 (file)
index 0000000..60ad1ab
--- /dev/null
@@ -0,0 +1,6 @@
+export class Task {
+  name: string;
+  metadata: object;
+
+  description: string;
+}
index ad391208267d3dc8961a7d5fa68b809d07341421..69a6d5734325832b2b4af085b47555f93cbe0fd9 100644 (file)
@@ -24,6 +24,14 @@ export class AuthInterceptorService implements HttpInterceptor {
     public notificationService: NotificationService
   ) {}
 
+  _notify (resp) {
+    this.notificationService.show(
+      NotificationType.error,
+      resp.error.detail || '',
+      `${resp.status} - ${resp.statusText}`
+    );
+  }
+
   intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
     return next.handle(request).catch(resp => {
       if (resp instanceof HttpErrorResponse) {
@@ -34,13 +42,11 @@ export class AuthInterceptorService implements HttpInterceptor {
           case 401:
             this.authStorageService.remove();
             this.router.navigate(['/login']);
-          // falls through
-          default:
-            this.notificationService.show(
-              NotificationType.error,
-              resp.error.detail || '',
-              `${resp.status} - ${resp.statusText}`
-            );
+            this._notify(resp);
+            break;
+          case 500:
+            this._notify(resp);
+            break;
         }
       }
       // Return the error to the method that called it.
index 768ec5e130a76f532ed6b0b1dd3f5075babae34d..e20f89612d468db53a847edaa9cbc49b17afc499 100644 (file)
@@ -3,11 +3,19 @@ import { inject, TestBed } from '@angular/core/testing';
 import { ToastOptions, ToastsManager } from 'ng2-toastr';
 
 import { NotificationService } from './notification.service';
+import { TaskManagerMessageService } from './task-manager-message.service';
+import { TaskManagerService } from './task-manager.service';
 
 describe('NotificationService', () => {
   beforeEach(() => {
     TestBed.configureTestingModule({
-      providers: [NotificationService, ToastsManager, ToastOptions]
+      providers: [
+        NotificationService,
+        ToastsManager,
+        ToastOptions,
+        TaskManagerService,
+        TaskManagerMessageService
+      ]
     });
   });
 
index 4770890b07b1baa14d05eb7523a7d2953a0dad40..88f83960e81bad734a63c7cf9981a8f7c5c9f21f 100644 (file)
@@ -6,6 +6,8 @@ import { BehaviorSubject } from 'rxjs/BehaviorSubject';
 
 import { NotificationType } from '../enum/notification-type.enum';
 import { CdNotification } from '../models/cd-notification';
+import { FinishedTask } from '../models/finished-task';
+import { TaskManagerMessageService } from './task-manager-message.service';
 
 @Injectable()
 export class NotificationService {
@@ -17,7 +19,8 @@ export class NotificationService {
 
   KEY = 'cdNotifications';
 
-  constructor(public toastr: ToastsManager) {
+  constructor(public toastr: ToastsManager,
+              private taskManagerMessageService: TaskManagerMessageService) {
     const stringNotifications = localStorage.getItem(this.KEY);
     let notifications: CdNotification[] = [];
 
@@ -82,4 +85,15 @@ export class NotificationService {
         break;
     }
   }
+
+  notifyTask(finishedTask: FinishedTask, success: boolean = true) {
+    if (finishedTask.success && success) {
+      this.show(NotificationType.success,
+        this.taskManagerMessageService.getSuccessMessage(finishedTask));
+    } else {
+      this.show(NotificationType.error,
+        this.taskManagerMessageService.getErrorMessage(finishedTask),
+        this.taskManagerMessageService.getDescription(finishedTask));
+    }
+  }
 }
index eddd3b02b479b05f449a4381598761ca2e308aa1..d9803cfe3790d212a0fd34159756f1692a8ff8e8 100644 (file)
@@ -1,11 +1,16 @@
 import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
 
+import { ConfigurationService } from '../api/configuration.service';
+import { RbdMirroringService } from '../api/rbd-mirroring.service';
+import { TcmuIscsiService } from '../api/tcmu-iscsi.service';
 import { AuthGuardService } from './auth-guard.service';
 import { AuthStorageService } from './auth-storage.service';
 import { FormatterService } from './formatter.service';
 import { NotificationService } from './notification.service';
 import { SummaryService } from './summary.service';
+import { TaskManagerMessageService } from './task-manager-message.service';
+import { TaskManagerService } from './task-manager.service';
 
 @NgModule({
   imports: [CommonModule],
@@ -15,7 +20,12 @@ import { SummaryService } from './summary.service';
     AuthStorageService,
     FormatterService,
     SummaryService,
-    NotificationService
+    NotificationService,
+    TcmuIscsiService,
+    ConfigurationService,
+    RbdMirroringService,
+    TaskManagerService,
+    TaskManagerMessageService
   ]
 })
 export class ServicesModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.ts
new file mode 100644 (file)
index 0000000..1eb70e1
--- /dev/null
@@ -0,0 +1,51 @@
+import { Injectable } from '@angular/core';
+import { FinishedTask } from '../models/finished-task';
+import { Task } from '../models/task';
+
+class TaskManagerMessage {
+  descr: (metadata) => string;
+  success: (metadata) => string;
+  error: (metadata) => object;
+
+  constructor(descr: (metadata) => string,
+              success: (metadata) => string,
+              error: (metadata) => object) {
+    this.descr = descr;
+    this.success = success;
+    this.error = error;
+  }
+}
+
+@Injectable()
+export class TaskManagerMessageService {
+
+  messages = {
+  };
+
+  defaultMessage = new TaskManagerMessage(
+    (metadata) => 'Unknown Task',
+    (metadata) => 'Task executed successfully',
+    () => {
+      return {
+      };
+    }
+  );
+
+  constructor() { }
+
+  getSuccessMessage(finishedTask: FinishedTask) {
+    const taskManagerMessage = this.messages[finishedTask.name] || this.defaultMessage;
+    return taskManagerMessage.success(finishedTask.metadata);
+  }
+
+  getErrorMessage(finishedTask: FinishedTask) {
+    const taskManagerMessage = this.messages[finishedTask.name] || this.defaultMessage;
+    return taskManagerMessage.error(finishedTask.metadata)[finishedTask.exception.errno] ||
+      finishedTask.exception.detail;
+  }
+
+  getDescription(task: Task) {
+    const taskManagerMessage = this.messages[task.name] || this.defaultMessage;
+    return taskManagerMessage.descr(task.metadata);
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.ts
new file mode 100644 (file)
index 0000000..cac1df0
--- /dev/null
@@ -0,0 +1,59 @@
+import { Injectable } from '@angular/core';
+
+import * as _ from 'lodash';
+
+import { ExecutingTask } from '../models/executing-task';
+import { FinishedTask } from '../models/finished-task';
+import { Task } from '../models/task';
+import { SummaryService } from './summary.service';
+
+class TaskSubscription {
+  name: string;
+  metadata: object;
+  onTaskFinished: (finishedTask: FinishedTask) => any;
+
+  constructor(name, metadata, onTaskFinished) {
+    this.name = name;
+    this.metadata = metadata;
+    this.onTaskFinished = onTaskFinished;
+  }
+}
+
+@Injectable()
+export class TaskManagerService {
+
+  subscriptions: Array<TaskSubscription> = [];
+
+  constructor(private summaryService: SummaryService) {
+    summaryService.summaryData$.subscribe((data: any) => {
+      const executingTasks = data.executing_tasks;
+      const finishedTasks = data.finished_tasks;
+      const newSubscriptions: Array<TaskSubscription> = [];
+      for (const subscription of this.subscriptions) {
+        const finishedTask = <FinishedTask>this._getTask(subscription, finishedTasks);
+        const executingTask = <ExecutingTask>this._getTask(subscription, executingTasks);
+        if (finishedTask !== null && executingTask === null) {
+          subscription.onTaskFinished(finishedTask);
+        }
+        if (executingTask !== null) {
+          newSubscriptions.push(subscription);
+        }
+        this.subscriptions = newSubscriptions;
+      }
+    });
+  }
+
+  subscribe(name, metadata, onTaskFinished: (finishedTask: FinishedTask) => any) {
+    this.subscriptions.push(new TaskSubscription(name, metadata, onTaskFinished));
+  }
+
+  _getTask(subscription: TaskSubscription, tasks: Array<Task>): Task {
+    for (const task of tasks) {
+      if (task.name === subscription.name &&
+        _.isEqual(task.metadata, subscription.metadata)) {
+        return task;
+      }
+    }
+    return null;
+  }
+}