From e9e09dd524a693f3a81d0da8147cae01250c74fa Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 23 Apr 2026 18:06:47 +0300 Subject: [PATCH 1/3] fix(maintenance-banner): updated message because text is deprecated --- .../maintenance-banner/maintenance-banner.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 }} } From 7782174aff44e4b029ed92e569035a4e3109aa58 Mon Sep 17 00:00:00 2001 From: nsemets Date: Fri, 24 Apr 2026 17:44:22 +0300 Subject: [PATCH 2/3] feat(maintenance): added logic for maintenance --- .../components/layout/layout.component.html | 7 ++ .../components/layout/layout.component.scss | 7 ++ .../layout/layout.component.spec.ts | 5 +- .../components/layout/layout.component.ts | 4 ++ .../interceptors/error.interceptor.spec.ts | 25 +++++++ .../core/interceptors/error.interceptor.ts | 14 ++++ .../core/models/maintenance-response.model.ts | 5 ++ .../core/services/maintenance-mode.service.ts | 66 +++++++++++++++++++ src/assets/i18n/en.json | 4 ++ .../maintenance-mode.service.mock.ts | 21 ++++++ 10 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 src/app/core/models/maintenance-response.model.ts create mode 100644 src/app/core/services/maintenance-mode.service.ts create mode 100644 src/testing/providers/maintenance-mode.service.mock.ts 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/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..09ccfdf88 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2823,6 +2823,10 @@ } } }, + "maintenance": { + "message": "OSF is down temporarily for maintenance.", + "title": "Temporary 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; + }, +}; From 848e2d94623ae4e4f65409e858737dbc515f4d34 Mon Sep 17 00:00:00 2001 From: nsemets Date: Fri, 24 Apr 2026 17:49:38 +0300 Subject: [PATCH 3/3] fix(maintenance): updated text for maintenance --- src/assets/i18n/en.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 09ccfdf88..1629b0d22 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2824,8 +2824,8 @@ } }, "maintenance": { - "message": "OSF is down temporarily for maintenance.", - "title": "Temporary Maintenance" + "message": "Please come back later.", + "title": "The OSF is currently down for scheduled maintenance." }, "shared": { "affiliatedInstitutions": {