diff --git a/src/app/core/shared/process-output.resource-type.ts b/src/app/core/shared/process-output.resource-type.ts new file mode 100644 index 0000000000..2e707d0bda --- /dev/null +++ b/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'); diff --git a/src/app/process-page/detail/process-detail.component.html b/src/app/process-page/detail/process-detail.component.html index 9cb1f1e6af..d59f93254e 100644 --- a/src/app/process-page/detail/process-detail.component.html +++ b/src/app/process-page/detail/process-detail.component.html @@ -17,7 +17,7 @@ {{getFileName(file)}} - ({{(file?.sizeBytes) | dsFileSize }}) + ({{(file?.sizeBytes) | dsFileSize }}) @@ -34,9 +34,20 @@
{{ process.processStatus }}
- - - + + + +
{{ (outputLogs$ | async) }}
+

+ {{ 'process.detail.logs.none' | translate }} +

+
- {{'process.detail.back' | translate}} +
+ {{'process.detail.back' | translate}} +
diff --git a/src/app/process-page/detail/process-detail.component.spec.ts b/src/app/process-page/detail/process-detail.component.spec.ts index dff481fdc6..b81eedabad 100644 --- a/src/app/process-page/detail/process-detail.component.spec.ts +++ b/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'; @@ -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', @@ -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 = [ @@ -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(() => { @@ -73,26 +112,41 @@ 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}`) @@ -100,8 +154,57 @@ describe('ProcessDetailComponent', () => { }); 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(); + }); + }); + }); diff --git a/src/app/process-page/detail/process-detail.component.ts b/src/app/process-page/detail/process-detail.component.ts index c4610b70e9..c4b94aa729 100644 --- a/src/app/process-page/detail/process-detail.component.ts +++ b/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 { RemoteData } from '../../core/data/remote-data'; -import { Process } from '../processes/process.model'; -import { map, switchMap } from 'rxjs/operators'; -import { getFirstSucceededRemoteDataPayload, redirectOn404Or401 } from '../../core/shared/operators'; -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 { 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 { 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 { hasValue } from '../../shared/empty.util'; +import { ProcessStatus } from '../processes/process-status.model'; +import { Process } from '../processes/process.model'; @Component({ selector: 'ds-process-detail', @@ -36,10 +44,33 @@ export class ProcessDetailComponent implements OnInit { */ filesRD$: Observable>>; + /** + * File link that contain the output logs with auth token + */ + outputLogFileUrl$: Observable; + + /** + * The Process's Output logs + */ + outputLogs$: Observable; + + /** + * 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; + 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) { } /** @@ -47,8 +78,12 @@ export class ProcessDetailComponent implements OnInit { * Display a 404 if the process doesn't exist */ ngOnInit(): void { + this.showOutputLogs = false; + this.retrievingOutputLogs$ = new BehaviorSubject(false); this.processRD$ = this.route.data.pipe( - map((data) => data.process as RemoteData), + map((data) => { + return data.process as RemoteData + }), redirectOn404Or401(this.router) ); @@ -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> = this.processRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((process: Process) => { + return this.bitstreamDataService.findByHref(process._links.output.href); + }) + ); + this.outputLogFileUrl$ = processOutputRD$.pipe( + tap((processOutputFileRD: RemoteData) => { + 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 { + // The Observable returned by get() is of type Observable + // because a text response was specified. + // There's no need to pass a 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())); } } diff --git a/src/app/process-page/processes/process.model.ts b/src/app/process-page/processes/process.model.ts index 85de5337e7..74bb82b890 100644 --- a/src/app/process-page/processes/process.model.ts +++ b/src/app/process-page/processes/process.model.ts @@ -1,3 +1,5 @@ +import { Bitstream } from '../../core/shared/bitstream.model'; +import { PROCESS_OUTPUT_TYPE } from '../../core/shared/process-output.resource-type'; import { ProcessStatus } from './process-status.model'; import { ProcessParameter } from './process-parameter.model'; import { CacheableObject } from '../../core/cache/object-cache.reducer'; @@ -85,4 +87,11 @@ export class Process implements CacheableObject { */ @link(SCRIPT) script?: Observable>; + + /** + * The output logs created by this Process + * Will be undefined unless the output {@link HALLink} has been resolved. + */ + @link(PROCESS_OUTPUT_TYPE) + output?: Observable>; } diff --git a/src/app/shared/mocks/auth.service.mock.ts b/src/app/shared/mocks/auth.service.mock.ts index a168ffd8e5..0f3a7d13d1 100644 --- a/src/app/shared/mocks/auth.service.mock.ts +++ b/src/app/shared/mocks/auth.service.mock.ts @@ -1,4 +1,6 @@ /* tslint:disable:no-empty */ +import { Observable, of as observableOf } from 'rxjs'; + export class AuthServiceMock { public checksAuthenticationToken() { return @@ -6,4 +8,8 @@ export class AuthServiceMock { public buildAuthHeader() { return 'auth-header'; } + + public getShortlivedToken(): Observable { + return observableOf('token'); + } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 9b73918e77..f01f5cbff1 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2294,7 +2294,11 @@ "process.detail.output" : "Process Output", - "process.detail.output.alert" : "Work in progress - Process output is not available yet", + "process.detail.logs.button": "Retrieve process output", + + "process.detail.logs.loading": "Retrieving", + + "process.detail.logs.none": "This process has no output", "process.detail.output-files" : "Output Files",