Skip to content

Commit a933d9d

Browse files
authored
[ENG-7471] Have a display of some form when the OSF is down for planned maintenance [FE] (#969)
- Ticket: https://openscience.atlassian.net/browse/ENG-7471 - Feature flag: n/a ## Summary of Changes 1. Added logic for maintenance.
1 parent 100d23f commit a933d9d

11 files changed

Lines changed: 157 additions & 3 deletions

File tree

src/app/core/components/layout/layout.component.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@
1919

2020
<osf-footer></osf-footer>
2121
</div>
22+
23+
@if (isMaintenanceMode()) {
24+
<section class="maintenance-overlay font-bold text-xl flex flex-column align-items-center justify-content-center">
25+
<p>{{ 'maintenance.title' | translate }}</p>
26+
<p>{{ 'maintenance.message' | translate }}</p>
27+
</section>
28+
}
2229
</main>
2330

2431
<p-confirm-dialog

src/app/core/components/layout/layout.component.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,10 @@
8484
}
8585
}
8686
}
87+
88+
.maintenance-overlay {
89+
position: fixed;
90+
inset: 0;
91+
z-index: 2000;
92+
background: var(--white);
93+
}

src/app/core/components/layout/layout.component.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { MockComponents, MockProvider } from 'ng-mocks';
22

3-
import { ConfirmationService } from 'primeng/api';
43
import { ConfirmDialog } from 'primeng/confirmdialog';
54

65
import { BehaviorSubject } from 'rxjs';
76

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

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

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

1415
import { BreadcrumbComponent } from '../breadcrumb/breadcrumb.component';
1516
import { FooterComponent } from '../footer/footer.component';
@@ -47,7 +48,7 @@ describe('LayoutComponent', () => {
4748
provideOSFCore(),
4849
MockProvider(IS_WEB, isWebSubject),
4950
MockProvider(IS_MEDIUM, isMediumSubject),
50-
MockProvider(ConfirmationService),
51+
MockProvider(MaintenanceModeService, MaintenanceModeServiceMock.simple()),
5152
],
5253
});
5354

src/app/core/components/layout/layout.component.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
66
import { toSignal } from '@angular/core/rxjs-interop';
77
import { RouterOutlet } from '@angular/router';
88

9+
import { MaintenanceModeService } from '@core/services/maintenance-mode.service';
910
import { ScrollTopOnRouteChangeDirective } from '@osf/shared/directives/scroll-top.directive';
1011
import { IS_MEDIUM, IS_WEB } from '@osf/shared/helpers/breakpoints.tokens';
1112

@@ -35,6 +36,9 @@ import { TopnavComponent } from '../topnav/topnav.component';
3536
changeDetection: ChangeDetectionStrategy.OnPush,
3637
})
3738
export class LayoutComponent {
39+
private readonly maintenanceModeService = inject(MaintenanceModeService);
40+
3841
isWeb = toSignal(inject(IS_WEB));
3942
isMedium = toSignal(inject(IS_MEDIUM));
43+
isMaintenanceMode = this.maintenanceModeService.isActive;
4044
}

src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
styleClass="w-full"
55
icon="pi pi-info-circle"
66
[severity]="maintenance()?.severity"
7-
[text]="maintenance()?.message"
87
[closable]="true"
98
(onClose)="dismiss()"
109
>
10+
{{ maintenance()?.message }}
1111
</p-message>
1212
}

src/app/core/interceptors/error.interceptor.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@ import { Router } from '@angular/router';
99

1010
import { SENTRY_TOKEN } from '@core/provider/sentry.provider';
1111
import { AuthService } from '@core/services/auth.service';
12+
import { MaintenanceModeService } from '@core/services/maintenance-mode.service';
1213
import { ToastService } from '@osf/shared/services/toast.service';
1314
import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service';
1415

