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) + } +}