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
This commit is contained in:
parent
43c141b5a8
commit
587031c09f
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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 { }
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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]
|
||||
})
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
64
client/src/app/task/task.component.css
Normal file
64
client/src/app/task/task.component.css
Normal file
@ -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;
|
||||
}
|
40
client/src/app/task/task.component.html
Normal file
40
client/src/app/task/task.component.html
Normal file
@ -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>
|
55
client/src/app/task/task.component.spec.ts
Normal file
55
client/src/app/task/task.component.spec.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
137
client/src/app/task/task.component.ts
Normal file
137
client/src/app/task/task.component.ts
Normal file
@ -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}`;
|
||||
}
|
||||
}
|
30
client/src/app/task/task.models.ts
Normal file
30
client/src/app/task/task.models.ts
Normal file
@ -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[];
|
||||
}
|
37
client/src/app/task/task.module.ts
Normal file
37
client/src/app/task/task.module.ts
Normal file
@ -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 { }
|
1
client/src/assets/icons/check_circle.svg
Normal file
1
client/src/assets/icons/check_circle.svg
Normal file
@ -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>
|
After Width: | Height: | Size: 255 B |
1
client/src/assets/icons/close.svg
Normal file
1
client/src/assets/icons/close.svg
Normal file
@ -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>
|
After Width: | Height: | Size: 239 B |
1
client/src/assets/icons/delete.svg
Normal file
1
client/src/assets/icons/delete.svg
Normal file
@ -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>
|
After Width: | Height: | Size: 215 B |
@ -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'
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
1
go.mod
1
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
|
||||
)
|
||||
|
||||
|
4
go.sum
4
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=
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"`
|
||||
|
159
pkg/task/task.go
Normal file
159
pkg/task/task.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user