1516
import { provideOSFCore } from '@testing/osf.testing.provider';
1617
import { AuthServiceMock, AuthServiceMockType } from '@testing/providers/auth-service.mock';
1718
import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock';
19+
import {
20+
MaintenanceModeServiceMock,
21+
MaintenanceModeServiceMockType,
22+
} from '@testing/providers/maintenance-mode.service.mock';
1823
import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock';
1924
import { SentryMock, SentryMockType } from '@testing/providers/sentry-provider.mock';
2025
import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock';
@@ -28,6 +33,7 @@ describe('errorInterceptor', () => {
2833
let toastServiceMock: ToastServiceMockType;
2934
let loaderServiceMock: LoaderServiceMock;
3035
let authServiceMock: AuthServiceMockType;
36+
let maintenanceModeServiceMock: MaintenanceModeServiceMockType;
3137
let viewOnlyHelperMock: ViewOnlyLinkHelperMockType;
3238
let sentryMock: SentryMockType;
3339

@@ -36,6 +42,7 @@ describe('errorInterceptor', () => {
3642
toastServiceMock = ToastServiceMock.simple();
3743
loaderServiceMock = new LoaderServiceMock();
3844
authServiceMock = AuthServiceMock.simple();
45+
maintenanceModeServiceMock = MaintenanceModeServiceMock.simple();
3946
viewOnlyHelperMock = ViewOnlyLinkHelperMock.simple(viewOnly);
4047
sentryMock = SentryMock.simple();
4148

@@ -46,6 +53,7 @@ describe('errorInterceptor', () => {
4653
MockProvider(Router, router),
4754
MockProvider(ToastService, toastServiceMock),
4855
MockProvider(AuthService, authServiceMock),
56+
MockProvider(MaintenanceModeService, maintenanceModeServiceMock),
4957
MockProvider(ViewOnlyLinkHelperService, viewOnlyHelperMock),
5058
MockProvider(PLATFORM_ID, platformId),
5159
{ provide: SENTRY_TOKEN, useValue: sentryMock },
@@ -156,4 +164,21 @@ describe('errorInterceptor', () => {
156164
expect(loaderServiceMock.hide).toHaveBeenCalled();
157165
expect(toastServiceMock.showError).not.toHaveBeenCalled();
158166
});
167+
168+
it('should activate maintenance mode on 503 maintenance response', async () => {
169+
setup('browser', false);
170+
const request = createRequest('/api/v2/');
171+
const error = new HttpErrorResponse({
172+
status: 503,
173+
error: { meta: { maintenance_mode: true } },
174+
url: request.url,
175+
});
176+
177+
const caught = await runInterceptor(request, error);
178+
179+
expect(caught?.status).toBe(503);
180+
expect(maintenanceModeServiceMock.activate).toHaveBeenCalled();
181+
expect(loaderServiceMock.hide).toHaveBeenCalled();
182+
expect(toastServiceMock.showError).not.toHaveBeenCalled();
183+
});
159184
});

src/app/core/interceptors/error.interceptor.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import { inject, PLATFORM_ID } from '@angular/core';
77
import { Router } from '@angular/router';
88

99
import { ERROR_MESSAGES } from '@core/constants/error-messages';
10+
import { MaintenanceResponse } from '@core/models/maintenance-response.model';
1011
import { SENTRY_TOKEN } from '@core/provider/sentry.provider';
1112
import { AuthService } from '@core/services/auth.service';
13+
import { MaintenanceModeService } from '@core/services/maintenance-mode.service';
1214
import { LoaderService } from '@osf/shared/services/loader.service';
1315
import { ToastService } from '@osf/shared/services/toast.service';
1416
import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service';
@@ -20,6 +22,7 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => {
2022
const loaderService = inject(LoaderService);
2123
const router = inject(Router);
2224
const authService = inject(AuthService);
25+
const maintenanceModeService = inject(MaintenanceModeService);
2326
const sentry = inject(SENTRY_TOKEN);
2427
const platformId = inject(PLATFORM_ID);
2528
const viewOnlyHelper = inject(ViewOnlyLinkHelperService);
@@ -43,6 +46,17 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => {
4346
}
4447

4548
const serverErrorRegex = /5\d{2}/;
49+
const maintenanceResponse = error.error as MaintenanceResponse | null;
50+
51+
const maintenanceMode = error.status === 503 && maintenanceResponse?.meta?.maintenance_mode === true;
52+
53+
if (maintenanceMode) {
54+
loaderService.hide();
55+
if (isPlatformBrowser(platformId)) {
56+
maintenanceModeService.activate();
57+
}
58+
return throwError(() => error);
59+
}
4660

4761
if (serverErrorRegex.test(error.status.toString())) {
4862
errorMessage = error.error.message || 'common.errorMessages.serverError';
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface MaintenanceResponse {
2+
meta?: {
3+
maintenance_mode?: boolean;
4+
};
5+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { catchError, map, Observable, of, Subscription, switchMap, timer } from 'rxjs';
2+
3+
import { HttpClient, HttpContext } from '@angular/common/http';
4+
import { inject, Injectable, OnDestroy, signal } from '@angular/core';
5+
6+
import { MaintenanceResponse } from '@core/models/maintenance-response.model';
7+
import { ENVIRONMENT } from '@core/provider/environment.provider';
8+
9+
import { BYPASS_ERROR_INTERCEPTOR } from '../interceptors/error-interceptor.tokens';
10+
11+
@Injectable({
12+
providedIn: 'root',
13+
})
14+
export class MaintenanceModeService implements OnDestroy {
15+
private readonly http = inject(HttpClient);
16+
private readonly environment = inject(ENVIRONMENT);
17+
18+
private readonly POLL_INTERVAL_MS = 5 * 60 * 1_000;
19+
private readonly _isActive = signal(false);
20+
private readonly bypassContext = new HttpContext().set(BYPASS_ERROR_INTERCEPTOR, true);
21+
22+
private pollingSubscription: Subscription | null = null;
23+
24+
readonly isActive = this._isActive.asReadonly();
25+
26+
activate(): void {
27+
this._isActive.set(true);
28+
if (this.pollingSubscription) {
29+
return;
30+
}
31+
this.startPolling();
32+
}
33+
34+
deactivate(): void {
35+
this._isActive.set(false);
36+
this.stopPolling();
37+
}
38+
39+
ngOnDestroy(): void {
40+
this.stopPolling();
41+
}
42+
43+
private startPolling(): void {
44+
this.pollingSubscription = timer(0, this.POLL_INTERVAL_MS)
45+
.pipe(switchMap(() => this.checkMaintenanceStatus()))
46+
.subscribe((isMaintenance) => {
47+
if (!isMaintenance) {
48+
this.deactivate();
49+
}
50+
});
51+
}
52+
53+
private stopPolling(): void {
54+
this.pollingSubscription?.unsubscribe();
55+
this.pollingSubscription = null;
56+
}
57+
58+
private checkMaintenanceStatus(): Observable<boolean> {
59+
return this.http
60+
.get<MaintenanceResponse>(`${this.environment.apiDomainUrl}/v2/`, { context: this.bypassContext })
61+
.pipe(
62+
map((response) => response.meta?.maintenance_mode === true),
63+
catchError(() => of(true))
64+
);
65+
}
66+
}

src/assets/i18n/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2824,6 +2824,10 @@
28242824
}
28252825
}
28262826
},
2827+
"maintenance": {
2828+
"message": "Please come back later.",
2829+
"title": "The OSF is currently down for scheduled maintenance."
2830+
},
28272831
"shared": {
28282832
"affiliatedInstitutions": {
28292833
"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."

0 commit comments

Comments
 (0)