Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for process output on detail page #827

Merged
merged 7 commits into from Nov 20, 2020
9 changes: 9 additions & 0 deletions src/app/core/shared/process-output.resource-type.ts
@@ -0,0 +1,9 @@
import { ResourceType } from './resource-type';

/**
* The resource type for ProcessOutput
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const PROCESS_OUTPUT_TYPE = new ResourceType('processOutput');
21 changes: 16 additions & 5 deletions src/app/process-page/detail/process-detail.component.html
Expand Up @@ -17,7 +17,7 @@ <h2 class="flex-grow-1">{{'process.detail.title' | translate:{id: process?.proce
<ds-process-detail-field *ngIf="files && files?.length > 0" id="process-files" [title]="'process.detail.output-files'">
<ds-file-download-link *ngFor="let file of files; let last=last;" [href]="file?._links?.content?.href" [download]="getFileName(file)">
<span>{{getFileName(file)}}</span>
<span>({{(file?.sizeBytes) | dsFileSize }})</span>
<span>({{(file?.sizeBytes) | dsFileSize }})</span>
</ds-file-download-link>
</ds-process-detail-field>
</div>
Expand All @@ -34,9 +34,20 @@ <h2 class="flex-grow-1">{{'process.detail.title' | translate:{id: process?.proce
<div>{{ process.processStatus }}</div>
</ds-process-detail-field>

<!--<ds-process-detail-field id="process-output" [title]="'process.detail.output'">-->
<!--<pre class="font-weight-bold text-secondary bg-light p-3">{{'process.detail.output.alert' | translate}}</pre>-->
<!--</ds-process-detail-field>-->
<ds-process-detail-field *ngIf="isProcessFinished(process)" id="process-output" [title]="'process.detail.output'">
<button *ngIf="!showOutputLogs && process?._links?.output?.href != undefined" id="showOutputButton" class="btn btn-light" (click)="showProcessOutputLogs()">
{{ 'process.detail.logs.button' | translate }}
</button>
<ds-loading *ngIf="retrievingOutputLogs$ | async" class="ds-loading" message="{{ 'process.detail.logs.loading' | translate }}"></ds-loading>
<pre class="font-weight-bold text-secondary bg-light p-3"
*ngIf="showOutputLogs && (outputLogs$ | async)?.length > 0">{{ (outputLogs$ | async) }}</pre>
<p id="no-output-logs-message" *ngIf="(!(retrievingOutputLogs$ | async) && showOutputLogs)
&& !(outputLogs$ | async) || (outputLogs$ | async)?.length == 0 || !process._links.output">
{{ 'process.detail.logs.none' | translate }}
</p>
</ds-process-detail-field>

<a class="btn btn-light mt-3" [routerLink]="'/processes'">{{'process.detail.back' | translate}}</a>
<div>
<a class="btn btn-light mt-3" [routerLink]="'/processes'">{{'process.detail.back' | translate}}</a>
</div>
</div>
117 changes: 110 additions & 7 deletions src/app/process-page/detail/process-detail.component.spec.ts
@@ -1,9 +1,22 @@
import { HttpClient } from '@angular/common/http';
import { AuthService } from '../../core/auth/auth.service';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import { AuthServiceMock } from '../../shared/mocks/auth.service.mock';
import { ProcessDetailComponent } from './process-detail.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import {
async,
ComponentFixture,
discardPeriodicTasks,
fakeAsync,
flush,
flushMicrotasks,
TestBed,
tick
} from '@angular/core/testing';
import { VarDirective } from '../../shared/utils/var.directive';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ProcessDetailFieldComponent } from './process-detail-field/process-detail-field.component';
import { Process } from '../processes/process.model';
import { ActivatedRoute } from '@angular/router';
Expand All @@ -22,15 +35,21 @@ describe('ProcessDetailComponent', () => {

let processService: ProcessDataService;
let nameService: DSONameService;
let bitstreamDataService: BitstreamDataService;
let httpClient: HttpClient;

let process: Process;
let fileName: string;
let files: Bitstream[];

let processOutput;

function init() {
processOutput = 'Process Started'
process = Object.assign(new Process(), {
processId: 1,
scriptName: 'script-name',
processStatus: 'COMPLETED',
parameters: [
{
name: '-f',
Expand All @@ -40,7 +59,15 @@ describe('ProcessDetailComponent', () => {
name: '-i',
value: 'identifier'
}
]
],
_links: {
self: {
href: 'https://rest.api/processes/1'
},
output: {
href: 'https://rest.api/processes/1/output'
}
}
});
fileName = 'fake-file-name';
files = [
Expand All @@ -59,12 +86,24 @@ describe('ProcessDetailComponent', () => {
}
})
];
const logBitstream = Object.assign(new Bitstream(), {
id: 'output.log',
_links: {
content: { href: 'log-selflink' }
}
});
processService = jasmine.createSpyObj('processService', {
getFiles: createSuccessfulRemoteDataObject$(createPaginatedList(files))
});
bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', {
findByHref: createSuccessfulRemoteDataObject$(logBitstream)
});
nameService = jasmine.createSpyObj('nameService', {
getName: fileName
});
httpClient = jasmine.createSpyObj('httpClient', {
get: observableOf(processOutput)
});
}

beforeEach(async(() => {
Expand All @@ -73,35 +112,99 @@ describe('ProcessDetailComponent', () => {
declarations: [ProcessDetailComponent, ProcessDetailFieldComponent, VarDirective, FileSizePipe],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
{ provide: ActivatedRoute, useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }) } },
{
provide: ActivatedRoute,
useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }) }
},
{ provide: ProcessDataService, useValue: processService },
{ provide: DSONameService, useValue: nameService }
{ provide: BitstreamDataService, useValue: bitstreamDataService },
{ provide: DSONameService, useValue: nameService },
{ provide: AuthService, useValue: new AuthServiceMock() },
{ provide: HttpClient, useValue: httpClient },
],
schemas: [NO_ERRORS_SCHEMA]
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(ProcessDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(fakeAsync(() => {
TestBed.resetTestingModule();
fixture.destroy();
flush();
flushMicrotasks();
discardPeriodicTasks();
component = null;
}));

it('should display the script\'s name', () => {
fixture.detectChanges();
const name = fixture.debugElement.query(By.css('#process-name')).nativeElement;
expect(name.textContent).toContain(process.scriptName);
});

it('should display the process\'s parameters', () => {
fixture.detectChanges();
const args = fixture.debugElement.query(By.css('#process-arguments')).nativeElement;
process.parameters.forEach((param) => {
expect(args.textContent).toContain(`${param.name} ${param.value}`)
});
});

it('should display the process\'s output files', () => {
fixture.detectChanges();
const processFiles = fixture.debugElement.query(By.css('#process-files')).nativeElement;
expect(processFiles.textContent).toContain(fileName);
});

describe('if press show output logs', () => {
beforeEach(fakeAsync(() => {
spyOn(component, 'showProcessOutputLogs').and.callThrough();
fixture.detectChanges();

const showOutputButton = fixture.debugElement.query(By.css('#showOutputButton'));
showOutputButton.triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
tick();
}));
it('should trigger showProcessOutputLogs', () => {
expect(component.showProcessOutputLogs).toHaveBeenCalled();
});
it('should display the process\'s output logs', () => {
fixture.detectChanges();
const outputProcess = fixture.debugElement.query(By.css('#process-output pre'));
expect(outputProcess.nativeElement.textContent).toContain(processOutput);
});
});

describe('if press show output logs and process has no output logs', () => {
beforeEach(fakeAsync(() => {
jasmine.getEnv().allowRespy(true);
spyOn(httpClient, 'get').and.returnValue(observableOf(null));
fixture = TestBed.createComponent(ProcessDetailComponent);
component = fixture.componentInstance;
spyOn(component, 'showProcessOutputLogs').and.callThrough();
fixture.detectChanges();
const showOutputButton = fixture.debugElement.query(By.css('#showOutputButton'));
showOutputButton.triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
tick();
fixture.detectChanges();
}));
it('should not display the process\'s output logs', () => {
const outputProcess = fixture.debugElement.query(By.css('#process-output pre'));
expect(outputProcess).toBeNull();
});
it('should display message saying there are no output logs', () => {
const noOutputProcess = fixture.debugElement.query(By.css('#no-output-logs-message')).nativeElement;
expect(noOutputProcess).toBeDefined();
});
});

});
116 changes: 106 additions & 10 deletions src/app/process-page/detail/process-detail.component.ts
@@ -1,15 +1,23 @@
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Component, NgZone, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { Observable } from 'rxjs/internal/Observable';
import { finalize, map, mergeMap, switchMap, take, tap } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import { PaginatedList } from '../../core/data/paginated-list';
import { ProcessDataService } from '../../core/data/processes/process-data.service';
import { RemoteData } from '../../core/data/remote-data';
import { Process } from '../processes/process.model';
import { map, switchMap } from 'rxjs/operators';
import { Bitstream } from '../../core/shared/bitstream.model';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { getFirstSucceededRemoteDataPayload, redirectOn404Or401 } from '../../core/shared/operators';
import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { AlertType } from '../../shared/alert/aletr-type';
import { ProcessDataService } from '../../core/data/processes/process-data.service';
import { PaginatedList } from '../../core/data/paginated-list';
import { Bitstream } from '../../core/shared/bitstream.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { hasValue } from '../../shared/empty.util';
import { ProcessStatus } from '../processes/process-status.model';
import { Process } from '../processes/process.model';

@Component({
selector: 'ds-process-detail',
Expand All @@ -36,19 +44,46 @@ export class ProcessDetailComponent implements OnInit {
*/
filesRD$: Observable<RemoteData<PaginatedList<Bitstream>>>;

