From 587031c09f44018e2fbe55eade6d5600dca8d3cb Mon Sep 17 00:00:00 2001
From: Matthew Fuller <mf4192@att.com>
Date: Mon, 12 Oct 2020 19:04:32 +0000
Subject: [PATCH] Task viewer for airshipui

This change introduces a task viewer component to the UI
which will allow users to monitor the progress of long
running tasks without having to stay on a particular tab.
Tasks are created and attached to CTL event processors and
injected into phase clients so that status message updates
can be displayed dynamically.

Still TODO at some point is utilizing backend caching to tie
tasks to the users who initiated them so that browser refreshes
(i.e. new session IDs) won't empty the task viewer for that user.

Change-Id: I38aa03d2660d1fcc2bad6ecda718015602e25b6a
---
 client/src/app/app.component.css              |  33 ++--
 client/src/app/app.component.html             |   1 +
 client/src/app/app.module.ts                  |  18 +-
 client/src/app/ctl/phase/phase.component.css  |  24 +++
 client/src/app/ctl/phase/phase.component.html |  24 +--
 .../src/app/ctl/phase/phase.component.spec.ts |   4 +-
 client/src/app/ctl/phase/phase.component.ts   |  39 ++++-
 client/src/app/ctl/phase/phase.models.ts      |   3 +-
 client/src/app/task/task.component.css        |  64 +++++++
 client/src/app/task/task.component.html       |  40 +++++
 client/src/app/task/task.component.spec.ts    |  55 ++++++
 client/src/app/task/task.component.ts         | 137 +++++++++++++++
 client/src/app/task/task.models.ts            |  30 ++++
 client/src/app/task/task.module.ts            |  37 ++++
 client/src/assets/icons/check_circle.svg      |   1 +
 client/src/assets/icons/close.svg             |   1 +
 client/src/assets/icons/delete.svg            |   1 +
 client/src/services/icon/icons.enum.ts        |   4 +-
 .../services/websocket/websocket.service.ts   |   1 -
 go.mod                                        |   1 +
 go.sum                                        |   4 +
 pkg/configs/configs.go                        |   7 +
 pkg/ctl/phase.go                              |  76 ++++++---
 pkg/ctl/processor.go                          | 105 +++++++++---
 pkg/ctl/tree.go                               |   2 +-
 pkg/task/task.go                              | 159 ++++++++++++++++++
 26 files changed, 783 insertions(+), 88 deletions(-)
 create mode 100644 client/src/app/task/task.component.css
 create mode 100644 client/src/app/task/task.component.html
 create mode 100644 client/src/app/task/task.component.spec.ts
 create mode 100644 client/src/app/task/task.component.ts
 create mode 100644 client/src/app/task/task.models.ts
 create mode 100644 client/src/app/task/task.module.ts
 create mode 100644 client/src/assets/icons/check_circle.svg
 create mode 100644 client/src/assets/icons/close.svg
 create mode 100644 client/src/assets/icons/delete.svg
 create mode 100644 pkg/task/task.go

diff --git a/client/src/app/app.component.css b/client/src/app/app.component.css
index 36918e9..06776d1 100644
--- a/client/src/app/app.component.css
+++ b/client/src/app/app.component.css
@@ -1,17 +1,17 @@
-/*
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#     http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-*/
-
+/*
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+*/
+
 .main-container {
   display: flex;
   flex-direction: column;
@@ -52,6 +52,11 @@
   height: 48px;
 }
 
+.tasks {
+  padding-right: 15px;
+  color: white;
+}
+
 .mat-sidenav-content {
   min-height: 100%;
   display: flex;
diff --git a/client/src/app/app.component.html b/client/src/app/app.component.html
index ea0ae1b..941bc0d 100644
--- a/client/src/app/app.component.html
+++ b/client/src/app/app.component.html
@@ -57,6 +57,7 @@
       <mat-toolbar color="primary" class="toolbar-header">
         <button mat-icon-button (click)="sidenav.toggle()"><mat-icon svgIcon="list"></mat-icon></button>
         <span class="spacer"></span>
+        <app-task></app-task>
         <button mat-icon-button (click)="authToggle()" id="loginButton">Login</button>
       </mat-toolbar>
       <router-outlet></router-outlet>
diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts
index 4a5e8be..a8407aa 100644
--- a/client/src/app/app.module.ts
+++ b/client/src/app/app.module.ts
@@ -13,7 +13,7 @@
 */
 
 import { BrowserModule } from '@angular/platform-browser';
-import { NgModule } from '@angular/core';
+import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
 import { AppRoutingModule } from './app-routing.module';
 import { AppComponent } from './app.component';
 import { RouterModule } from '@angular/router';
@@ -33,6 +33,12 @@ import { MatTabsModule } from '@angular/material/tabs';
 import { CtlModule } from './ctl/ctl.module';
 import { MatProgressBarModule } from '@angular/material/progress-bar';
 import monacoConfig from './monaco-config';
+import { TaskModule } from './task/task.module';
+import { MatMenuModule } from '@angular/material/menu';
+import { TaskComponent } from './task/task.component';
+import { OverlayModule } from '@angular/cdk/overlay';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
 
 
 @NgModule({
@@ -49,14 +55,20 @@ import monacoConfig from './monaco-config';
     MatExpansionModule,
     MatListModule,
     MatProgressBarModule,
+    MatProgressSpinnerModule,
     MatToolbarModule,
     RouterModule,
     MatTabsModule,
     ToastrModule.forRoot(),
     MonacoEditorModule.forRoot(monacoConfig),
+    TaskModule,
+    MatMenuModule,
+    OverlayModule,
+    MatTooltipModule
   ],
-  declarations: [AppComponent],
+  declarations: [AppComponent, TaskComponent],
   providers: [WebsocketService],
-  bootstrap: [AppComponent]
+  bootstrap: [AppComponent],
+  schemas: [CUSTOM_ELEMENTS_SCHEMA]
 })
 export class AppModule { }
