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:
Matthew Fuller 2020-10-12 19:04:32 +00:00
parent 43c141b5a8
commit 587031c09f
26 changed files with 783 additions and 88 deletions

View File

@ -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;

View File

@ -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>

View File

@ -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 { }

View File

@ -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;

View File

@ -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>

View File

@ -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]
})

View File

@ -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;
}
}
}
}

View File

@ -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;
}

View 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;
}

View 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>

View 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();
});
});

View 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}`;
}
}

View 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[];
}

View 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 { }

View 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

View 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

View 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

View File

@ -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'
}

View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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
View 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)
}
}