From b3411f2f6a635b1cfda5ed2db17f828a67c5ae40 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Tue, 5 May 2020 10:51:52 +0200 Subject: [PATCH] feat(admin-ui): Add system health status page Relates to #289 --- .../main-nav/main-nav.component.html | 8 +++ .../admin-ui/src/lib/core/src/core.module.ts | 6 +- .../src/lib/core/src/data/data.module.ts | 18 ++--- .../core/src/data/providers/interceptor.ts | 2 + .../src/data/utils/get-server-location.ts | 17 +++++ .../health-check/health-check.service.ts | 67 +++++++++++++++++++ .../admin-ui/src/lib/core/src/public_api.ts | 2 + .../health-check/health-check.component.html | 65 ++++++++++++++++++ .../health-check/health-check.component.scss | 15 +++++ .../health-check/health-check.component.ts | 12 ++++ .../src/lib/settings/src/public_api.ts | 1 + .../src/lib/settings/src/settings.module.ts | 2 + .../src/lib/settings/src/settings.routes.ts | 8 +++ .../src/lib/static/i18n-messages/en.json | 12 ++++ 14 files changed, 220 insertions(+), 15 deletions(-) create mode 100644 packages/admin-ui/src/lib/core/src/data/utils/get-server-location.ts create mode 100644 packages/admin-ui/src/lib/core/src/providers/health-check/health-check.service.ts create mode 100644 packages/admin-ui/src/lib/settings/src/components/health-check/health-check.component.html create mode 100644 packages/admin-ui/src/lib/settings/src/components/health-check/health-check.component.scss create mode 100644 packages/admin-ui/src/lib/settings/src/components/health-check/health-check.component.ts diff --git a/packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html b/packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html index ef8e6ed858..8c7f3a9e9f 100644 --- a/packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html +++ b/packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html @@ -26,6 +26,14 @@ diff --git a/packages/admin-ui/src/lib/core/src/core.module.ts b/packages/admin-ui/src/lib/core/src/core.module.ts index 7257f90d80..00344ee1d3 100644 --- a/packages/admin-ui/src/lib/core/src/core.module.ts +++ b/packages/admin-ui/src/lib/core/src/core.module.ts @@ -4,7 +4,7 @@ import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { TranslateCompiler, TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { MESSAGE_FORMAT_CONFIG, MessageFormatConfig } from 'ngx-translate-messageformat-compiler'; +import { MessageFormatConfig, MESSAGE_FORMAT_CONFIG } from 'ngx-translate-messageformat-compiler'; import { getAppConfig } from './app.config'; import { getDefaultUiLanguage } from './common/utilities/get-default-ui-language'; @@ -66,7 +66,7 @@ export class CoreModule { if (!availableLanguages.includes(defaultLanguage)) { throw new Error( `The defaultLanguage "${defaultLanguage}" must be one of the availableLanguages [${availableLanguages - .map((l) => `"${l}"`) + .map(l => `"${l}"`) .join(', ')}]`, ); } @@ -95,7 +95,7 @@ export function HttpLoaderFactory(http: HttpClient, location: PlatformLocation) export function getLocales(): MessageFormatConfig { const locales = getAppConfig().availableLanguages; const defaultLanguage = getDefaultUiLanguage(); - const localesWithoutDefault = locales.filter((l) => l !== defaultLanguage); + const localesWithoutDefault = locales.filter(l => l !== defaultLanguage); return { locales: [defaultLanguage, ...localesWithoutDefault], }; diff --git a/packages/admin-ui/src/lib/core/src/data/data.module.ts b/packages/admin-ui/src/lib/core/src/data/data.module.ts index 4b4b40f736..ecc5e114a6 100644 --- a/packages/admin-ui/src/lib/core/src/data/data.module.ts +++ b/packages/admin-ui/src/lib/core/src/data/data.module.ts @@ -1,6 +1,6 @@ -import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; +import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; import { APP_INITIALIZER, Injector, NgModule } from '@angular/core'; -import { APOLLO_OPTIONS, ApolloModule } from 'apollo-angular'; +import { ApolloModule, APOLLO_OPTIONS } from 'apollo-angular'; import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import { ApolloClientOptions } from 'apollo-client'; import { ApolloLink } from 'apollo-link'; @@ -20,21 +20,15 @@ import { DataService } from './providers/data.service'; import { FetchAdapter } from './providers/fetch-adapter'; import { DefaultInterceptor } from './providers/interceptor'; import { initializeServerConfigService, ServerConfigService } from './server-config'; +import { getServerLocation } from './utils/get-server-location'; export function createApollo( localStorageService: LocalStorageService, fetchAdapter: FetchAdapter, injector: Injector, ): ApolloClientOptions { - const { apiHost, apiPort, adminApiPath, tokenMethod } = getAppConfig(); - const host = apiHost === 'auto' ? `${location.protocol}//${location.hostname}` : apiHost; - const port = apiPort - ? apiPort === 'auto' - ? location.port === '' - ? '' - : `:${location.port}` - : `:${apiPort}` - : ''; + const { adminApiPath, tokenMethod } = getAppConfig(); + const serverLocation = getServerLocation(); const apolloCache = new InMemoryCache({ fragmentMatcher: new IntrospectionFragmentMatcher({ introspectionQueryResultData: introspectionResult, @@ -76,7 +70,7 @@ export function createApollo( } }), createUploadLink({ - uri: `${host}${port}/${adminApiPath}`, + uri: `${serverLocation}/${adminApiPath}`, fetch: fetchAdapter.fetch, }), ]), diff --git a/packages/admin-ui/src/lib/core/src/data/providers/interceptor.ts b/packages/admin-ui/src/lib/core/src/data/providers/interceptor.ts index 074fa1cb39..6cb5263e7d 100644 --- a/packages/admin-ui/src/lib/core/src/data/providers/interceptor.ts +++ b/packages/admin-ui/src/lib/core/src/data/providers/interceptor.ts @@ -72,6 +72,8 @@ export class DefaultInterceptor implements HttpInterceptor { this.displayErrorNotification(_(`error.could-not-connect-to-server`), { url: `${apiHost}:${apiPort}`, }); + } else if (response.status === 503 && response.url?.endsWith('/health')) { + this.displayErrorNotification(_(`error.health-check-failed`)); } else { this.displayErrorNotification(this.extractErrorFromHttpResponse(response)); } diff --git a/packages/admin-ui/src/lib/core/src/data/utils/get-server-location.ts b/packages/admin-ui/src/lib/core/src/data/utils/get-server-location.ts new file mode 100644 index 0000000000..f7eeae4078 --- /dev/null +++ b/packages/admin-ui/src/lib/core/src/data/utils/get-server-location.ts @@ -0,0 +1,17 @@ +import { getAppConfig } from '../../app.config'; + +/** + * Returns the location of the server, e.g. "http://localhost:3000" + */ +export function getServerLocation(): string { + const { apiHost, apiPort, adminApiPath, tokenMethod } = getAppConfig(); + const host = apiHost === 'auto' ? `${location.protocol}//${location.hostname}` : apiHost; + const port = apiPort + ? apiPort === 'auto' + ? location.port === '' + ? '' + : `:${location.port}` + : `:${apiPort}` + : ''; + return `${host}${port}`; +} diff --git a/packages/admin-ui/src/lib/core/src/providers/health-check/health-check.service.ts b/packages/admin-ui/src/lib/core/src/providers/health-check/health-check.service.ts new file mode 100644 index 0000000000..4f09c62f5e --- /dev/null +++ b/packages/admin-ui/src/lib/core/src/providers/health-check/health-check.service.ts @@ -0,0 +1,67 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { getServerLocation } from '@vendure/admin-ui/core'; +import { merge, Observable, of, Subject, timer } from 'rxjs'; +import { catchError, map, shareReplay, switchMap, throttleTime } from 'rxjs/operators'; + +export type SystemStatus = 'ok' | 'error'; + +export interface HealthCheckResult { + status: SystemStatus; + info: { [name: string]: HealthCheckSuccessResult }; + details: { [name: string]: HealthCheckSuccessResult | HealthCheckErrorResult }; + error: { [name: string]: HealthCheckErrorResult }; +} + +export interface HealthCheckSuccessResult { + status: 'up'; +} + +export interface HealthCheckErrorResult { + status: 'down'; + message: string; +} + +@Injectable({ + providedIn: 'root', +}) +export class HealthCheckService { + status$: Observable; + details$: Observable>; + lastCheck$: Observable; + + private readonly pollingDelayMs = 60 * 1000; + private readonly healthCheckEndpoint: string; + private readonly _refresh = new Subject(); + + constructor(private httpClient: HttpClient) { + this.healthCheckEndpoint = getServerLocation() + '/health'; + + const refresh$ = this._refresh.pipe(throttleTime(1000)); + const result$ = merge(timer(0, this.pollingDelayMs), refresh$).pipe( + switchMap(() => this.checkHealth()), + shareReplay(1), + ); + + this.status$ = result$.pipe(map(res => res.status)); + this.details$ = result$.pipe( + map(res => + Object.keys(res.details).map(key => { + return { key, result: res.details[key] }; + }), + ), + ); + this.lastCheck$ = result$.pipe(map(res => res.lastChecked)); + } + + refresh() { + this._refresh.next(); + } + + private checkHealth() { + return this.httpClient.get(this.healthCheckEndpoint).pipe( + catchError(err => of(err.error)), + map(res => ({ ...res, lastChecked: new Date() })), + ); + } +} diff --git a/packages/admin-ui/src/lib/core/src/public_api.ts b/packages/admin-ui/src/lib/core/src/public_api.ts index 6e4a5f043b..0b4403f4f2 100644 --- a/packages/admin-ui/src/lib/core/src/public_api.ts +++ b/packages/admin-ui/src/lib/core/src/public_api.ts @@ -63,10 +63,12 @@ export * from './data/providers/shipping-method-data.service'; export * from './data/query-result'; export * from './data/server-config'; export * from './data/utils/add-custom-fields'; +export * from './data/utils/get-server-location'; export * from './data/utils/remove-readonly-custom-fields'; export * from './providers/auth/auth.service'; export * from './providers/custom-field-component/custom-field-component.service'; export * from './providers/guard/auth.guard'; +export * from './providers/health-check/health-check.service'; export * from './providers/i18n/custom-http-loader'; export * from './providers/i18n/custom-message-format-compiler'; export * from './providers/i18n/i18n.service'; diff --git a/packages/admin-ui/src/lib/settings/src/components/health-check/health-check.component.html b/packages/admin-ui/src/lib/settings/src/components/health-check/health-check.component.html new file mode 100644 index 0000000000..0a57199801 --- /dev/null +++ b/packages/admin-ui/src/lib/settings/src/components/health-check/health-check.component.html @@ -0,0 +1,65 @@ + + +
+
+ +
+
+ + {{ 'system.health-all-systems-up' | translate }} + + + {{ 'system.health-error' | translate }} + +
+ {{ 'system.health-last-checked' | translate }}: + {{ healthCheckService.lastCheck$ | async | date: 'mediumTime' }} +
+
+
+
+ + + + +
+ + + + + + + + + + + + + + + + +
+ {{ 'common.name' | translate }} + + {{ 'system.health-status' | translate }} + + {{ 'system.health-message' | translate }} +
{{ row.key }} + + + + {{ 'system.health-status-up' | translate }} + + + + {{ 'system.health-status-down' | translate }} + + + {{ row.result.message }}
diff --git a/packages/admin-ui/src/lib/settings/src/components/health-check/health-check.component.scss b/packages/admin-ui/src/lib/settings/src/components/health-check/health-check.component.scss new file mode 100644 index 0000000000..2463eeb92f --- /dev/null +++ b/packages/admin-ui/src/lib/settings/src/components/health-check/health-check.component.scss @@ -0,0 +1,15 @@ +@import "variables"; + +.system-status-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + + .status-detail { + font-weight: bold; + } + .last-checked { + font-weight: normal; + color: $color-grey-500; + } +} diff --git a/packages/admin-ui/src/lib/settings/src/components/health-check/health-check.component.ts b/packages/admin-ui/src/lib/settings/src/components/health-check/health-check.component.ts new file mode 100644 index 0000000000..ff53a521d2 --- /dev/null +++ b/packages/admin-ui/src/lib/settings/src/components/health-check/health-check.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { HealthCheckService } from '@vendure/admin-ui/core'; + +@Component({ + selector: 'vdr-health-check', + templateUrl: './health-check.component.html', + styleUrls: ['./health-check.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class HealthCheckComponent { + constructor(public healthCheckService: HealthCheckService) {} +} diff --git a/packages/admin-ui/src/lib/settings/src/public_api.ts b/packages/admin-ui/src/lib/settings/src/public_api.ts index c3c93f335c..de0d308292 100644 --- a/packages/admin-ui/src/lib/settings/src/public_api.ts +++ b/packages/admin-ui/src/lib/settings/src/public_api.ts @@ -7,6 +7,7 @@ export * from './components/channel-list/channel-list.component'; export * from './components/country-detail/country-detail.component'; export * from './components/country-list/country-list.component'; export * from './components/global-settings/global-settings.component'; +export * from './components/health-check/health-check.component'; export * from './components/job-list/job-list.component'; export * from './components/job-state-label/job-state-label.component'; export * from './components/payment-method-detail/payment-method-detail.component'; diff --git a/packages/admin-ui/src/lib/settings/src/settings.module.ts b/packages/admin-ui/src/lib/settings/src/settings.module.ts index 959d85596a..f9be52343a 100644 --- a/packages/admin-ui/src/lib/settings/src/settings.module.ts +++ b/packages/admin-ui/src/lib/settings/src/settings.module.ts @@ -10,6 +10,7 @@ import { ChannelListComponent } from './components/channel-list/channel-list.com import { CountryDetailComponent } from './components/country-detail/country-detail.component'; import { CountryListComponent } from './components/country-list/country-list.component'; import { GlobalSettingsComponent } from './components/global-settings/global-settings.component'; +import { HealthCheckComponent } from './components/health-check/health-check.component'; import { JobListComponent } from './components/job-list/job-list.component'; import { JobStateLabelComponent } from './components/job-state-label/job-state-label.component'; import { PaymentMethodDetailComponent } from './components/payment-method-detail/payment-method-detail.component'; @@ -67,6 +68,7 @@ import { settingsRoutes } from './settings.routes'; ZoneMemberListHeaderDirective, ZoneMemberControlsDirective, ZoneDetailDialogComponent, + HealthCheckComponent, ], }) export class SettingsModule {} diff --git a/packages/admin-ui/src/lib/settings/src/settings.routes.ts b/packages/admin-ui/src/lib/settings/src/settings.routes.ts index 8c358d88cf..8c29c1dfbf 100644 --- a/packages/admin-ui/src/lib/settings/src/settings.routes.ts +++ b/packages/admin-ui/src/lib/settings/src/settings.routes.ts @@ -20,6 +20,7 @@ import { ChannelListComponent } from './components/channel-list/channel-list.com import { CountryDetailComponent } from './components/country-detail/country-detail.component'; import { CountryListComponent } from './components/country-list/country-list.component'; import { GlobalSettingsComponent } from './components/global-settings/global-settings.component'; +import { HealthCheckComponent } from './components/health-check/health-check.component'; import { JobListComponent } from './components/job-list/job-list.component'; import { PaymentMethodDetailComponent } from './components/payment-method-detail/payment-method-detail.component'; import { PaymentMethodListComponent } from './components/payment-method-list/payment-method-list.component'; @@ -188,6 +189,13 @@ export const settingsRoutes: Route[] = [ breadcrumb: _('breadcrumb.job-queue'), }, }, + { + path: 'system-status', + component: HealthCheckComponent, + data: { + breadcrumb: _('breadcrumb.system-status'), + }, + }, ]; export function administratorBreadcrumb(data: any, params: any) { diff --git a/packages/admin-ui/src/lib/static/i18n-messages/en.json b/packages/admin-ui/src/lib/static/i18n-messages/en.json index d14f3bbe03..710e9bdf96 100644 --- a/packages/admin-ui/src/lib/static/i18n-messages/en.json +++ b/packages/admin-ui/src/lib/static/i18n-messages/en.json @@ -41,6 +41,7 @@ "promotions": "Promotions", "roles": "Roles", "shipping-methods": "Shipping methods", + "system-status": "System status", "tax-categories": "Tax categories", "tax-rates": "Tax rates", "zones": "Zones" @@ -266,6 +267,7 @@ "403-forbidden": "You are not currently authorized to access \"{ path }\". Either you lack permissions, or your session has expired.", "could-not-connect-to-server": "Could not connect to the Vendure server at { url }", "facet-value-form-values-do-not-match": "The number of values in the facet form does not match the actual number of values", + "health-check-failed": "System health check failed", "no-default-shipping-zone-set": "This channel has no default shipping zone. This may cause errors when calculating order shipping charges.", "no-default-tax-zone-set": "This channel has no default tax zone, which will cause errors when calculating prices. Please create or select a zone.", "product-variant-form-values-do-not-match": "The number of variants in the product form does not match the actual number of variants" @@ -487,6 +489,8 @@ "sales": "Sales", "settings": "Settings", "shipping-methods": "Shipping methods", + "system": "System", + "system-status": "System Status", "tax-categories": "Tax categories", "tax-rates": "Tax Rates", "zones": "Zones" @@ -647,6 +651,14 @@ }, "system": { "all-job-queues": "All job queues", + "health-all-systems-up": "All systems up", + "health-error": "Error: one or more systems are down!", + "health-last-checked": "Last checked", + "health-message": "Message", + "health-refresh": "Refresh", + "health-status": "Status", + "health-status-down": "Down", + "health-status-up": "Up", "hide-settled-jobs": "Hide settled jobs", "job-data": "Job data", "job-duration": "Duration",