Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/app/core/components/layout/layout.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@

<osf-footer></osf-footer>
</div>

@if (isMaintenanceMode()) {
<section class="maintenance-overlay font-bold text-xl flex flex-column align-items-center justify-content-center">
<p>{{ 'maintenance.title' | translate }}</p>
<p>{{ 'maintenance.message' | translate }}</p>
</section>
}
</main>

<p-confirm-dialog
Expand Down
7 changes: 7 additions & 0 deletions src/app/core/components/layout/layout.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,10 @@
}
}
}

.maintenance-overlay {
position: fixed;
inset: 0;
z-index: 2000;
background: var(--white);
}
5 changes: 3 additions & 2 deletions src/app/core/components/layout/layout.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { MockComponents, MockProvider } from 'ng-mocks';

import { ConfirmationService } from 'primeng/api';
import { ConfirmDialog } from 'primeng/confirmdialog';

import { BehaviorSubject } from 'rxjs';

import { ComponentFixture, TestBed } from '@angular/core/testing';

import { MaintenanceModeService } from '@core/services/maintenance-mode.service';
import { IS_MEDIUM, IS_WEB } from '@osf/shared/helpers/breakpoints.tokens';

import { provideOSFCore } from '@testing/osf.testing.provider';
import { MaintenanceModeServiceMock } from '@testing/providers/maintenance-mode.service.mock';

import { BreadcrumbComponent } from '../breadcrumb/breadcrumb.component';
import { FooterComponent } from '../footer/footer.component';
Expand Down Expand Up @@ -47,7 +48,7 @@ describe('LayoutComponent', () => {
provideOSFCore(),
MockProvider(IS_WEB, isWebSubject),
MockProvider(IS_MEDIUM, isMediumSubject),
MockProvider(ConfirmationService),
MockProvider(MaintenanceModeService, MaintenanceModeServiceMock.simple()),
],
});

Expand Down
4 changes: 4 additions & 0 deletions src/app/core/components/layout/layout.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
styleClass="w-full"
icon="pi pi-info-circle"
[severity]="maintenance()?.severity"
[text]="maintenance()?.message"
[closable]="true"
(onClose)="dismiss()"
>
{{ maintenance()?.message }}
</p-message>
}
25 changes: 25 additions & 0 deletions src/app/core/interceptors/error.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,6 +33,7 @@ describe('errorInterceptor', () => {
let toastServiceMock: ToastServiceMockType;
let loaderServiceMock: LoaderServiceMock;
let authServiceMock: AuthServiceMockType;
let maintenanceModeServiceMock: MaintenanceModeServiceMockType;
let viewOnlyHelperMock: ViewOnlyLinkHelperMockType;
let sentryMock: SentryMockType;

Expand All @@ -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();

Expand All @@ -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 },
Expand Down Expand Up @@ -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();
});
});
14 changes: 14 additions & 0 deletions src/app/core/interceptors/error.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand All @@ -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';
Expand Down
5 changes: 5 additions & 0 deletions src/app/core/models/maintenance-response.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface MaintenanceResponse {
meta?: {
maintenance_mode?: boolean;
};
}
66 changes: 66 additions & 0 deletions src/app/core/services/maintenance-mode.service.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
return this.http
.get<MaintenanceResponse>(`${this.environment.apiDomainUrl}/v2/`, { context: this.bypassContext })
.pipe(
map((response) => response.meta?.maintenance_mode === true),
catchError(() => of(true))
);
}
}
4 changes: 4 additions & 0 deletions src/assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
21 changes: 21 additions & 0 deletions src/testing/providers/maintenance-mode.service.mock.ts
Original file line number Diff line number Diff line change
@@ -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<MaintenanceModeService> & {
activate: Mock<() => void>;
deactivate: Mock<() => void>;
isActive: Signal<boolean>;
};

export const MaintenanceModeServiceMock = {
simple() {
return {
activate: vi.fn(),
deactivate: vi.fn(),
isActive: signal(false).asReadonly(),
} as MaintenanceModeServiceMockType;
},
};
Loading