diff --git a/package-lock.json b/package-lock.json index 063c14cf8d..24fe0a7e1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@angular/platform-browser-dynamic": "^13.2.6", "@angular/router": "^13.2.6", "@ckeditor/ckeditor5-angular": "^2.0.2", - "@dasch-swiss/dsp-js": "^6.2.2", + "@dasch-swiss/dsp-js": "^7.0.0", "@datadog/browser-rum": "^3.11.0", "@ngx-translate/core": "^13.0.0", "@ngx-translate/http-loader": "6.0.0", @@ -2425,9 +2425,9 @@ } }, "node_modules/@dasch-swiss/dsp-js": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@dasch-swiss/dsp-js/-/dsp-js-6.2.2.tgz", - "integrity": "sha512-ng7vCVdBfcfFn39u8XvVyGcMRyPjMyGCiFHPYdBhmE9AFa2qT0JA5WAGqSF8/gIF24UEhbpzW48m1R37L+sHTw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@dasch-swiss/dsp-js/-/dsp-js-7.0.0.tgz", + "integrity": "sha512-MM58ipXipltAPm0eAdjNZnCc3SzTE5U6HB+VhVGYP24/fk73ptcpMV6It0Axw9XlJNUh/+34DcpqA04l4T2hgA==", "dependencies": { "@babel/helper-compilation-targets": "^7.16.7", "@types/jsonld": "^1.5.6", @@ -17009,9 +17009,9 @@ } }, "@dasch-swiss/dsp-js": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@dasch-swiss/dsp-js/-/dsp-js-6.2.2.tgz", - "integrity": "sha512-ng7vCVdBfcfFn39u8XvVyGcMRyPjMyGCiFHPYdBhmE9AFa2qT0JA5WAGqSF8/gIF24UEhbpzW48m1R37L+sHTw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@dasch-swiss/dsp-js/-/dsp-js-7.0.0.tgz", + "integrity": "sha512-MM58ipXipltAPm0eAdjNZnCc3SzTE5U6HB+VhVGYP24/fk73ptcpMV6It0Axw9XlJNUh/+34DcpqA04l4T2hgA==", "requires": { "@babel/helper-compilation-targets": "^7.16.7", "@types/jsonld": "^1.5.6", diff --git a/package.json b/package.json index b9de77c5fd..94e91937c7 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@angular/platform-browser-dynamic": "^13.2.6", "@angular/router": "^13.2.6", "@ckeditor/ckeditor5-angular": "^2.0.2", - "@dasch-swiss/dsp-js": "^6.2.2", + "@dasch-swiss/dsp-js": "^7.0.0", "@datadog/browser-rum": "^3.11.0", "@ngx-translate/core": "^13.0.0", "@ngx-translate/http-loader": "6.0.0", diff --git a/src/app/main/action/login-form/login-form.component.html b/src/app/main/action/login-form/login-form.component.html index 5466b9c05e..3ba5c31be1 100644 --- a/src/app/main/action/login-form/login-form.component.html +++ b/src/app/main/action/login-form/login-form.component.html @@ -40,7 +40,7 @@ (click)="login()" type="submit" class="full-width submit-button" - [disabled]="!form.valid" + [disabled]="!form?.valid" [class.mat-primary]="!isError" [class.mat-warn]="isError"> diff --git a/src/app/main/action/login-form/login-form.component.ts b/src/app/main/action/login-form/login-form.component.ts index 9cea257fc1..a0fbdf5279 100644 --- a/src/app/main/action/login-form/login-form.component.ts +++ b/src/app/main/action/login-form/login-form.component.ts @@ -1,8 +1,7 @@ -import { AfterViewInit, Component, ElementRef, EventEmitter, Inject, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { ApiResponseData, ApiResponseError, KnoraApiConnection, LoginResponse, LogoutResponse } from '@dasch-swiss/dsp-js'; -import { CacheService } from '../../cache/cache.service'; +import { ApiResponseData, ApiResponseError, KnoraApiConnection, LoginResponse } from '@dasch-swiss/dsp-js'; import { DspApiConnectionToken } from '../../declarations/dsp-api-tokens'; import { ErrorHandlerService } from '../../error/error-handler.service'; import { AuthenticationService } from '../../services/authentication.service'; diff --git a/src/app/main/dialog/dialog.component.html b/src/app/main/dialog/dialog.component.html index 2c7fbecd97..3c7207ab8b 100644 --- a/src/app/main/dialog/dialog.component.html +++ b/src/app/main/dialog/dialog.component.html @@ -408,7 +408,7 @@
- +
diff --git a/src/app/main/dialog/dialog.component.ts b/src/app/main/dialog/dialog.component.ts index bfb51d3e0a..3e2bbac7e9 100644 --- a/src/app/main/dialog/dialog.component.ts +++ b/src/app/main/dialog/dialog.component.ts @@ -1,5 +1,5 @@ -import { Component, Inject, OnInit, ViewChild } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Component, Inject, OnInit } from '@angular/core'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { ReadResource } from '@dasch-swiss/dsp-js'; import { PropertyInfoObject } from 'src/app/project/ontology/default-data/default-properties'; import { FilteredResources } from 'src/app/workspace/results/list-view/list-view.component'; @@ -21,6 +21,7 @@ export interface DialogData { projectCode?: string; selectedResources?: FilteredResources; resourceClassDefinition?: string; + fullSize?: boolean; } export interface ConfirmationWithComment { @@ -42,7 +43,12 @@ export class DialogComponent implements OnInit { constructor( public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: DialogData - ) { } + ) { + if (this.data.fullSize) { + // do not animate the dialog box + this.dialogRef.addPanelClass('full-size-dialog'); + } + } ngOnInit() { } diff --git a/src/app/main/error/error-handler.service.spec.ts b/src/app/main/error/error-handler.service.spec.ts index 4084068a60..4c841bd543 100644 --- a/src/app/main/error/error-handler.service.spec.ts +++ b/src/app/main/error/error-handler.service.spec.ts @@ -1,24 +1,37 @@ +import { OverlayContainer } from '@angular/cdk/overlay'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; -import { MatDialogModule } from '@angular/material/dialog'; +import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { HealthEndpointSystem, MockHealth } from '@dasch-swiss/dsp-js'; +import { of } from 'rxjs'; import { DspApiConnectionToken } from '../declarations/dsp-api-tokens'; +import { DialogComponent } from '../dialog/dialog.component'; import { ErrorHandlerService } from './error-handler.service'; describe('ErrorHandlerService', () => { + let httpTestingController: HttpTestingController; let service: ErrorHandlerService; + let overlayContainer: OverlayContainer; + + let dialog: MatDialog; beforeEach(() => { const apiEndpointSpyObj = { v2: { auth: jasmine.createSpyObj('auth', ['logout']) + }, + system: { + healthEndpoint: jasmine.createSpyObj('healthEndpoint', ['getHealthStatus']) } }; TestBed.configureTestingModule({ imports: [ BrowserAnimationsModule, + HttpClientTestingModule, MatDialogModule, MatSnackBarModule ], @@ -27,12 +40,50 @@ describe('ErrorHandlerService', () => { provide: DspApiConnectionToken, useValue: apiEndpointSpyObj }, + { + provide: MatDialog, + useValue: { open: () => of({ id: 1 }) } + } ] }); service = TestBed.inject(ErrorHandlerService); + + httpTestingController = TestBed.inject(HttpTestingController); + + const dspConnSpy = TestBed.inject(DspApiConnectionToken); + (dspConnSpy.system.healthEndpoint as jasmine.SpyObj).getHealthStatus.and.callFake( + () => { + const health = MockHealth.mockStopped(); + return of(health); + } + ); + + overlayContainer = TestBed.inject(OverlayContainer); + + dialog = TestBed.inject(MatDialog); + }); + + afterEach(() => { + httpTestingController.verify(); + }); + + afterEach(async () => { + // angular won't call this for us so we need to do it ourselves to avoid leaks. + overlayContainer.ngOnDestroy(); }); it('should be created', () => { expect(service).toBeTruthy(); }); + + it('api is not healthy: should return 503 server error', () => { + + const error = require('../../../assets/test-data/api-error-0.json'); + + service.showMessage(error); + + spyOn(dialog, 'open').and.returnValue({ afterClosed: () => of({ id: 1 }) } as MatDialogRef); + expect(dialog).toBeDefined(); + + }); }); diff --git a/src/app/main/error/error-handler.service.ts b/src/app/main/error/error-handler.service.ts index c289684f52..6d22c2d4c3 100644 --- a/src/app/main/error/error-handler.service.ts +++ b/src/app/main/error/error-handler.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@angular/core'; import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; -import { ApiResponseData, ApiResponseError, KnoraApiConnection, LogoutResponse } from '@dasch-swiss/dsp-js'; +import { ApiResponseData, ApiResponseError, HealthResponse, KnoraApiConnection, LogoutResponse } from '@dasch-swiss/dsp-js'; import { StatusMsg } from 'src/assets/http/statusMsg'; import { DspApiConnectionToken } from '../declarations/dsp-api-tokens'; import { DialogComponent } from '../dialog/dialog.component'; @@ -25,9 +25,33 @@ export class ErrorHandlerService { // in case of (internal) server error const apiServerError = (error.error && !error.error['response']); + const apiResponseMessage = (error.error['response'] ? error.error['response'].error : undefined); + if ((error.status > 499 && error.status < 600) || apiServerError) { - const status = (apiServerError ? 503 : error.status); + let status = (apiServerError ? 503 : error.status); + + // check if the api is healthy: + this._dspApiConnection.system.healthEndpoint.getHealthStatus().subscribe( + (response: ApiResponseData) => { + if (response.body.status === 'unhealthy') { + const healthError: ApiResponseError = { + error: response.body.message, + method: response.method, + status: 500, + url: error.url + }; + status = 500; + error = healthError; + throw new Error(`ERROR ${status}: Server side error — dsp-api is not healthy`); + } else { + throw new Error(`ERROR ${status}: Server side error — dsp-api not responding`); + } + }, + (healthError: ApiResponseError) => { + error = healthError; + } + ); // open error message in full size view const dialogConfig: MatDialogConfig = { @@ -38,7 +62,7 @@ export class ErrorHandlerService { position: { top: '0' }, - data: { mode: 'error', id: status }, + data: { mode: 'error', id: status, comment: apiResponseMessage, fullSize: true }, disableClose: true }; @@ -47,7 +71,6 @@ export class ErrorHandlerService { dialogConfig ); - throw new Error(`ERROR ${status}: Server side error — dsp-api not responding`); } else if (error.status === 401 && typeof(error.error) !== 'string') { // logout if error status is a 401 error and comes from a DSP-JS request diff --git a/src/app/main/error/error.component.html b/src/app/main/error/error.component.html index c65a8919a4..dbe0853e2b 100644 --- a/src/app/main/error/error.component.html +++ b/src/app/main/error/error.component.html @@ -1,7 +1,6 @@
- -
+
@@ -9,16 +8,20 @@

ERROR {{status}}

{{errorMessage?.message}}

+

API response:
→ {{comment}}

- Please come back in a few minutes and try to reload the page + Please come back in a few minutes and try to reload the page.

+ + +
diff --git a/src/app/main/error/error.component.spec.ts b/src/app/main/error/error.component.spec.ts index 12796b4785..bdf2699b3f 100644 --- a/src/app/main/error/error.component.spec.ts +++ b/src/app/main/error/error.component.spec.ts @@ -1,18 +1,36 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { MatDialogRef } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { RouterTestingModule } from '@angular/router/testing'; +import { DspApiConnectionToken } from '../declarations/dsp-api-tokens'; import { ErrorComponent } from './error.component'; describe('ErrorComponent', () => { let component: ErrorComponent; let fixture: ComponentFixture; + const systemEndpointSpyObj = { + health: { + healthEndpoint: jasmine.createSpyObj('healthEndpoint', ['getHealthStatus']) + } + }; + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [ErrorComponent], imports: [ MatIconModule, RouterTestingModule + ], + providers: [ + { + provide: MatDialogRef, + useValue: {} + }, + { + provide: DspApiConnectionToken, + useValue: systemEndpointSpyObj + }, ] }) .compileComponents(); diff --git a/src/app/main/error/error.component.ts b/src/app/main/error/error.component.ts index dfdeb90909..81290b2711 100644 --- a/src/app/main/error/error.component.ts +++ b/src/app/main/error/error.component.ts @@ -1,6 +1,8 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Inject, Input, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { ActivatedRoute } from '@angular/router'; +import { ApiResponseData, ApiResponseError, HealthResponse, KnoraApiConnection } from '@dasch-swiss/dsp-js'; +import { DspApiConnectionToken } from '../declarations/dsp-api-tokens'; export interface ErrorMsg { status: number; @@ -19,6 +21,10 @@ export class ErrorComponent implements OnInit { @Input() status: number; + @Input() comment?: string; + + refresh = false; + // default error messages errorMessages: ErrorMsg[] = [ { @@ -67,6 +73,7 @@ export class ErrorComponent implements OnInit { errorMessage: ErrorMsg; constructor( + @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection, private _titleService: Title, private _route: ActivatedRoute ) { } @@ -99,7 +106,20 @@ export class ErrorComponent implements OnInit { } reload() { - window.location.reload(); + // get api health status first and reload page only, if the api is running and status is healthy + this.refresh = true; + + this._dspApiConnection.system.healthEndpoint.getHealthStatus().subscribe( + (response: ApiResponseData) => { + if (response.body.status === 'healthy') { + window.location.reload(); + } + }, + (error: ApiResponseError) => { + this. refresh = false; + } + ); + } } diff --git a/src/app/main/header/header.component.ts b/src/app/main/header/header.component.ts index 2012aabd28..65e0c8e12c 100644 --- a/src/app/main/header/header.component.ts +++ b/src/app/main/header/header.component.ts @@ -1,14 +1,17 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; import { MatIconRegistry } from '@angular/material/icon'; import { DomSanitizer } from '@angular/platform-browser'; import { NavigationStart, Router } from '@angular/router'; +import { ApiResponseData, ApiResponseError, HealthResponse, KnoraApiConnection } from '@dasch-swiss/dsp-js'; import { Subscription } from 'rxjs'; import { AppInitService } from 'src/app/app-init.service'; import { DialogComponent } from 'src/app/main/dialog/dialog.component'; import { ComponentCommunicationEventService, Events } from 'src/app/main/services/component-communication-event.service'; import { SearchParams } from 'src/app/workspace/results/list-view/list-view.component'; +import { DspApiConnectionToken } from '../declarations/dsp-api-tokens'; import { DspConfig } from '../declarations/dsp-config'; +import { ErrorHandlerService } from '../error/error-handler.service'; import { NotificationService } from '../services/notification.service'; import { SessionService } from '../services/session.service'; @@ -28,10 +31,12 @@ export class HeaderComponent implements OnInit, OnDestroy { componentCommsSubscription: Subscription; constructor( + @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection, private _appInitService: AppInitService, private _componentCommsService: ComponentCommunicationEventService, private _dialog: MatDialog, private _domSanitizer: DomSanitizer, + private _errorHandler: ErrorHandlerService, private _matIconRegistry: MatIconRegistry, private _notification: NotificationService, private _router: Router, @@ -46,8 +51,9 @@ export class HeaderComponent implements OnInit, OnDestroy { // logged-in user? show user menu or login button this._router.events.forEach((event) => { + if (event instanceof NavigationStart) { - this._session.isSessionValid().subscribe((response) => { + this._session.isSessionValid().subscribe((response: boolean) => { this.session = response; }); } diff --git a/src/app/main/services/session.service.ts b/src/app/main/services/session.service.ts index f7f768c864..dac833fc52 100644 --- a/src/app/main/services/session.service.ts +++ b/src/app/main/services/session.service.ts @@ -3,8 +3,7 @@ import { ApiResponseData, ApiResponseError, Constants, - CredentialsResponse, - KnoraApiConnection, UserResponse + CredentialsResponse, KnoraApiConnection, UserResponse } from '@dasch-swiss/dsp-js'; import { Observable, of } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -45,10 +44,10 @@ export class SessionService { /** * max session time in milliseconds - * default value (24h): 86400000 + * default value (24h = 24 * 60 * 60 * 1000): 86400000 * */ - readonly MAX_SESSION_TIME: number = 3600; // 1d = 24 * 60 * 60 * 1000 + readonly MAX_SESSION_TIME: number = 3600; constructor( @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection @@ -100,19 +99,18 @@ export class SessionService { // check if the session is still valid: if (session.id + this.MAX_SESSION_TIME <= tsNow) { - // the internal (dsp-ui) session has expired + // the internal session has expired // check if the api credentials are still valid return this._dspApiConnection.v2.auth.checkCredentials().pipe( map((credentials: ApiResponseData | ApiResponseError) => { const idUpdated = this._updateSessionId(credentials, session, tsNow); return idUpdated; - } - ) + }) ); } else { - // the internal (dsp-ui) session is still valid + // the internal session is still valid return of(true); } } else { @@ -130,7 +128,6 @@ export class SessionService { localStorage.removeItem('session'); } - /** * returns a timestamp represented in seconds * diff --git a/src/assets/style/main.scss b/src/assets/style/main.scss index c394ce0678..ea8babdc00 100644 --- a/src/assets/style/main.scss +++ b/src/assets/style/main.scss @@ -196,3 +196,11 @@ button.space-reducer { .mat-menu-panel { min-height: 48px !important; } + +.full-size-dialog { + transition: unset !important; + .mat-dialog-container { + transition: none !important; + border-radius: 0 !important; + } +} diff --git a/src/assets/test-data/api-error-0.json b/src/assets/test-data/api-error-0.json new file mode 100644 index 0000000000..d4fdc60951 --- /dev/null +++ b/src/assets/test-data/api-error-0.json @@ -0,0 +1,65 @@ +{ + "method": "GET", + "url": "http://0.0.0.0:3333/admin/users/username/root", + "status": 0, + "error": { + "message": "ajax error", + "name": "AjaxError", + "xhr": { + "__zone_symbol__timeoutfalse": [{ + "type": "eventTask", + "state": "scheduled", + "source": "XMLHttpRequest.addEventListener:timeout", + "zone": "angular", + "runCount": 0 + }], + "__zone_symbol__errorfalse": [{ + "type": "eventTask", + "state": "running", + "source": "XMLHttpRequest.addEventListener:error", + "zone": "angular", + "runCount": 2 + }], + "__zone_symbol__readystatechangefalse": [{ + "type": "eventTask", + "state": "scheduled", + "source": "XMLHttpRequest.addEventListener:readystatechange", + "zone": "angular", + "runCount": 4 + }], + "__zone_symbol__loadfalse": [{ + "type": "eventTask", + "state": "scheduled", + "source": "XMLHttpRequest.addEventListener:load", + "zone": "angular", + "runCount": 0 + }], + "__zone_symbol__xhrSync": false, + "__zone_symbol__xhrURL": "http://0.0.0.0:3333/admin/users/username/root", + "__zone_symbol__xhrScheduled": true, + "__zone_symbol__xhrErrorBeforeScheduled": false, + "__zone_symbol__xhrTask": { + "type": "macroTask", + "state": "notScheduled", + "source": "XMLHttpRequest.send", + "zone": "angular", + "runCount": 0 + } + }, + "request": { + "async": true, + "crossDomain": true, + "withCredentials": true, + "headers": { + "Authorization": "Bearer justAnExampleFakeToken" + }, + "method": "GET", + "responseType": "json", + "timeout": 0, + "url": "http://0.0.0.0:3333/admin/users/username/root" + }, + "status": 0, + "responseType": "json", + "response": null + } +} diff --git a/src/assets/test-data/api-error-500.json b/src/assets/test-data/api-error-500.json new file mode 100644 index 0000000000..5a713b2c78 --- /dev/null +++ b/src/assets/test-data/api-error-500.json @@ -0,0 +1,73 @@ +{ + "method": "GET", + "url": "http://0.0.0.0:3333/admin/projects", + "status": 500, + "error": { + "message": "ajax error 500", + "name": "AjaxError", + "xhr": { + "__zone_symbol__timeoutfalse": [{ + "type": "eventTask", + "state": "scheduled", + "source": "XMLHttpRequest.addEventListener:timeout", + "zone": "angular", + "runCount": 0 + }], + "__zone_symbol__errorfalse": [{ + "type": "eventTask", + "state": "scheduled", + "source": "XMLHttpRequest.addEventListener:error", + "zone": "angular", + "runCount": 0 + }], + "__zone_symbol__readystatechangefalse": [{ + "type": "eventTask", + "state": "scheduled", + "source": "XMLHttpRequest.addEventListener:readystatechange", + "zone": "angular", + "runCount": 8 + }], + "__zone_symbol__loadfalse": [{ + "type": "eventTask", + "state": "running", + "source": "XMLHttpRequest.addEventListener:load", + "zone": "angular", + "runCount": 2 + }, { + "type": "macroTask", + "state": "scheduled", + "source": "XMLHttpRequest.send", + "zone": "angular", + "runCount": 0 + }], + "__zone_symbol__xhrSync": false, + "__zone_symbol__xhrURL": "http://0.0.0.0:3333/admin/projects", + "__zone_symbol__xhrScheduled": true, + "__zone_symbol__xhrErrorBeforeScheduled": false, + "__zone_symbol__xhrTask": { + "type": "macroTask", + "state": "scheduled", + "source": "XMLHttpRequest.send", + "zone": "angular", + "runCount": 0 + } + }, + "request": { + "async": true, + "crossDomain": true, + "withCredentials": true, + "headers": { + "Authorization": "Bearer justAnExampleFakeToken" + }, + "method": "GET", + "responseType": "json", + "timeout": 0, + "url": "http://0.0.0.0:3333/admin/projects" + }, + "status": 500, + "responseType": "json", + "response": { + "error": "org.knora.webapi.exceptions.TriplestoreResponseException: Couldn't parse Turtle from triplestore" + } + } +} diff --git a/src/config/config.dev.json b/src/config/config.dev.json index c34c04532d..8cf4b8685d 100644 --- a/src/config/config.dev.json +++ b/src/config/config.dev.json @@ -1,5 +1,5 @@ { - "dspRelease": "2022.02.01-rc", + "dspRelease": "work.in.progress", "apiProtocol": "http", "apiHost": "0.0.0.0", "apiPort": 3333,