diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index deb68f1ea92..1d368e67961 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -37,7 +37,7 @@ import { import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component'; -import { ServerCheckGuard } from './core/server-check/server-check.guard'; +import { ServerStatusGuard } from './core/server-check/server-status-guard.service'; import { MenuResolver } from './menu.resolver'; import { ThemedPageErrorComponent } from './page-error/themed-page-error.component'; @@ -49,7 +49,7 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone { path: '', canActivate: [AuthBlockingGuard], - canActivateChild: [ServerCheckGuard], + canActivateChild: [ServerStatusGuard], resolve: [MenuResolver], children: [ { path: '', redirectTo: '/home', pathMatch: 'full' }, diff --git a/src/app/core/core-state.model.ts b/src/app/core/core-state.model.ts index b8211fdb555..8bb8e766286 100644 --- a/src/app/core/core-state.model.ts +++ b/src/app/core/core-state.model.ts @@ -11,6 +11,7 @@ import { JsonPatchOperationsState } from './json-patch/json-patch-operations.red import { MetaTagState } from './metadata/meta-tag.reducer'; import { RouteState } from './services/route.reducer'; import { RequestState } from './data/request-state.model'; +import { ServerStatusState } from './history/server-status.reducer'; /** * The core sub-state in the NgRx store @@ -22,6 +23,7 @@ export interface CoreState { 'cache/object-updates': ObjectUpdatesState; 'data/request': RequestState; 'history': HistoryState; + 'serverStatus': ServerStatusState; 'index': MetaIndexState; 'auth': AuthState; 'json/patch': JsonPatchOperationsState; diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index c0165c53848..c165504be2d 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -14,6 +14,7 @@ import { import { historyReducer } from './history/history.reducer'; import { metaTagReducer } from './metadata/meta-tag.reducer'; import { CoreState } from './core-state.model'; +import { serverStatusReducer } from './history/server-status.reducer'; export const coreReducers: ActionReducerMap = { 'bitstreamFormats': bitstreamFormatReducer, @@ -22,6 +23,7 @@ export const coreReducers: ActionReducerMap = { 'cache/object-updates': objectUpdatesReducer, 'data/request': requestReducer, 'history': historyReducer, + 'serverStatus': serverStatusReducer, 'index': indexReducer, 'auth': authReducer, 'json/patch': jsonPatchOperationsReducer, diff --git a/src/app/core/data/request.effects.ts b/src/app/core/data/request.effects.ts index d79dd9a2835..a023550bc47 100644 --- a/src/app/core/data/request.effects.ts +++ b/src/app/core/data/request.effects.ts @@ -23,11 +23,12 @@ import { RequestError } from './request-error.model'; import { RestRequestMethod } from './rest-request-method'; import { RestRequestWithResponseParser } from './rest-request-with-response-parser.model'; import { RequestEntry } from './request-entry.model'; +import { ServerStatusService } from '../server-check/server-status.service'; @Injectable() export class RequestEffects { - execute = createEffect(() => this.actions$.pipe( + execute = createEffect(() => this.actions$.pipe( ofType(RequestActionTypes.EXECUTE), mergeMap((action: RequestExecuteAction) => { return this.requestService.getByUUID(action.payload).pipe( @@ -51,7 +52,14 @@ export class RequestEffects { map((response: ParsedResponse) => new RequestSuccessAction(request.uuid, response.statusCode, response.link, response.unCacheableObject)), catchError((error: RequestError) => { if (hasValue(error.statusCode)) { - // if it's an error returned by the server, complete the request + // if it's an error returned by the server, check if the server is still running and update its status + // then navigate to the internal error page while still completing the request + this.serverStatusService.checkAndUpdateServerStatus(request, error) + .subscribe((isAvailable: boolean) => { + if (!isAvailable) { + this.serverStatusService.navigateToInternalServerErrorPage(); + } + }); return [new RequestErrorAction(request.uuid, error.statusCode, error.message)]; } else { // if it's a client side error, throw it @@ -70,7 +78,7 @@ export class RequestEffects { * This assumes that the server cached everything a negligible * time ago, and will likely need to be revisited later */ - fixTimestampsOnRehydrate = createEffect(() => this.actions$ + fixTimestampsOnRehydrate = createEffect(() => this.actions$ .pipe(ofType(StoreActionTypes.REHYDRATE), map(() => new ResetResponseTimestampsAction(new Date().getTime())) )); @@ -81,6 +89,8 @@ export class RequestEffects { private injector: Injector, protected requestService: RequestService, protected xsrfService: XSRFService, - ) { } + protected serverStatusService: ServerStatusService, + ) { + } } diff --git a/src/app/core/data/root-data.service.spec.ts b/src/app/core/data/root-data.service.spec.ts index c34ad375310..c2d8eb84ee8 100644 --- a/src/app/core/data/root-data.service.spec.ts +++ b/src/app/core/data/root-data.service.spec.ts @@ -1,13 +1,11 @@ import { RootDataService } from './root-data.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { - createSuccessfulRemoteDataObject$, - createFailedRemoteDataObject$ + createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { Observable } from 'rxjs'; import { RemoteData } from './remote-data'; import { Root } from './root.model'; -import { cold } from 'jasmine-marbles'; describe('RootDataService', () => { let service: RootDataService; @@ -21,9 +19,9 @@ describe('RootDataService', () => { halService = jasmine.createSpyObj('halService', { getRootHref: rootEndpoint, }); - requestService = jasmine.createSpyObj('requestService', [ - 'setStaleByHref', - ]); + requestService = jasmine.createSpyObj('halService', { + setStaleByHref: {}, + }); service = new RootDataService(requestService, null, null, halService); findByHrefSpy = spyOn(service as any, 'findByHref'); @@ -45,36 +43,14 @@ describe('RootDataService', () => { }); }); - describe('checkServerAvailability', () => { - let result$: Observable; - - it('should return observable of true when root endpoint is available', () => { - spyOn(service, 'findRoot').and.returnValue(createSuccessfulRemoteDataObject$({} as any)); - - result$ = service.checkServerAvailability(); - - expect(result$).toBeObservable(cold('(a|)', { - a: true - })); - }); - - it('should return observable of false when root endpoint is not available', () => { - spyOn(service, 'findRoot').and.returnValue(createFailedRemoteDataObject$('500')); - - result$ = service.checkServerAvailability(); - - expect(result$).toBeObservable(cold('(a|)', { - a: false - })); - }); - - }); - - describe(`invalidateRootCache`, () => { - it(`should set the cached root request to stale`, () => { + describe('invalidateRootCache', () => { + it('should call setStaleByHrefSubstring with the root endpoint href', () => { service.invalidateRootCache(); + expect(halService.getRootHref).toHaveBeenCalled(); expect(requestService.setStaleByHref).toHaveBeenCalledWith(rootEndpoint); }); }); + + }); diff --git a/src/app/core/data/root-data.service.ts b/src/app/core/data/root-data.service.ts index 5431a2d1fb2..a950c957c04 100644 --- a/src/app/core/data/root-data.service.ts +++ b/src/app/core/data/root-data.service.ts @@ -4,14 +4,12 @@ import { ROOT } from './root.resource-type'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { Observable } from 'rxjs'; import { RemoteData } from './remote-data'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { catchError, map } from 'rxjs/operators'; import { BaseDataService } from './base/base-data.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { dataService } from './base/data-service.decorator'; -import { getFirstCompletedRemoteData } from '../shared/operators'; /** * A service to retrieve the {@link Root} object from the REST API. @@ -28,20 +26,6 @@ export class RootDataService extends BaseDataService { super('', requestService, rdbService, objectCache, halService, 6 * 60 * 60 * 1000); } - /** - * Check if root endpoint is available - */ - checkServerAvailability(): Observable { - return this.findRoot().pipe( - catchError((err ) => { - console.error(err); - return observableOf(false); - }), - getFirstCompletedRemoteData(), - map((rootRd: RemoteData) => rootRd.statusCode === 200) - ); - } - /** * Find the {@link Root} object of the REST API * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's diff --git a/src/app/core/history/server-status.actions.ts b/src/app/core/history/server-status.actions.ts new file mode 100644 index 00000000000..efc01ac16d5 --- /dev/null +++ b/src/app/core/history/server-status.actions.ts @@ -0,0 +1,24 @@ +/* eslint-disable max-classes-per-file */ +import { Action } from '@ngrx/store'; + +import { type } from '../../shared/ngrx/type'; + +export const ServerStatusActionTypes = { + UPDATE_SERVER_STATUS: type('dspace/server-status/UPDATE_SERVER_STATUS'), +}; + + +export class UpdateServerStatusAction implements Action { + type = ServerStatusActionTypes.UPDATE_SERVER_STATUS; + payload: { + isAvailable: boolean; + }; + + constructor(isAvailable: boolean) { + this.payload = {isAvailable}; + } +} + + +export type ServerStatusAction + = UpdateServerStatusAction; diff --git a/src/app/core/history/server-status.reducer.ts b/src/app/core/history/server-status.reducer.ts new file mode 100644 index 00000000000..a0f4f98cab1 --- /dev/null +++ b/src/app/core/history/server-status.reducer.ts @@ -0,0 +1,25 @@ +import { ServerStatusAction, ServerStatusActionTypes, UpdateServerStatusAction } from './server-status.actions'; + +/** + * The auth state. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ServerStatusState { + isAvailable: boolean; +} + +/** + * The initial state. + */ +const initialState: ServerStatusState = {isAvailable: true}; + +export function serverStatusReducer(state = initialState, action: ServerStatusAction): ServerStatusState { + switch (action.type) { + case ServerStatusActionTypes.UPDATE_SERVER_STATUS: { + return {isAvailable: (action as UpdateServerStatusAction).payload.isAvailable}; + } + default: { + return state; + } + } +} diff --git a/src/app/core/server-check/server-check.guard.spec.ts b/src/app/core/server-check/server-check.guard.spec.ts deleted file mode 100644 index f65a7deca7c..00000000000 --- a/src/app/core/server-check/server-check.guard.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { ServerCheckGuard } from './server-check.guard'; -import { Router, NavigationStart, UrlTree, NavigationEnd, RouterEvent } from '@angular/router'; - -import { of, ReplaySubject } from 'rxjs'; -import { RootDataService } from '../data/root-data.service'; -import { TestScheduler } from 'rxjs/testing'; -import SpyObj = jasmine.SpyObj; - -describe('ServerCheckGuard', () => { - let guard: ServerCheckGuard; - let router: Router; - let eventSubject: ReplaySubject; - let rootDataServiceStub: SpyObj; - let testScheduler: TestScheduler; - let redirectUrlTree: UrlTree; - - beforeEach(() => { - testScheduler = new TestScheduler((actual, expected) => { - expect(actual).toEqual(expected); - }); - rootDataServiceStub = jasmine.createSpyObj('RootDataService', { - checkServerAvailability: jasmine.createSpy('checkServerAvailability'), - invalidateRootCache: jasmine.createSpy('invalidateRootCache'), - findRoot: jasmine.createSpy('findRoot') - }); - redirectUrlTree = new UrlTree(); - eventSubject = new ReplaySubject(1); - router = { - events: eventSubject.asObservable(), - navigateByUrl: jasmine.createSpy('navigateByUrl'), - parseUrl: jasmine.createSpy('parseUrl').and.returnValue(redirectUrlTree) - } as any; - guard = new ServerCheckGuard(router, rootDataServiceStub); - }); - - it('should be created', () => { - expect(guard).toBeTruthy(); - }); - - describe('when root endpoint request has succeeded', () => { - beforeEach(() => { - rootDataServiceStub.checkServerAvailability.and.returnValue(of(true)); - }); - - it('should return true', () => { - testScheduler.run(({ expectObservable }) => { - const result$ = guard.canActivateChild({} as any, {} as any); - expectObservable(result$).toBe('(a|)', { a: true }); - }); - }); - }); - - describe('when root endpoint request has not succeeded', () => { - beforeEach(() => { - rootDataServiceStub.checkServerAvailability.and.returnValue(of(false)); - }); - - it('should return a UrlTree with the route to the 500 error page', () => { - testScheduler.run(({ expectObservable }) => { - const result$ = guard.canActivateChild({} as any, {} as any); - expectObservable(result$).toBe('(b|)', { b: redirectUrlTree }); - }); - expect(router.parseUrl).toHaveBeenCalledWith('/500'); - }); - }); - - describe(`listenForRouteChanges`, () => { - it(`should invalidate the root cache, when the method is first called`, () => { - testScheduler.run(() => { - guard.listenForRouteChanges(); - expect(rootDataServiceStub.invalidateRootCache).toHaveBeenCalledTimes(1); - }); - }); - - it(`should invalidate the root cache on every NavigationStart event`, () => { - testScheduler.run(() => { - guard.listenForRouteChanges(); - eventSubject.next(new NavigationStart(1,'')); - eventSubject.next(new NavigationEnd(1,'', '')); - eventSubject.next(new NavigationStart(2,'')); - eventSubject.next(new NavigationEnd(2,'', '')); - eventSubject.next(new NavigationStart(3,'')); - }); - // once when the method is first called, and then 3 times for NavigationStart events - expect(rootDataServiceStub.invalidateRootCache).toHaveBeenCalledTimes(1 + 3); - }); - }); -}); diff --git a/src/app/core/server-check/server-check.guard.ts b/src/app/core/server-check/server-check.guard.ts deleted file mode 100644 index 79c34c36590..00000000000 --- a/src/app/core/server-check/server-check.guard.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Injectable } from '@angular/core'; -import { - ActivatedRouteSnapshot, - CanActivateChild, - Router, - RouterStateSnapshot, - UrlTree, - NavigationStart -} from '@angular/router'; - -import { Observable } from 'rxjs'; -import { take, map, filter } from 'rxjs/operators'; - -import { RootDataService } from '../data/root-data.service'; -import { getPageInternalServerErrorRoute } from '../../app-routing-paths'; - -@Injectable({ - providedIn: 'root' -}) -/** - * A guard that checks if root api endpoint is reachable. - * If not redirect to 500 error page - */ -export class ServerCheckGuard implements CanActivateChild { - constructor(private router: Router, private rootDataService: RootDataService) { - } - - /** - * True when root api endpoint is reachable. - */ - canActivateChild( - route: ActivatedRouteSnapshot, - state: RouterStateSnapshot - ): Observable { - - return this.rootDataService.checkServerAvailability().pipe( - take(1), - map((isAvailable: boolean) => { - if (!isAvailable) { - return this.router.parseUrl(getPageInternalServerErrorRoute()); - } else { - return true; - } - }) - ); - } - - /** - * Listen to all router events. Every time a new navigation starts, invalidate the cache - * for the root endpoint. That way we retrieve it once per routing operation to ensure the - * backend is not down. But if the guard is called multiple times during the same routing - * operation, the cached version is used. - */ - listenForRouteChanges(): void { - // we'll always be too late for the first NavigationStart event with the router subscribe below, - // so this statement is for the very first route operation. - this.rootDataService.invalidateRootCache(); - - this.router.events.pipe( - filter(event => event instanceof NavigationStart), - ).subscribe(() => { - this.rootDataService.invalidateRootCache(); - }); - } -} diff --git a/src/app/core/server-check/server-status-guard.service.ts b/src/app/core/server-check/server-status-guard.service.ts new file mode 100644 index 00000000000..b6ed9b9dfa4 --- /dev/null +++ b/src/app/core/server-check/server-status-guard.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivateChild, RouterStateSnapshot } from '@angular/router'; + +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { ServerStatusService } from './server-status.service'; + + +@Injectable({ + providedIn: 'root' +}) +/** + * A guard that checks the server state from the store + * If the server state in the store is not available, redirect to 500 error page + */ +export class ServerStatusGuard implements CanActivateChild { + constructor(protected serverStatusService: ServerStatusService,) { + } + + /** + * True when the server status in the store is available. + */ + canActivateChild( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot): Observable { + return this.serverStatusService.checkServerAvailabilityFromStore() + .pipe( + tap((isAvailableInStore: boolean) => { + if (!isAvailableInStore) { + this.serverStatusService.navigateToInternalServerErrorPage(); + } + }) + ); + } +} diff --git a/src/app/core/server-check/server-status.guard.spec.ts b/src/app/core/server-check/server-status.guard.spec.ts new file mode 100644 index 00000000000..b4753ecd4b5 --- /dev/null +++ b/src/app/core/server-check/server-status.guard.spec.ts @@ -0,0 +1,59 @@ +import { ServerStatusGuard } from './server-status-guard.service'; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; + +import { of } from 'rxjs'; +import { ServerStatusService } from './server-status.service'; +import SpyObj = jasmine.SpyObj; + +describe('ServerStatusGuard', () => { + let guard: ServerStatusGuard; + let serverStatusService: SpyObj; + let mockRouteSnapshot: ActivatedRouteSnapshot; + let mockRouterStateSnapshot: RouterStateSnapshot; + + + serverStatusService = jasmine.createSpyObj('ServerStatusService', [ + 'checkServerAvailabilityFromStore', + 'navigateToInternalServerErrorPage', + ]); + + beforeEach(() => { + guard = new ServerStatusGuard(serverStatusService); + mockRouteSnapshot = {} as ActivatedRouteSnapshot; + mockRouterStateSnapshot = {} as RouterStateSnapshot; + }); + + afterEach(() => { + serverStatusService.checkServerAvailabilityFromStore.calls.reset(); + serverStatusService.navigateToInternalServerErrorPage.calls.reset(); + }); + + it('should be created', () => { + expect(guard).toBeTruthy(); + }); + + + describe('canActivate', () => { + it('should allow child activation when server status is available in the store', (done) => { + serverStatusService.checkServerAvailabilityFromStore.and.returnValue(of(true)); + + guard.canActivateChild(mockRouteSnapshot, mockRouterStateSnapshot).subscribe((result) => { + expect(result).toBeTrue(); + expect(serverStatusService.checkServerAvailabilityFromStore).toHaveBeenCalled(); + expect(serverStatusService.navigateToInternalServerErrorPage).not.toHaveBeenCalled(); + done(); + }); + }); + }); + + it('should redirect to the 500 error page when server status is unavailable in the store', (done) => { + serverStatusService.checkServerAvailabilityFromStore.and.returnValue(of(false)); + + guard.canActivateChild(mockRouteSnapshot, mockRouterStateSnapshot).subscribe((result) => { + expect(result).toBeFalse(); + expect(serverStatusService.checkServerAvailabilityFromStore).toHaveBeenCalled(); + expect(serverStatusService.navigateToInternalServerErrorPage).toHaveBeenCalled(); + done(); + }); + }); +}); diff --git a/src/app/core/server-check/server-status.service.spec.ts b/src/app/core/server-check/server-status.service.spec.ts new file mode 100644 index 00000000000..9920be0eb24 --- /dev/null +++ b/src/app/core/server-check/server-status.service.spec.ts @@ -0,0 +1,148 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; + +import { ServerStatusService } from './server-status.service'; +import { RootDataService } from '../data/root-data.service'; +import { getPageInternalServerErrorRoute } from '../../app-routing-paths'; +import { UpdateServerStatusAction } from '../history/server-status.actions'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { Root } from '../data/root.model'; + +describe('ServerStatusService', () => { + let service: ServerStatusService; + let rootDataServiceMock; + let storeMock; + let routerMock; + + beforeEach(() => { + rootDataServiceMock = jasmine.createSpyObj('RootDataService', ['invalidateRootCache', 'findRoot']); + storeMock = jasmine.createSpyObj('Store', ['select', 'dispatch']); + routerMock = jasmine.createSpyObj('Router', ['navigateByUrl']); + + TestBed.configureTestingModule({ + providers: [ + ServerStatusService, + {provide: RootDataService, useValue: rootDataServiceMock}, + {provide: Store, useValue: storeMock}, + {provide: Router, useValue: routerMock}, + ], + }); + + service = TestBed.inject(ServerStatusService); + }); + + describe('checkServerAvailabilityFromStore', () => { + it('should return true if the server status is available in the store', (done) => { + const serverState = {isAvailable: true}; + storeMock.select.and.returnValue(of(serverState)); + + service.checkServerAvailabilityFromStore().subscribe((result) => { + expect(result).toBeTrue(); + expect(storeMock.select).toHaveBeenCalled(); + expect(rootDataServiceMock.invalidateRootCache).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should verify root availability if the server status is unavailable in the store', (done) => { + const serverState = {isAvailable: false}; + storeMock.select.and.returnValue(of(serverState)); + spyOn(service, 'isRootServerAvailable').and.returnValue(of(true)); + + service.checkServerAvailabilityFromStore().subscribe((result) => { + expect(result).toBeTrue(); + expect(storeMock.select).toHaveBeenCalled(); + expect(rootDataServiceMock.invalidateRootCache).toHaveBeenCalled(); + expect(service.isRootServerAvailable).toHaveBeenCalled(); + done(); + }); + }); + + it('should return false if the root endpoint is unavailable', (done) => { + const serverState = {isAvailable: false}; + storeMock.select.and.returnValue(of(serverState)); + spyOn(service, 'isRootServerAvailable').and.returnValue(of(false)); + + service.checkServerAvailabilityFromStore().subscribe((result) => { + expect(result).toBeFalse(); + expect(storeMock.select).toHaveBeenCalled(); + expect(rootDataServiceMock.invalidateRootCache).toHaveBeenCalled(); + expect(service.isRootServerAvailable).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe('isRootServerAvailable', () => { + it('should return true if the root endpoint is available', (done) => { + rootDataServiceMock.findRoot.and.returnValue(createSuccessfulRemoteDataObject$(new Root())); + service.isRootServerAvailable().subscribe((result) => { + expect(result).toBeTrue(); + expect(rootDataServiceMock.findRoot).toHaveBeenCalled(); + done(); + }); + }); + it('should return false if the root endpoint is not available', (done) => { + rootDataServiceMock.findRoot.and.returnValue(createFailedRemoteDataObject$('error', 500)); + service.isRootServerAvailable().subscribe((result) => { + expect(result).toBeFalse(); + expect(rootDataServiceMock.findRoot).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe('checkAndUpdateServerStatus', () => { + it('should dispatch an UpdateServerStatusAction if the server is down', (done) => { + const request = {method: 'GET'} as any; + const error = {statusCode: 500} as any; + spyOn(service, 'isRootServerAvailable').and.returnValue(of(false)); + + service.checkAndUpdateServerStatus(request, error).subscribe((result) => { + expect(result).toBeFalse(); + expect(service.isRootServerAvailable).toHaveBeenCalled(); + expect(storeMock.dispatch).toHaveBeenCalledWith(new UpdateServerStatusAction(false)); + done(); + }); + }); + + it('should return true if the server is available during the status check', (done) => { + const request = {method: 'GET'} as any; + const error = {statusCode: 500} as any; + spyOn(service, 'isRootServerAvailable').and.returnValue(of(true)); + + service.checkAndUpdateServerStatus(request, error).subscribe((result) => { + expect(result).toBeTrue(); + expect(service.isRootServerAvailable).toHaveBeenCalled(); + expect(storeMock.dispatch).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should return true if the error does not match the specified criteria', (done) => { + const request = {method: 'POST'} as any; + const error = {statusCode: 500} as any; + spyOn(service, 'isRootServerAvailable'); + + service.checkAndUpdateServerStatus(request, error).subscribe((result) => { + expect(result).toBeTrue(); + expect(service.isRootServerAvailable).not.toHaveBeenCalled(); + expect(storeMock.dispatch).not.toHaveBeenCalled(); + done(); + }); + }); + }); + + describe('navigateToInternalServerErrorPage', () => { + it('should invalidate the root cache and navigate to the internal server error page', () => { + const errorPageRoute = getPageInternalServerErrorRoute(); + + service.navigateToInternalServerErrorPage(); + + expect(rootDataServiceMock.invalidateRootCache).toHaveBeenCalled(); + expect(routerMock.navigateByUrl).toHaveBeenCalledWith(errorPageRoute); + }); + }); +}); diff --git a/src/app/core/server-check/server-status.service.ts b/src/app/core/server-check/server-status.service.ts new file mode 100644 index 00000000000..cc33541a07a --- /dev/null +++ b/src/app/core/server-check/server-status.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from '@angular/core'; +import { Observable, of, of as observableOf, switchMap } from 'rxjs'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { catchError, map, tap } from 'rxjs/operators'; +import { RemoteData } from '../data/remote-data'; +import { Root } from '../data/root.model'; +import { RootDataService } from '../data/root-data.service'; +import { AppState } from '../../app.reducer'; +import { createSelector, Store } from '@ngrx/store'; +import { UpdateServerStatusAction } from '../history/server-status.actions'; +import { getPageInternalServerErrorRoute } from '../../app-routing-paths'; +import { Router } from '@angular/router'; +import { coreSelector } from '../core.selectors'; +import { CoreState } from '../core-state.model'; +import { RestRequestWithResponseParser } from '../data/rest-request-with-response-parser.model'; +import { RequestError } from '../data/request-error.model'; + +export const getServerStatusState = createSelector(coreSelector, (state: CoreState) => state.serverStatus); + +/** + * Service responsible for checking and storing the server status (whether it is running or not) + */ +@Injectable({ + providedIn: 'root' +}) +export class ServerStatusService { + + constructor( + protected rootDataService: RootDataService, + protected store: Store, + protected router: Router, + ) { + } + + + /** + * Check if the server is running based on the value in the store. + * When the server is not available according to the store, the root endpoint will be checked to verify + * if the server is still down or has come up again. + */ + checkServerAvailabilityFromStore(): Observable { + return this.store.select(getServerStatusState).pipe( + map((state) => state.isAvailable), + switchMap((isAvailable: boolean) => { + if (!isAvailable) { + this.rootDataService.invalidateRootCache(); + return this.isRootServerAvailable(); + } else { + return of(isAvailable); + } + }) + ); + } + + + /** + * Check if the root endpoint is available + */ + isRootServerAvailable() { + return this.rootDataService.findRoot(false).pipe( + getFirstCompletedRemoteData(), + catchError((err) => { + console.error(err); + return observableOf(false); + }), + map((rd: RemoteData) => rd.statusCode === 200) + ); + } + + /** + * When a request with an error is provided, the available of the root endpoint will be checked. + * If the root server is down, update the server status in the store + * Returns whether the server is running or not + * @param request - The request to be checked + * @param error - The error from the request to be checked + */ + checkAndUpdateServerStatus(request: RestRequestWithResponseParser, error: RequestError): Observable { + if ((error.statusCode === 500 || error.statusCode === 0) && request.method === 'GET' && this.router.url !== getPageInternalServerErrorRoute()) { + return this.isRootServerAvailable().pipe( + tap((isAvailable: boolean) => { + if (!isAvailable) { + this.store.dispatch(new UpdateServerStatusAction(false)); + } + }) + ); + } + return observableOf(true); + } + + /** + * Clear the root server cache to ensure that future requests do not use the cached error responses, + * then navigate to the internal server error page + */ + navigateToInternalServerErrorPage() { + this.rootDataService.invalidateRootCache(); + this.router.navigateByUrl(getPageInternalServerErrorRoute()); + } + + +} diff --git a/src/app/root/root.component.html b/src/app/root/root.component.html index 5f107a73497..06ab7b775ff 100644 --- a/src/app/root/root.component.html +++ b/src/app/root/root.component.html @@ -13,10 +13,10 @@
-
+
-
+
diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index 4c4370fc62c..f1017d865e8 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -1,8 +1,8 @@ -import { first, map, skipWhile, startWith } from 'rxjs/operators'; -import { Component, Input, OnInit } from '@angular/core'; -import { Router } from '@angular/router'; +import { first, map, skipWhile, startWith, switchMap } from 'rxjs/operators'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; -import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; +import { BehaviorSubject, combineLatest as combineLatestObservable, Observable, of, Subscription } from 'rxjs'; import { CSSVariableService } from '../shared/sass-helper/css-variable.service'; import { MenuService } from '../shared/menu/menu.service'; import { HostWindowService } from '../shared/host-window.service'; @@ -19,7 +19,7 @@ import { INotificationBoardOptions } from 'src/config/notifications-config.inter styleUrls: ['./root.component.scss'], animations: [slideSidebarPadding], }) -export class RootComponent implements OnInit { +export class RootComponent implements OnInit, OnDestroy { theme: Observable = of({} as any); isSidebarVisible$: Observable; slideSidebarOver$: Observable; @@ -38,16 +38,21 @@ export class RootComponent implements OnInit { */ @Input() shouldShowRouteLoader: boolean; + shouldShowRouteLoader$: BehaviorSubject = new BehaviorSubject(false); + + subs: Subscription[] = []; + constructor( private router: Router, private cssService: CSSVariableService, private menuService: MenuService, - private windowService: HostWindowService + private windowService: HostWindowService, ) { this.notificationOptions = environment.notifications; } ngOnInit() { + this.isSidebarVisible$ = this.menuService.isMenuVisibleWithVisibleSections(MenuID.ADMIN); this.expandedSidebarWidth$ = this.cssService.getVariable('--ds-admin-sidebar-total-width').pipe( @@ -66,9 +71,31 @@ export class RootComponent implements OnInit { startWith(true), ); - if (this.router.url === getPageInternalServerErrorRoute()) { - this.shouldShowRouteLoader = false; + if (this.shouldShowRouteLoader && !this.shouldShowFullscreenLoader) { + this.subs.push( + this.router.events.pipe( + map(() => this.router.routerState.root), + switchMap((route: ActivatedRoute) => { + route = this.getCurrentRoute(route); + return route.url; + }), + map((urlSegment) => '/' + urlSegment.join('/')), + map((url) => url === getPageInternalServerErrorRoute()), + ).subscribe((isInternalServerError) => { + this.shouldShowRouteLoader$.next(!isInternalServerError); + })); + } + } + + private getCurrentRoute(route: ActivatedRoute): ActivatedRoute { + while (route.firstChild) { + route = route.firstChild; } + return route; + } + + ngOnDestroy(): void { + this.subs.forEach((sub) => sub.unsubscribe()); } skipToMainContent() { diff --git a/src/modules/app/browser-init.service.ts b/src/modules/app/browser-init.service.ts index 4b1c6b40d27..9880a85793e 100644 --- a/src/modules/app/browser-init.service.ts +++ b/src/modules/app/browser-init.service.ts @@ -31,9 +31,7 @@ import { isNotEmpty } from '../../app/shared/empty.util'; import { logStartupMessage } from '../../../startup-message'; import { MenuService } from '../../app/shared/menu/menu.service'; import { RequestService } from '../../app/core/data/request.service'; -import { RootDataService } from '../../app/core/data/root-data.service'; import { firstValueFrom, lastValueFrom, Subscription } from 'rxjs'; -import { ServerCheckGuard } from '../../app/core/server-check/server-check.guard'; import { HALEndpointService } from '../../app/core/shared/hal-endpoint.service'; import { BuildConfig } from '../../config/build-config.interface'; @@ -60,8 +58,6 @@ export class BrowserInitService extends InitService { protected authService: AuthService, protected themeService: ThemeService, protected menuService: MenuService, - private rootDataService: RootDataService, - protected serverCheckGuard: ServerCheckGuard, private requestService: RequestService, private halService: HALEndpointService, ) { @@ -190,7 +186,6 @@ export class BrowserInitService extends InitService { */ protected initRouteListeners(): void { super.initRouteListeners(); - this.serverCheckGuard.listenForRouteChanges(); } }