diff --git a/client/src/app/ctl/phase/phase.component.css b/client/src/app/ctl/phase/phase.component.css
index 2869f96..65361f8 100644
--- a/client/src/app/ctl/phase/phase.component.css
+++ b/client/src/app/ctl/phase/phase.component.css
@@ -35,6 +35,8 @@
 
 .docless-phase {
   padding-left: 20px;
+  display: flex;
+  flex-direction: row;
 }
 
 .docless-phase-btn {
@@ -87,6 +89,28 @@
   padding-right: 0px;
 }
 
+.phase-error {
+  width: 100%;
+}
+
+.tree-node-li {
+  width: 100%;
+}
+
+.has-children {
+  display: flex;
+  flex-direction: row;
+}
+
+.menu-button {
+  margin-left: auto;
+}
+
+.spinner {
+  margin-left: auto;
+  margin-right: 10px;
+}
+
 /* Editor styles */
 .editor-btn {
   margin-right: 5px;
diff --git a/client/src/app/ctl/phase/phase.component.html b/client/src/app/ctl/phase/phase.component.html
index 59e4e1f..607ef27 100755
--- a/client/src/app/ctl/phase/phase.component.html
+++ b/client/src/app/ctl/phase/phase.component.html
@@ -10,7 +10,7 @@
           <mat-card-content class="phase-card-content">
             <mat-tree [dataSource]="dataSource" [treeControl]="treeControl" class="phase-tree">
               <mat-tree-node *matTreeNodeDef="let node" matTreeNodeToggle>
-                <li class="mat-tree-node">
+                <li class="tree-node-li">
                   <div class="mat-tree-node">
                     <div *ngIf="!node.isPhaseNode">
                       <button class="get-yaml-btn" mat-icon-button (click)="!node.isPhaseNode && getYaml(node.id)">
@@ -22,17 +22,18 @@
                         <mat-icon class="error-icon" svgIcon="error"></mat-icon> {{node.name}}
                       </button>
                       <div *ngIf="node.isPhaseNode && !node.hasError" class="docless-phase">
-                        <button mat-button class="docless-phase-btn">{{node.name}}
-                        </button>
-                        <button mat-icon-button [matMenuTriggerFor]="menu">
-                          <mat-icon class="grey-icon">more_vert</mat-icon>
+                        <button mat-button class="docless-phase-btn">{{node.name}}</button>
+                        <span class="spacer"></span>
+                        <button class="menu-button" *ngIf="!node.running" mat-icon-button [matMenuTriggerFor]="menu">
+                          <mat-icon class="grey-icon" svgIcon="settings"></mat-icon>
                         </button>
+                        <mat-spinner *ngIf="node.running" class="spinner" [diameter]="20"></mat-spinner>
                         <mat-menu #menu="matMenu">
-                          <button mat-menu-item (click)="getPhase(node.phaseid)">
+                          <button mat-menu-item (click)="getPhase(node.phaseId)">
                             <mat-icon class="grey-icon" svgIcon="open_in_new"></mat-icon>
                             <span>View</span>
                           </button>
-                          <button mat-menu-item (click)="validatePhase(node.phaseid)">
+                          <button mat-menu-item (click)="validatePhase(node.phaseId)">
                             <mat-icon>check_circle_icon</mat-icon>
                             <span>Validate</span>
                           </button>
@@ -55,15 +56,16 @@
                         {{treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right'}}
                       </mat-icon>{{node.name}}
                     </button>
-                    <button *ngIf="node.isPhaseNode && !node.hasError" mat-icon-button [matMenuTriggerFor]="menu">
-                      <mat-icon class="grey-icon">more_vert</mat-icon>
+                    <button class="menu-button" *ngIf="node.isPhaseNode && !node.hasError && !node.running" mat-icon-button [matMenuTriggerFor]="menu">
+                      <mat-icon class="grey-icon" svgIcon="settings"></mat-icon>
                     </button>
+                    <mat-spinner *ngIf="node.running" class="spinner" [diameter]="20"></mat-spinner>
                     <mat-menu #menu="matMenu">
-                      <button mat-menu-item (click)="getPhase(node.phaseid)">
+                      <button mat-menu-item (click)="getPhase(node.phaseId)">
                         <mat-icon class="grey-icon" svgIcon="open_in_new"></mat-icon>
                         <span>View</span>
                       </button>
-                      <button mat-menu-item (click)="validatePhase(node.phaseid)">
+                      <button mat-menu-item (click)="validatePhase(node.phaseId)">
                         <mat-icon>check_circle_icon</mat-icon>
                         <span>Validate</span>
                       </button>
diff --git a/client/src/app/ctl/phase/phase.component.spec.ts b/client/src/app/ctl/phase/phase.component.spec.ts
index 3e0e284..2143884 100755
--- a/client/src/app/ctl/phase/phase.component.spec.ts
+++ b/client/src/app/ctl/phase/phase.component.spec.ts
@@ -28,6 +28,7 @@ import {MatProgressBarModule } from '@angular/material/progress-bar';
 import { MatTooltipModule } from '@angular/material/tooltip';
 import { MatDialogModule } from '@angular/material/dialog';
 import { MatMenuModule } from '@angular/material/menu';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
 
 describe('DocumentComponent', () => {
   let component: PhaseComponent;
@@ -49,7 +50,8 @@ describe('DocumentComponent', () => {
         MatProgressBarModule,
         MatTooltipModule,
         MatDialogModule,
-        MatMenuModule
+        MatMenuModule,
+        MatProgressSpinnerModule
       ],
       declarations: [PhaseComponent]
     })
diff --git a/client/src/app/ctl/phase/phase.component.ts b/client/src/app/ctl/phase/phase.component.ts
index 7210233..cda34a3 100755
--- a/client/src/app/ctl/phase/phase.component.ts
+++ b/client/src/app/ctl/phase/phase.component.ts
@@ -76,14 +76,14 @@ export class PhaseComponent implements WSReceiver {
     if (message.hasOwnProperty('error')) {
       this.websocketService.printIfToast(message);
       this.loading = false;
+      if (message.subComponent === 'run') {
+        this.toggleNode(message.id);
+      }
     } else {
       switch (message.subComponent) {
         case 'getTarget':
           this.targetPath = message.message;
           break;
-        case 'docPull':
-          this.statusMsg = 'Message pull was a ' + message.message;
-          break;
         case 'getPhaseTree':
           this.handleGetPhaseTree(message.data);
           break;
@@ -120,8 +120,7 @@ export class PhaseComponent implements WSReceiver {
   }
 
   handleRunPhase(message: WebsocketMessage): void {
-    this.running = false;
-    this.websocketService.printIfToast(message);
+    this.toggleNode(message.id);
   }
 
   handleGetPhaseTree(data: JSON): void {
@@ -215,7 +214,7 @@ export class PhaseComponent implements WSReceiver {
       width: '25vw',
       height: '30vh',
       data: {
-        id: node.phaseid,
+        id: node.phaseId,
         name: node.name,
         options: new RunOptions()
       }
@@ -270,9 +269,9 @@ export class PhaseComponent implements WSReceiver {
   // TODO(mfuller): we'll probably want to run / check phase validation
   // before actually running the phase
   runPhase(node: KustomNode, opts: RunOptions): void {
-    this.running = true;
+    node.running = true;
     const msg = this.newMessage('run');
-    msg.id = JSON.stringify(node.phaseid);
+    msg.id = JSON.stringify(node.phaseId);
     if (opts !== undefined) {
       msg.data = JSON.parse(JSON.stringify(opts));
     }
@@ -288,4 +287,28 @@ export class PhaseComponent implements WSReceiver {
   newMessage(subComponent: string): WebsocketMessage {
     return new WebsocketMessage(this.type, this.component, subComponent);
   }
+
+  findNode(node: KustomNode, id: string): KustomNode {
+    if (node.id === id) {
+      return node;
+    }
+
+    for (const child of node.children) {
+      const c = this.findNode(child, id);
+      if (c) {
+        return c;
+      }
+    }
+  }
+
+  toggleNode(id: string): void {
+    const phaseID = JSON.parse(id);
+    for (const node of this.phaseTree) {
+      const name = phaseID.Name as string;
+      if (node.phaseId.Name === name) {
+        node.running = false;
+        return;
+      }
+    }
+  }
 }
diff --git a/client/src/app/ctl/phase/phase.models.ts b/client/src/app/ctl/phase/phase.models.ts
index 8e968a6..39365bd 100644
--- a/client/src/app/ctl/phase/phase.models.ts
+++ b/client/src/app/ctl/phase/phase.models.ts
@@ -14,11 +14,12 @@
 
 export class KustomNode {
     id: string;
-    phaseid: { name: string, namespace: string};
+    phaseId: { Name: string, Namespace: string};
     name: string;
     canLoadChildren: boolean;
     children: KustomNode[];
     isPhaseNode: boolean;
+    running: boolean;
     hasError: boolean;
 }
 
diff --git a/client/src/app/task/task.component.css b/client/src/app/task/task.component.css
new file mode 100644
index 0000000..01d97a1
--- /dev/null
+++ b/client/src/app/task/task.component.css
@@ -0,0 +1,64 @@
+/*
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+*/
+
+.white-icon {
+    color: white;
+    margin-right: 25px;
+}
+
+.green-icon {
+    color: green;
+    height: 20px;
+    width: 20px;
+}
+
+.error-icon {
+    color: red;
+    height: 20px;
+    width: 20px;
+}
+
+.grey-icon {
+    color: grey;
+    height: 20px;
+    width: 20px;
+}
+
+.title {
+    margin-left: 15px;
+}
+
+.task-overlay {
+    width: 600px;
+    height: 400px;
+    background-color: white;
+}
+
+.overlay-container {
+    width: 600px;
+    height: 400px;
+    background-color: white;
+    border: 2px solid rgb(101, 199, 194);
+    padding: 5px;
+    overflow-y: auto;
+}
+
+.status-message {
+    word-wrap: break-word;
+    white-space: normal;
+}
+
+::ng-deep.multiline-tooltip {
+    white-space: pre-line;
+}
diff --git a/client/src/app/task/task.component.html b/client/src/app/task/task.component.html
new file mode 100644
index 0000000..ed10b5c
--- /dev/null
+++ b/client/src/app/task/task.component.html
@@ -0,0 +1,40 @@
+<button mat-icon-button (click)="isOpen = !isOpen" type="button" cdkOverlayOrigin #trigger="cdkOverlayOrigin">
+  <mat-icon *ngIf="!isOpen" class="white-icon" svgIcon="list_alt"
+    matTooltip="Running Tasks"
+    matTooltipPosition="below"
+    matTooltipShowDelay="1000"
+    matTooltipHideDelay="500"></mat-icon>
+  <mat-icon *ngIf="isOpen" class="white-icon" svgIcon="close"
+    matTooltip="Close"
+    matTooltipPosition="below"
+    matTooltipShowDelay="1000"
+    matTooltipHideDelay="500"></mat-icon>
+</button>
+
+<ng-template class="task-overlay" cdkConnectedOverlay [cdkConnectedOverlayOrigin]="trigger" [cdkConnectedOverlayOpen]="isOpen">
+  <div class="overlay-container">
+    <div class="overlay-header">
+      <h4 class="title">Running Tasks</h4>
+    </div>
+    <mat-list dense *ngFor="let task of tasks">
+      <mat-list-item [matTooltip]="taskToString(task)"
+          matTooltipPosition="below"
+          matTooltipShowDelay="1000"
+          matTooltipHideDelay="500"
+          matTooltipClass="multiline-tooltip">
+        <h4 matLine>{{task.name}}</h4>
+        <h6 class="status-message" matLine>{{task.progress.message}}</h6>
+        <mat-icon *ngIf="!task.running && task.progress.errors.length > 0" class="error-icon" svgIcon="error"></mat-icon>
+        <mat-icon *ngIf="!task.running && task.progress.errors.length == 0" class="green-icon" svgIcon="check_circle"></mat-icon>
+        <mat-spinner *ngIf="task.running" class="spinner" [diameter]="20"></mat-spinner>
+        <button *ngIf="!task.running" mat-icon-button (click)="taskRemove(task.id)"
+          matTooltip="Delete Task"
+          matTooltipPosition="below"
+          matTooltipShowDelay="1000"
+          matTooltipHideDelay="500">
+          <mat-icon svgIcon="close"></mat-icon>
+        </button>
+      </mat-list-item>
+    </mat-list>
+  </div>
+</ng-template>
diff --git a/client/src/app/task/task.component.spec.ts b/client/src/app/task/task.component.spec.ts
new file mode 100644
index 0000000..79a40a5
--- /dev/null
+++ b/client/src/app/task/task.component.spec.ts
@@ -0,0 +1,55 @@
+/*
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+*/
+
+import { OverlayModule } from '@angular/cdk/overlay';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatListModule } from '@angular/material/list';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { ToastrModule } from 'ngx-toastr';
+
+import { TaskComponent } from './task.component';
+
+describe('TaskComponent', () => {
+  let component: TaskComponent;
+  let fixture: ComponentFixture<TaskComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      imports: [
+        MatIconModule,
+        MatButtonModule,
+        MatListModule,
+        MatTooltipModule,
+        OverlayModule,
+        ToastrModule.forRoot(),
+        MatProgressSpinnerModule
+      ],
+      declarations: [ TaskComponent ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(TaskComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/client/src/app/task/task.component.ts b/client/src/app/task/task.component.ts
new file mode 100644
index 0000000..6090fe5
--- /dev/null
+++ b/client/src/app/task/task.component.ts
@@ -0,0 +1,137 @@
+/*
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+*/
+
+import { Component } from '@angular/core';
+import { WebsocketService } from '../../services/websocket/websocket.service';
+import { WSReceiver, WebsocketMessage } from '../../services/websocket/websocket.models';
+import { Task, Progress } from './task.models';
+import { Log } from '../../services/log/log.service';
+import { LogMessage } from '../../services/log/log-message';
+
+@Component({
+  selector: 'app-task',
+  templateUrl: './task.component.html',
+  styleUrls: ['./task.component.css']
+})
+export class TaskComponent implements WSReceiver {
+  className = this.constructor.name;
+  type = 'ui';
+  component = 'task';
+
+  message: string;
+  tasks: Task[] = [];
+  isOpen = false;
+
+  constructor(private websocketService: WebsocketService) {
+    this.websocketService.registerFunctions(this);
+  }
+
+  public async receiver(message: WebsocketMessage): Promise<void> {
+    if (message.hasOwnProperty('error')) {
+      this.websocketService.printIfToast(message);
+    } else {
+      switch (message.subComponent) {
+        case 'taskStart':
+          this.handleTaskStart(message);
+          break;
+        case 'taskUpdate':
+          this.handleTaskUpdate(message);
+          break;
+        case 'taskEnd':
+          this.handleTaskEnd(message);
+          break;
+        default:
+          Log.Error(new LogMessage('Task message sub component not handled', this.className, message));
+          break;
+      }
+    }
+  }
+
+  handleTaskStart(message: WebsocketMessage): void {
+    this.addTask(message);
+    const msg = new WebsocketMessage(this.type, this.component, message.subComponent);
+    msg.message = `${message.name} added to Running Tasks`;
+    msg.sessionID = message.sessionID;
+    this.websocketService.printIfToast(msg);
+  }
+
+  handleTaskUpdate(message: WebsocketMessage): void {
+    const task = this.findTask(message.id);
+    if (task !== null) {
+      Object.assign(task.progress, message.data);
+      if (task.progress.errors.length > 0) {
+        task.running = false;
+        task.progress.message = task.progress.errors.toString();
+      }
+    } else {
+      const msg = new WebsocketMessage(this.type, this.component, message.subComponent);
+      msg.sessionID = message.sessionID;
+      msg.message = `Task with id ${message.id} not found`;
+      this.websocketService.printIfToast(msg);
+    }
+  }
+
+  handleTaskEnd(message: WebsocketMessage): void {
+    const task = this.findTask(message.id);
+    if (task !== null) {
+      Object.assign(task.progress, message.data);
+      task.running = false;
+    } else {
+      const msg = new WebsocketMessage(this.type, this.component, message.subComponent);
+      msg.sessionID = message.sessionID;
+      msg.message = `Task with id ${message.id} not found`;
+      this.websocketService.printIfToast(msg);
+    }
+  }
+
+  taskRemove(id: string): void {
+    for (let i = 0; i < this.tasks.length; i++) {
+      if (this.tasks[i].id === id) {
+        this.tasks.splice(i, 1);
+      }
+    }
+  }
+
+  addTask(message: WebsocketMessage): void {
+    const p = new Progress();
+    Object.assign(p, message.data);
+
+    const task: Task = {
+      id: message.id,
+      name: message.name,
+      running: true,
+      progress: p
+    };
+    this.tasks.push(task);
+  }
+
+  findTask(id: string): Task {
+    for (const task of this.tasks) {
+      if (task.id === id) {
+        return task;
+      }
+    }
+    return null;
+  }
+
+  // TODO(mfuller): this was intended to be used for tooltip content, but
+  // I can't get the tooltip to show up on menu items, even with 'hello world'
+  taskToString(task: Task): string {
+    return `Name: ${task.name}
+    Start Time: ${new Date(task.progress.startTime).toUTCString()}
+    Last Updated: ${new Date(task.progress.lastUpdated).toUTCString()}
+    End Time: ${new Date(task.progress.endTime).toUTCString()}
+    Message: ${task.progress.message}`;
+  }
+}
diff --git a/client/src/app/task/task.models.ts b/client/src/app/task/task.models.ts
new file mode 100644
index 0000000..d9071a0
--- /dev/null
+++ b/client/src/app/task/task.models.ts
@@ -0,0 +1,30 @@
+/*
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+*/
+
+export class Task {
+    id: string;
+    name: string;
+    running: boolean;
+    progress: Progress;
+}
+
+export class Progress {
+    startTime: number;
+    endTime: number;
+    lastUpdated: number;
+    message: string;
+    totalSteps: number;
+    currentStep: number;
+    errors: string[];
+}
diff --git a/client/src/app/task/task.module.ts b/client/src/app/task/task.module.ts
new file mode 100644
index 0000000..650897d
--- /dev/null
+++ b/client/src/app/task/task.module.ts
@@ -0,0 +1,37 @@
+/*
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+*/
+
+import { NgModule } from '@angular/core';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatMenuModule } from '@angular/material/menu';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { OverlayModule } from '@angular/cdk/overlay';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { MatProgressBarModule } from '@angular/material/progress-bar';
+
+@NgModule({
+    imports: [
+      BrowserAnimationsModule,
+      MatIconModule,
+      MatMenuModule,
+      MatButtonModule,
+      MatTooltipModule,
+      OverlayModule,
+      MatProgressBarModule
+    ],
+    declarations: []
+})
+
+export class TaskModule { }
diff --git a/client/src/assets/icons/check_circle.svg b/client/src/assets/icons/check_circle.svg
new file mode 100644
index 0000000..12e2775
--- /dev/null
+++ b/client/src/assets/icons/check_circle.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
\ No newline at end of file
diff --git a/client/src/assets/icons/close.svg b/client/src/assets/icons/close.svg
new file mode 100644
index 0000000..e96a4e7
--- /dev/null
+++ b/client/src/assets/icons/close.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
\ No newline at end of file
diff --git a/client/src/assets/icons/delete.svg b/client/src/assets/icons/delete.svg
new file mode 100644
index 0000000..3acec0a
--- /dev/null
+++ b/client/src/assets/icons/delete.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
\ No newline at end of file
diff --git a/client/src/services/icon/icons.enum.ts b/client/src/services/icon/icons.enum.ts
index 224a70f..192d807 100644
--- a/client/src/services/icon/icons.enum.ts
+++ b/client/src/services/icon/icons.enum.ts
@@ -28,5 +28,7 @@ export enum Icons {
   settings = 'settings',
   camera = 'camera',
   list_alt = 'list_alt',
-  devices = 'devices'
+  devices = 'devices',
+  check_circle = 'check_circle',
+  close = 'close'
 }
diff --git a/client/src/services/websocket/websocket.service.ts b/client/src/services/websocket/websocket.service.ts
index 26817c4..993a6cc 100644
--- a/client/src/services/websocket/websocket.service.ts
+++ b/client/src/services/websocket/websocket.service.ts
@@ -204,7 +204,6 @@ export class WebsocketService implements OnDestroy {
     if (message.error !== undefined && message.error !== null) {
       this.toastrService.error(message.error);
     } else {
-      console.log(message);
       this.toastrService.info(message.message);
     }
   }
diff --git a/go.mod b/go.mod
index 03994b6..7ac2f1b 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@ require (
 	github.com/spf13/cobra v1.0.0
 	github.com/stretchr/testify v1.6.1
 	opendev.org/airship/airshipctl v0.0.0-20201007194648-8d6851511840
+	sigs.k8s.io/cli-utils v0.18.1
 	sigs.k8s.io/kustomize/api v0.5.1
 )
 
diff --git a/go.sum b/go.sum
index e3ae91e..1a25fa2 100644
--- a/go.sum
+++ b/go.sum
@@ -357,6 +357,7 @@ github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsC
 github.com/googleapis/gnostic v0.3.1 h1:WeAefnSUHlBb0iJKwxFDZdbfGwkd7xRNuV+IpXMJhYk=
 github.com/googleapis/gnostic v0.3.1/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1awfrALZdbtU=
 github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8=
+github.com/gophercloud/gophercloud v0.6.0 h1:Xb2lcqZtml1XjgYZxbeayEemq7ASbeTp09m36gQFpEU=
 github.com/gophercloud/gophercloud v0.6.0/go.mod h1:GICNByuaEBibcjmjvI7QvYJSZEbGkcYwAR7EZK2WMqM=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
@@ -398,6 +399,7 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
 github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs=
 github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
 github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
 github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
@@ -483,6 +485,7 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0j
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/mholt/certmagic v0.6.2-0.20190624175158-6a42ef9fe8c2/go.mod h1:g4cOPxcjV0oFq3qwpjSA30LReKD8AoIfwAY9VvG35NY=
 github.com/miekg/dns v1.1.3/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
+github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
 github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
 github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -494,6 +497,7 @@ github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9
 github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
 github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
 github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
diff --git a/pkg/configs/configs.go b/pkg/configs/configs.go
index 3c4595e..82fdcba 100644
--- a/pkg/configs/configs.go
+++ b/pkg/configs/configs.go
@@ -95,6 +95,13 @@ const (
 	Keepalive    WsComponentType = "keepalive"
 	Auth         WsComponentType = "auth"
 	Log          WsComponentType = "log"
+	Task         WsComponentType = "task"
+
+	// task subcomponents
+	TaskStart  WsSubComponentType = "taskStart"
+	TaskUpdate WsSubComponentType = "taskUpdate"
+	TaskRemove WsSubComponentType = "taskRemove"
+	TaskEnd    WsSubComponentType = "taskEnd"
 
 	// CTL components
 	Baremetal WsComponentType = "baremetal"
diff --git a/pkg/ctl/phase.go b/pkg/ctl/phase.go
index b0bc8dc..d712f02 100644
--- a/pkg/ctl/phase.go
+++ b/pkg/ctl/phase.go
@@ -23,6 +23,9 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
+	"time"
+
+	"opendev.org/airship/airshipui/pkg/webservice"
 
 	"github.com/google/uuid"
 	"opendev.org/airship/airshipctl/pkg/document"
@@ -30,6 +33,7 @@ import (
 	"opendev.org/airship/airshipctl/pkg/phase"
 	"opendev.org/airship/airshipctl/pkg/phase/ifc"
 	"opendev.org/airship/airshipui/pkg/configs"
+	"opendev.org/airship/airshipui/pkg/task"
 )
 
 var (
@@ -40,15 +44,16 @@ var (
 // HandlePhaseRequest will flop between requests so we don't have to have them all mapped as function calls
 // This will wait for the sub component to complete before responding.  The assumption is this is an async request
 func HandlePhaseRequest(user *string, request configs.WsMessage) configs.WsMessage {
+	id := request.ID
 	response := configs.WsMessage{
 		Type:         configs.CTL,
 		Component:    configs.Phase,
 		SubComponent: request.SubComponent,
+		ID:           id,
 	}
 
 	var err error
 	var message *string
-	var id string
 	var valid bool
 
 	client, err := NewClient(AirshipConfigPath, KubeConfigPath, request)
@@ -65,29 +70,24 @@ func HandlePhaseRequest(user *string, request configs.WsMessage) configs.WsMessa
 		valid, err = client.ValidatePhase(request.ID, request.SessionID)
 		message = validateHelper(valid)
 	case configs.YamlWrite:
-		id = request.ID
 		response.Name, response.YAML, err = client.writeYamlFile(id, request.YAML)
 		s := fmt.Sprintf("File '%s' saved successfully", response.Name)
 		message = &s
 	case configs.GetYaml:
-		id = request.ID
 		message = request.Message
 		response.Name, response.YAML, err = client.getYaml(id, *message)
 	case configs.GetPhaseTree:
 		response.Data, err = client.GetPhaseTree()
 	case configs.GetPhase:
-		id = request.ID
 		s := "rendered"
 		message = &s
 		response.Name, response.Details, response.YAML, err = client.GetPhase(id)
 	case configs.GetDocumentsBySelector:
-		id = request.ID
 		message = request.Message
 		response.Data, err = GetDocumentsBySelector(request.ID, *message)
 	case configs.GetTarget:
 		message = client.getTarget()
 	case configs.GetExecutorDoc:
-		id = request.ID
 		s := "rendered"
 		message = &s
 		response.Name, response.YAML, err = client.GetExecutorDoc(id)
@@ -100,7 +100,6 @@ func HandlePhaseRequest(user *string, request configs.WsMessage) configs.WsMessa
 		response.Error = &e
 	} else {
 		response.Message = message
-		response.ID = id
 	}
 
 	return response
@@ -453,12 +452,21 @@ func getSelector(data string) (document.Selector, error) {
 // (ifc.Phase.Validate isn't implemented yet, so this function
 // currently always returns "valid")
 func (c *Client) ValidatePhase(id, sessionID string) (bool, error) {
-	phase, err := getPhaseIfc(id, sessionID)
+	phaseID := ifc.ID{}
+	err := json.Unmarshal([]byte(id), &phaseID)
 	if err != nil {
 		return false, err
 	}
 
-	err = phase.Validate()
+	// probably not needed for validate, but let's create one anyway
+	taskid := uuid.New().String()
+
+	phaseIfc, err := getPhaseIfc(phaseID, taskid, sessionID)
+	if err != nil {
+		return false, err
+	}
+
+	err = phaseIfc.Validate()
 	if err != nil {
 		return false, err
 	}
@@ -468,14 +476,25 @@ func (c *Client) ValidatePhase(id, sessionID string) (bool, error) {
 
 // RunPhase runs the selected phase
 func (c *Client) RunPhase(request configs.WsMessage) error {
-	phase, err := getPhaseIfc(request.ID, request.SessionID)
+	phaseID := ifc.ID{}
+	err := json.Unmarshal([]byte(request.ID), &phaseID)
+	if err != nil {
+		return err
+	}
+
+	name := phaseID.Name
+
+	taskID := uuid.New().String()
+
+	phaseIfc, err := getPhaseIfc(phaseID, taskID, request.SessionID)
 	if err != nil {
 		return err
 	}
 
 	opts := ifc.RunOptions{}
+	var bytes []byte
 	if request.Data != nil {
-		bytes, err := json.Marshal(request.Data)
+		bytes, err = json.Marshal(request.Data)
 		if err != nil {
 			return err
 		}
@@ -486,27 +505,42 @@ func (c *Client) RunPhase(request configs.WsMessage) error {
 		}
 	}
 
-	return phase.Run(opts)
+	// send initial TaskStart message to create task on frontend
+	msg := configs.WsMessage{
+		SessionID:    request.SessionID,
+		Type:         configs.UI,
+		Component:    configs.Task,
+		SubComponent: configs.TaskStart,
+		Name:         name,
+		ID:           taskID,
+		Data: task.Progress{
+			StartTime: time.Now().UnixNano() / 1000000,
+			Message:   fmt.Sprintf("Starting task '%s'", name),
+			Errors:    []string{},
+		},
+	}
+
+	err = webservice.WebSocketSend(msg)
+	if err != nil {
+		return err
+	}
+
+	return phaseIfc.Run(opts)
 }
 
 // helper function to return a Phase interface based on a JSON
 // string representation of an ifc.ID value
-func getPhaseIfc(id, sessionID string) (ifc.Phase, error) {
-	phaseID := ifc.ID{}
-
-	err := json.Unmarshal([]byte(id), &phaseID)
-	if err != nil {
-		return nil, err
-	}
-
+func getPhaseIfc(phaseID ifc.ID, taskID, sessionID string) (ifc.Phase, error) {
 	helper, err := getHelper()
 	if err != nil {
 		return nil, err
 	}
 
+	tsk := task.NewTask(sessionID, taskID, phaseID.Name)
+
 	var procFunc phase.ProcessorFunc
 	procFunc = func() events.EventProcessor {
-		return NewUIEventProcessor(sessionID)
+		return NewUIEventProcessor(sessionID, tsk)
 	}
 
 	// inject event processor to phase client
diff --git a/pkg/ctl/processor.go b/pkg/ctl/processor.go
index b851aef..fdb8fa4 100644
--- a/pkg/ctl/processor.go
+++ b/pkg/ctl/processor.go
@@ -16,27 +16,35 @@ package ctl
 
 import (
 	"fmt"
+	"time"
 
 	"opendev.org/airship/airshipctl/pkg/events"
 	"opendev.org/airship/airshipui/pkg/configs"
 	"opendev.org/airship/airshipui/pkg/log"
-	"opendev.org/airship/airshipui/pkg/webservice"
+	"opendev.org/airship/airshipui/pkg/task"
+	applyevent "sigs.k8s.io/cli-utils/pkg/apply/event"
 )
 
+// TODO(mfuller): I'll need to implement at least some no-op event
+// processors for the remaining types, otherwise tasks don't get added
+// to the frontend, and I can't process errors for them either
+
 // UIEventProcessor basic structure to hold eventsChan, session ID, and errors
 type UIEventProcessor struct {
 	errors     []error
 	eventsChan chan<- events.Event
 	sessionID  string
+	task       *task.Task
 }
 
 // NewUIEventProcessor returns instance of UIEventProcessor for current session ID
-func NewUIEventProcessor(id string) events.EventProcessor {
+func NewUIEventProcessor(sessionID string, task *task.Task) events.EventProcessor {
 	eventsCh := make(chan events.Event)
 	return &UIEventProcessor{
 		errors:     []error{},
 		eventsChan: eventsCh,
-		sessionID:  id,
+		sessionID:  sessionID,
+		task:       task,
 	}
 }
 
@@ -45,8 +53,7 @@ func (p *UIEventProcessor) Process(ch <-chan events.Event) error {
 	for e := range ch {
 		switch e.Type {
 		case events.ApplierType:
-			log.Errorf("Processing for apply events are not yet implemented")
-			p.errors = append(p.errors, e.ErrorEvent.Error)
+			p.processApplierEvent(e.ApplierEvent)
 		case events.ErrorType:
 			log.Errorf("Received error on event channel %v", e.ErrorEvent)
 			p.errors = append(p.errors, e.ErrorEvent.Error)
@@ -65,78 +72,124 @@ func (p *UIEventProcessor) Process(ch <-chan events.Event) error {
 			p.errors = append(p.errors, e.ErrorEvent.Error)
 		}
 	}
-	return checkErrors(p.errors)
+	return p.checkErrors()
+}
+
+// TODO(mfuller): this function currently only adds errors if present,
+// otherwise it sends a task message with the entire applyevent.Event
+// object. At some point, we'll probably want to see how the printer
+// being used in ctl is determining what to print out to console and
+// do something similar
+func (p *UIEventProcessor) processApplierEvent(e applyevent.Event) {
+	var sub configs.WsSubComponentType
+	eventType := "kubernetes applier"
+	var msg string
+
+	if e.Type == applyevent.ErrorType {
+		p.errors = append(p.errors, e.ErrorEvent.Err)
+		return
+	}
+
+	if e.Type == applyevent.ApplyType {
+		switch e.ApplyEvent.Type {
+		case applyevent.ApplyEventCompleted:
+			sub = configs.TaskEnd
+			msg = "completed"
+			p.task.Progress.EndTime = time.Now().UnixNano() / 1000000
+		default:
+			sub = configs.TaskUpdate
+			msg = fmt.Sprintf("%+v", e)
+		}
+	}
+
+	message := fmt.Sprintf("%s: %s", eventType, msg)
+	p.task.Progress.LastUpdated = time.Now().UnixNano() / 1000000
+	p.task.Progress.Message = message
+
+	p.task.SendTaskMessage(sub, p.task.Progress)
 }
 
 func (p *UIEventProcessor) processIsogenEvent(e events.IsogenEvent) {
+	var sub configs.WsSubComponentType
 	eventType := "isogen"
 	msg := e.Message
 	switch e.Operation {
 	case events.IsogenStart:
+		sub = configs.TaskUpdate
 		if msg == "" {
 			msg = "starting ISO generation"
 		}
 	case events.IsogenValidation:
+		sub = configs.TaskUpdate
+		p.task.Progress.LastUpdated = time.Now().UnixNano() / 1000000
 		if msg == "" {
 			msg = "validation in progress"
 		}
 	case events.IsogenEnd:
+		sub = configs.TaskEnd
 		if msg == "" {
 			msg = "ISO generation complete"
 		}
+		p.task.Progress.EndTime = time.Now().UnixNano() / 1000000
 	}
 
-	// TODO(mfuller): what shall we do with these events? Pushing
-	// them as toasts for now
-	sendEventMessage(p.sessionID, eventType, msg)
+	message := fmt.Sprintf("%s: %s", eventType, msg)
+
+	p.task.Progress.LastUpdated = time.Now().UnixNano() / 1000000
+	p.task.Progress.Message = message
+
+	p.task.SendTaskMessage(sub, p.task.Progress)
 }
 
 func (p *UIEventProcessor) processClusterctlEvent(e events.ClusterctlEvent) {
+	var sub configs.WsSubComponentType
 	eventType := "clusterctl"
 	msg := e.Message
 
 	switch e.Operation {
 	case events.ClusterctlInitStart:
+		sub = configs.TaskUpdate
 		if msg == "" {
 			msg = "starting init"
 		}
 	case events.ClusterctlInitEnd:
+		sub = configs.TaskEnd
+		p.task.Progress.EndTime = time.Now().UnixNano() / 1000000
 		if msg == "" {
 			msg = "init completed"
 		}
 	case events.ClusterctlMoveStart:
+		sub = configs.TaskUpdate
 		if msg == "" {
 			msg = "starting move"
 		}
 	case events.ClusterctlMoveEnd:
+		sub = configs.TaskEnd
+		p.task.Progress.EndTime = time.Now().UnixNano() / 1000000
 		if msg == "" {
 			msg = "move completed"
 		}
 	}
 
-	sendEventMessage(p.sessionID, eventType, msg)
-}
+	message := fmt.Sprintf("%s: %s", eventType, msg)
 
-func sendEventMessage(sessionID, eventType, message string) {
-	m := fmt.Sprintf("%s: %s", eventType, message)
-	err := webservice.WebSocketSend(configs.WsMessage{
-		SessionID:    sessionID,
-		Type:         configs.CTL,
-		Component:    configs.Phase,
-		SubComponent: configs.Run,
-		Message:      &m,
-	})
-	if err != nil {
-		log.Errorf("Error sending message %s", err)
-	}
+	p.task.Progress.LastUpdated = time.Now().UnixNano() / 1000000
+	p.task.Progress.Message = message
+
+	p.task.SendTaskMessage(sub, p.task.Progress)
 }
 
 // Check list of errors, and verify that these errors we are able to tolerate
 // currently we simply check if the list is empty or not
-func checkErrors(errs []error) error {
-	if len(errs) != 0 {
+func (p *UIEventProcessor) checkErrors() error {
+	log.Infof("p.errors: %+v", p.errors)
+	if len(p.errors) != 0 {
+		for _, e := range p.errors {
+			p.task.Progress.Errors = append(p.task.Progress.Errors, e.Error())
+		}
+		p.task.SendTaskMessage(configs.TaskUpdate, p.task.Progress)
 		return events.ErrEventReceived{
-			Errors: errs,
+			Errors: p.errors,
 		}
 	}
 	return nil
diff --git a/pkg/ctl/tree.go b/pkg/ctl/tree.go
index d187329..66d8408 100644
--- a/pkg/ctl/tree.go
+++ b/pkg/ctl/tree.go
@@ -181,7 +181,7 @@ func (client *Client) GetPhaseSourceFiles(id ifc.ID) ([]KustomNode, error) {
 // bundle to be consumed by the UI frontend
 type KustomNode struct {
 	ID          string       `json:"id"` // UUID for backend node index
-	PhaseID     ifc.ID       `json:"phaseid"`
+	PhaseID     ifc.ID       `json:"phaseId"`
 	Name        string       `json:"name"` // name used for display purposes (cli, ui)
 	IsPhaseNode bool         `json:"isPhaseNode"`
 	HasError    bool         `json:"hasError"`
diff --git a/pkg/task/task.go b/pkg/task/task.go
new file mode 100644
index 0000000..f04e8ce
--- /dev/null
+++ b/pkg/task/task.go
@@ -0,0 +1,159 @@
+/*
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+     https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package task
+
+import (
+	"fmt"
+	"time"
+
+	"opendev.org/airship/airshipui/pkg/configs"
+	"opendev.org/airship/airshipui/pkg/log"
+	"opendev.org/airship/airshipui/pkg/webservice"
+)
+
+// RunningTasks serves as a cache for currently running tasks
+// TODO(mfuller): keeping a backend cache may not be necessary since
+// task objects get attached to the event processors injected into phase
+// clients and will always be updated directly from there. But we may
+// want to use it to ensure the frontend can retrieve running tasks
+// in the event of a browser refresh
+var RunningTasks = map[string]Task{}
+
+// Task simple structure to hold details about a long running task
+type Task struct {
+	ID        string
+	SessionID string
+	Name      string
+	Progress  Progress
+	Running   bool // TODO(mfuller): this is probably only necessary on the frontend
+}
+
+// Progress structure to store and pass progress data for a running task
+type Progress struct {
+	StartTime   int64    `json:"startTime"`
+	EndTime     int64    `json:"endTime"`
+	LastUpdated int64    `json:"lastUpdated"`
+	TotalSteps  int      `json:"totalSteps"`
+	CurrentStep int      `json:"currentStep"`
+	Message     string   `json:"message"`
+	Errors      []string `json:"errors"`
+}
+
+// HandleTaskRequest handles incoming WS messages for tasks
+// TODO(mfuller): it's unclear how often this will happen. Task requests
+// and updates will almost always come from event processing. Is this needed?
+func HandleTaskRequest(request configs.WsMessage) configs.WsMessage {
+	response := configs.WsMessage{
+		Type:         configs.UI,
+		Component:    configs.Task,
+		SubComponent: request.SubComponent,
+	}
+
+	var err error
+	var message *string
+
+	switch request.SubComponent {
+	// case configs.TaskStart:
+	// 	response.ID, response.Data = StartTask(request.SessionID, request.Name)
+	case configs.TaskRemove:
+		message, err = RemoveTask(request.ID)
+	default:
+		err = fmt.Errorf("Subcomponent %s not found", request.SubComponent)
+	}
+
+	if err != nil {
+		e := err.Error()
+		response.Error = &e
+	} else {
+		response.Message = message
+	}
+
+	return response
+}
+
+// NewTask returns a pointer to a new Task built with a session ID, name, and UUID
+func NewTask(sessionID, taskID, name string) *Task {
+	task := Task{
+		ID:        taskID,
+		SessionID: sessionID,
+		Name:      name,
+		Progress: Progress{
+			StartTime:   time.Now().UnixNano() / 1000000,
+			TotalSteps:  0, // will steps be determinable at task start?
+			CurrentStep: 1,
+			Errors:      []string{},
+		},
+		Running: true,
+	}
+
+	RunningTasks[task.ID] = task
+
+	return &task
+}
+
+// RemoveTask removes a Task from RunningTasks and sends confirmation
+// message to UI. This function is intended to be called by the frontend
+// client by clicking a "remove" button in the task manager
+func RemoveTask(id string) (*string, error) {
+	if t, ok := RunningTasks[id]; ok {
+		delete(RunningTasks, id)
+		msg := fmt.Sprintf("Removed task '%s'", t.Name)
+		return &msg, nil
+	}
+
+	return nil, fmt.Errorf("Task with id %s not found", id)
+}
+
+// UpdateTask updates a task with new progress details
+// TODO(mfuller): I don't know if this function is even necessary
+// since most updates are going to come from event processing,
+// so the message will likely be fired from the processor directly
+func UpdateTask(sessionID, id string, progress Progress) {
+	if t, ok := RunningTasks[id]; ok {
+		t.SendTaskMessage(configs.TaskUpdate, progress)
+	}
+
+	// this is the only reason we need session ID, otherwise we'd have
+	// to just log a message and walk away...
+	m := fmt.Sprintf("Task with id %s not found", id)
+	err := webservice.WebSocketSend(configs.WsMessage{
+		SessionID: sessionID,
+		Type:      configs.UI,
+		Error:     &m,
+	})
+
+	if err != nil {
+		log.Errorf("Error sending message for task %s", err)
+	}
+}
+
+// SendTaskMessage allows a running Task to push progress updates to the frontend client
+func (t *Task) SendTaskMessage(subComponent configs.WsSubComponentType, progress Progress) {
+	err := webservice.WebSocketSend(configs.WsMessage{
+		SessionID:    t.SessionID,
+		ID:           t.ID,
+		Name:         t.Name,
+		Timestamp:    time.Now().UnixNano() / 1000000,
+		Type:         configs.UI,
+		Component:    configs.Task,
+		SubComponent: subComponent,
+		Message:      &t.Name,
+		Data:         progress,
+	})
+
+	if err != nil {
+		log.Errorf("Error sending message for task %s", err)
+	}
+}