diff --git a/src/app/core/components/layout/layout.component.html b/src/app/core/components/layout/layout.component.html index c587f4ef4..5f872d40f 100644 --- a/src/app/core/components/layout/layout.component.html +++ b/src/app/core/components/layout/layout.component.html @@ -19,6 +19,13 @@ + + @if (isMaintenanceMode()) { +
+

{{ 'maintenance.title' | translate }}

+

{{ 'maintenance.message' | translate }}

+
+ } { provideOSFCore(), MockProvider(IS_WEB, isWebSubject), MockProvider(IS_MEDIUM, isMediumSubject), - MockProvider(ConfirmationService), + MockProvider(MaintenanceModeService, MaintenanceModeServiceMock.simple()), ], }); diff --git a/src/app/core/components/layout/layout.component.ts b/src/app/core/components/layout/layout.component.ts index 63601d382..62befef60 100644 --- a/src/app/core/components/layout/layout.component.ts +++ b/src/app/core/components/layout/layout.component.ts @@ -6,6 +6,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { RouterOutlet } from '@angular/router'; +import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; import { ScrollTopOnRouteChangeDirective } from '@osf/shared/directives/scroll-top.directive'; import { IS_MEDIUM, IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; @@ -35,6 +36,9 @@ import { TopnavComponent } from '../topnav/topnav.component'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class LayoutComponent { + private readonly maintenanceModeService = inject(MaintenanceModeService); + isWeb = toSignal(inject(IS_WEB)); isMedium = toSignal(inject(IS_MEDIUM)); + isMaintenanceMode = this.maintenanceModeService.isActive; } diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html index 9dd9ed582..f3687af5c 100644 --- a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html @@ -4,9 +4,9 @@ styleClass="w-full" icon="pi pi-info-circle" [severity]="maintenance()?.severity" - [text]="maintenance()?.message" [closable]="true" (onClose)="dismiss()" > + {{ maintenance()?.message }} } diff --git a/src/app/core/interceptors/error.interceptor.spec.ts b/src/app/core/interceptors/error.interceptor.spec.ts index 8b9ef1e55..d8209e666 100644 --- a/src/app/core/interceptors/error.interceptor.spec.ts +++ b/src/app/core/interceptors/error.interceptor.spec.ts @@ -9,12 +9,17 @@ import { Router } from '@angular/router'; import { SENTRY_TOKEN } from '@core/provider/sentry.provider'; import { AuthService } from '@core/services/auth.service'; +import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { AuthServiceMock, AuthServiceMockType } from '@testing/providers/auth-service.mock'; import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { + MaintenanceModeServiceMock, + MaintenanceModeServiceMockType, +} from '@testing/providers/maintenance-mode.service.mock'; import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { SentryMock, SentryMockType } from '@testing/providers/sentry-provider.mock'; import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; @@ -28,6 +33,7 @@ describe('errorInterceptor', () => { let toastServiceMock: ToastServiceMockType; let loaderServiceMock: LoaderServiceMock; let authServiceMock: AuthServiceMockType; + let maintenanceModeServiceMock: MaintenanceModeServiceMockType; let viewOnlyHelperMock: ViewOnlyLinkHelperMockType; let sentryMock: SentryMockType; @@ -36,6 +42,7 @@ describe('errorInterceptor', () => { toastServiceMock = ToastServiceMock.simple(); loaderServiceMock = new LoaderServiceMock(); authServiceMock = AuthServiceMock.simple(); + maintenanceModeServiceMock = MaintenanceModeServiceMock.simple(); viewOnlyHelperMock = ViewOnlyLinkHelperMock.simple(viewOnly); sentryMock = SentryMock.simple(); @@ -46,6 +53,7 @@ describe('errorInterceptor', () => { MockProvider(Router, router), MockProvider(ToastService, toastServiceMock), MockProvider(AuthService, authServiceMock), + MockProvider(MaintenanceModeService, maintenanceModeServiceMock), MockProvider(ViewOnlyLinkHelperService, viewOnlyHelperMock), MockProvider(PLATFORM_ID, platformId), { provide: SENTRY_TOKEN, useValue: sentryMock }, @@ -156,4 +164,21 @@ describe('errorInterceptor', () => { expect(loaderServiceMock.hide).toHaveBeenCalled(); expect(toastServiceMock.showError).not.toHaveBeenCalled(); }); + + it('should activate maintenance mode on 503 maintenance response', async () => { + setup('browser', false); + const request = createRequest('/api/v2/'); + const error = new HttpErrorResponse({ + status: 503, + error: { meta: { maintenance_mode: true } }, + url: request.url, + }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(503); + expect(maintenanceModeServiceMock.activate).toHaveBeenCalled(); + expect(loaderServiceMock.hide).toHaveBeenCalled(); + expect(toastServiceMock.showError).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/core/interceptors/error.interceptor.ts b/src/app/core/interceptors/error.interceptor.ts index 99848cc4b..7adb6a7c2 100644 --- a/src/app/core/interceptors/error.interceptor.ts +++ b/src/app/core/interceptors/error.interceptor.ts @@ -7,8 +7,10 @@ import { inject, PLATFORM_ID } from '@angular/core'; import { Router } from '@angular/router'; import { ERROR_MESSAGES } from '@core/constants/error-messages'; +import { MaintenanceResponse } from '@core/models/maintenance-response.model'; import { SENTRY_TOKEN } from '@core/provider/sentry.provider'; import { AuthService } from '@core/services/auth.service'; +import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; import { LoaderService } from '@osf/shared/services/loader.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; @@ -20,6 +22,7 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => { const loaderService = inject(LoaderService); const router = inject(Router); const authService = inject(AuthService); + const maintenanceModeService = inject(MaintenanceModeService); const sentry = inject(SENTRY_TOKEN); const platformId = inject(PLATFORM_ID); const viewOnlyHelper = inject(ViewOnlyLinkHelperService); @@ -43,6 +46,17 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => { } const serverErrorRegex = /5\d{2}/; + const maintenanceResponse = error.error as MaintenanceResponse | null; + + const maintenanceMode = error.status === 503 && maintenanceResponse?.meta?.maintenance_mode === true; + + if (maintenanceMode) { + loaderService.hide(); + if (isPlatformBrowser(platformId)) { + maintenanceModeService.activate(); + } + return throwError(() => error); + } if (serverErrorRegex.test(error.status.toString())) { errorMessage = error.error.message || 'common.errorMessages.serverError'; diff --git a/src/app/core/models/maintenance-response.model.ts b/src/app/core/models/maintenance-response.model.ts new file mode 100644 index 000000000..88c5fea94 --- /dev/null +++ b/src/app/core/models/maintenance-response.model.ts @@ -0,0 +1,5 @@ +export interface MaintenanceResponse { + meta?: { + maintenance_mode?: boolean; + }; +} diff --git a/src/app/core/services/maintenance-mode.service.ts b/src/app/core/services/maintenance-mode.service.ts new file mode 100644 index 000000000..2059b710f --- /dev/null +++ b/src/app/core/services/maintenance-mode.service.ts @@ -0,0 +1,66 @@ +import { catchError, map, Observable, of, Subscription, switchMap, timer } from 'rxjs'; + +import { HttpClient, HttpContext } from '@angular/common/http'; +import { inject, Injectable, OnDestroy, signal } from '@angular/core'; + +import { MaintenanceResponse } from '@core/models/maintenance-response.model'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; + +import { BYPASS_ERROR_INTERCEPTOR } from '../interceptors/error-interceptor.tokens'; + +@Injectable({ + providedIn: 'root', +}) +export class MaintenanceModeService implements OnDestroy { + private readonly http = inject(HttpClient); + private readonly environment = inject(ENVIRONMENT); + + private readonly POLL_INTERVAL_MS = 5 * 60 * 1_000; + private readonly _isActive = signal(false); + private readonly bypassContext = new HttpContext().set(BYPASS_ERROR_INTERCEPTOR, true); + + private pollingSubscription: Subscription | null = null; + + readonly isActive = this._isActive.asReadonly(); + + activate(): void { + this._isActive.set(true); + if (this.pollingSubscription) { + return; + } + this.startPolling(); + } + + deactivate(): void { + this._isActive.set(false); + this.stopPolling(); + } + + ngOnDestroy(): void { + this.stopPolling(); + } + + private startPolling(): void { + this.pollingSubscription = timer(0, this.POLL_INTERVAL_MS) + .pipe(switchMap(() => this.checkMaintenanceStatus())) + .subscribe((isMaintenance) => { + if (!isMaintenance) { + this.deactivate(); + } + }); + } + + private stopPolling(): void { + this.pollingSubscription?.unsubscribe(); + this.pollingSubscription = null; + } + + private checkMaintenanceStatus(): Observable { + return this.http + .get(`${this.environment.apiDomainUrl}/v2/`, { context: this.bypassContext }) + .pipe( + map((response) => response.meta?.maintenance_mode === true), + catchError(() => of(true)) + ); + } +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 2887a045d..1629b0d22 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2823,6 +2823,10 @@ } } }, + "maintenance": { + "message": "Please come back later.", + "title": "The OSF is currently down for scheduled maintenance." + }, "shared": { "affiliatedInstitutions": { "description": "This is a service provided by the OSF and is automatically applied to your registration. If you are not sure if your institution has signed up for this service, you can look for their name in this list." diff --git a/src/testing/providers/maintenance-mode.service.mock.ts b/src/testing/providers/maintenance-mode.service.mock.ts new file mode 100644 index 000000000..7db5cdb40 --- /dev/null +++ b/src/testing/providers/maintenance-mode.service.mock.ts @@ -0,0 +1,21 @@ +import { Mock } from 'vitest'; + +import { Signal, signal } from '@angular/core'; + +import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; + +export type MaintenanceModeServiceMockType = Partial & { + activate: Mock<() => void>; + deactivate: Mock<() => void>; + isActive: Signal; +}; + +export const MaintenanceModeServiceMock = { + simple() { + return { + activate: vi.fn(), + deactivate: vi.fn(), + isActive: signal(false).asReadonly(), + } as MaintenanceModeServiceMockType; + }, +};