/**
* File link that contain the output logs with auth token
*/
outputLogFileUrl$: Observable<string>;

/**
* The Process's Output logs
*/
outputLogs$: Observable<string>;

/**
* Boolean on whether or not to show the output logs
*/
showOutputLogs;
/**
* When it's retrieving the output logs from backend, to show loading component
*/
retrievingOutputLogs$: BehaviorSubject<boolean>;

constructor(protected route: ActivatedRoute,
protected router: Router,
protected processService: ProcessDataService,
protected nameService: DSONameService) {
protected bitstreamDataService: BitstreamDataService,
protected nameService: DSONameService,
private zone: NgZone,
protected authService: AuthService,
protected http: HttpClient) {
}

/**
* Initialize component properties
* Display a 404 if the process doesn't exist
*/
ngOnInit(): void {
this.showOutputLogs = false;
this.retrievingOutputLogs$ = new BehaviorSubject<boolean>(false);
this.processRD$ = this.route.data.pipe(
map((data) => data.process as RemoteData<Process>),
map((data) => {
return data.process as RemoteData<Process>
}),
redirectOn404Or401(this.router)
);

Expand All @@ -63,7 +98,68 @@ export class ProcessDetailComponent implements OnInit {
* @param bitstream
*/
getFileName(bitstream: Bitstream) {
return this.nameService.getName(bitstream);
return bitstream instanceof DSpaceObject ? this.nameService.getName(bitstream) : 'unknown';
}

/**
* Retrieves the process logs, while setting the loading subject to true.
* Sets the outputLogs when retrieved and sets the showOutputLogs boolean to show them and hide the button.
*/
showProcessOutputLogs() {
this.retrievingOutputLogs$.next(true);
this.zone.runOutsideAngular(() => {
const processOutputRD$: Observable<RemoteData<Bitstream>> = this.processRD$.pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((process: Process) => {
return this.bitstreamDataService.findByHref(process._links.output.href);
})
);
this.outputLogFileUrl$ = processOutputRD$.pipe(
tap((processOutputFileRD: RemoteData<Bitstream>) => {
if (processOutputFileRD.statusCode === 204) {
this.zone.run(() => this.retrievingOutputLogs$.next(false));
this.showOutputLogs = true;
}
}),
getFirstSucceededRemoteDataPayload(),
mergeMap((processOutput: Bitstream) => {
const url = processOutput._links.content.href;
return this.authService.getShortlivedToken().pipe(take(1),
map((token: string) => {
return hasValue(token) ? new URLCombiner(url, `?authentication-token=${token}`).toString() : url;
}));
})
)
});
this.outputLogs$ = this.outputLogFileUrl$.pipe(take(1),
mergeMap((url: string) => {
return this.getTextFile(url);
}),
finalize(() => this.zone.run(() => this.retrievingOutputLogs$.next(false))),
);
this.outputLogs$.pipe(take(1)).subscribe();
}

getTextFile(filename: string): Observable<string> {
// The Observable returned by get() is of type Observable<string>
// because a text response was specified.
// There's no need to pass a <string> type parameter to get().
return this.http.get(filename, { responseType: 'text' })
.pipe(
finalize(() => {
this.showOutputLogs = true;
}),
);
}

/**
* Whether or not the given process has Completed or Failed status
* @param process Process to check if completed or failed
*/
isProcessFinished(process: Process): boolean {
return (hasValue(process) && hasValue(process.processStatus) &&
(process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()
|| process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()));
}

}