Browse Source

feat(admin-ui): Add system health status page

Relates to #289
Michael Bromley 5 years ago
parent
commit
b3411f2f6a

+ 8 - 0
packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html

@@ -26,6 +26,14 @@
         </ng-container>
         </ng-container>
         <section class="nav-group">
         <section class="nav-group">
             <vdr-job-link></vdr-job-link>
             <vdr-job-link></vdr-job-link>
+            <a
+                type="button"
+                class="btn btn-link btn-sm job-button"
+                [routerLink]="['/settings/system-status']"
+            >
+                {{ 'nav.system-status' | translate }}
+            </a>
+
         </section>
         </section>
     </section>
     </section>
 </nav>
 </nav>

+ 3 - 3
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 { BrowserModule } from '@angular/platform-browser';
 import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
 import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
 import { TranslateCompiler, TranslateLoader, TranslateModule } from '@ngx-translate/core';
 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 { getAppConfig } from './app.config';
 import { getDefaultUiLanguage } from './common/utilities/get-default-ui-language';
 import { getDefaultUiLanguage } from './common/utilities/get-default-ui-language';
@@ -66,7 +66,7 @@ export class CoreModule {
         if (!availableLanguages.includes(defaultLanguage)) {
         if (!availableLanguages.includes(defaultLanguage)) {
             throw new Error(
             throw new Error(
                 `The defaultLanguage "${defaultLanguage}" must be one of the availableLanguages [${availableLanguages
                 `The defaultLanguage "${defaultLanguage}" must be one of the availableLanguages [${availableLanguages
-                    .map((l) => `"${l}"`)
+                    .map(l => `"${l}"`)
                     .join(', ')}]`,
                     .join(', ')}]`,
             );
             );
         }
         }
@@ -95,7 +95,7 @@ export function HttpLoaderFactory(http: HttpClient, location: PlatformLocation)
 export function getLocales(): MessageFormatConfig {
 export function getLocales(): MessageFormatConfig {
     const locales = getAppConfig().availableLanguages;
     const locales = getAppConfig().availableLanguages;
     const defaultLanguage = getDefaultUiLanguage();
     const defaultLanguage = getDefaultUiLanguage();
-    const localesWithoutDefault = locales.filter((l) => l !== defaultLanguage);
+    const localesWithoutDefault = locales.filter(l => l !== defaultLanguage);
     return {
     return {
         locales: [defaultLanguage, ...localesWithoutDefault],
         locales: [defaultLanguage, ...localesWithoutDefault],
     };
     };

+ 6 - 12
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 { 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 { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
 import { ApolloClientOptions } from 'apollo-client';
 import { ApolloClientOptions } from 'apollo-client';
 import { ApolloLink } from 'apollo-link';
 import { ApolloLink } from 'apollo-link';
@@ -20,21 +20,15 @@ import { DataService } from './providers/data.service';
 import { FetchAdapter } from './providers/fetch-adapter';
 import { FetchAdapter } from './providers/fetch-adapter';
 import { DefaultInterceptor } from './providers/interceptor';
 import { DefaultInterceptor } from './providers/interceptor';
 import { initializeServerConfigService, ServerConfigService } from './server-config';
 import { initializeServerConfigService, ServerConfigService } from './server-config';
+import { getServerLocation } from './utils/get-server-location';
 
 
 export function createApollo(
 export function createApollo(
     localStorageService: LocalStorageService,
     localStorageService: LocalStorageService,
     fetchAdapter: FetchAdapter,
     fetchAdapter: FetchAdapter,
     injector: Injector,
     injector: Injector,
 ): ApolloClientOptions<any> {
 ): ApolloClientOptions<any> {
-    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({
     const apolloCache = new InMemoryCache({
         fragmentMatcher: new IntrospectionFragmentMatcher({
         fragmentMatcher: new IntrospectionFragmentMatcher({
             introspectionQueryResultData: introspectionResult,
             introspectionQueryResultData: introspectionResult,
@@ -76,7 +70,7 @@ export function createApollo(
                 }
                 }
             }),
             }),
             createUploadLink({
             createUploadLink({
-                uri: `${host}${port}/${adminApiPath}`,
+                uri: `${serverLocation}/${adminApiPath}`,
                 fetch: fetchAdapter.fetch,
                 fetch: fetchAdapter.fetch,
             }),
             }),
         ]),
         ]),

+ 2 - 0
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`), {
                 this.displayErrorNotification(_(`error.could-not-connect-to-server`), {
                     url: `${apiHost}:${apiPort}`,
                     url: `${apiHost}:${apiPort}`,
                 });
                 });
+            } else if (response.status === 503 && response.url?.endsWith('/health')) {
+                this.displayErrorNotification(_(`error.health-check-failed`));
             } else {
             } else {
                 this.displayErrorNotification(this.extractErrorFromHttpResponse(response));
                 this.displayErrorNotification(this.extractErrorFromHttpResponse(response));
             }
             }

+ 17 - 0
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}`;
+}

+ 67 - 0
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<SystemStatus>;
+    details$: Observable<Array<{ key: string; result: HealthCheckSuccessResult | HealthCheckErrorResult }>>;
+    lastCheck$: Observable<Date>;
+
+    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<HealthCheckResult>(this.healthCheckEndpoint).pipe(
+            catchError(err => of(err.error)),
+            map(res => ({ ...res, lastChecked: new Date() })),
+        );
+    }
+}

+ 2 - 0
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/query-result';
 export * from './data/server-config';
 export * from './data/server-config';
 export * from './data/utils/add-custom-fields';
 export * from './data/utils/add-custom-fields';
+export * from './data/utils/get-server-location';
 export * from './data/utils/remove-readonly-custom-fields';
 export * from './data/utils/remove-readonly-custom-fields';
 export * from './providers/auth/auth.service';
 export * from './providers/auth/auth.service';
 export * from './providers/custom-field-component/custom-field-component.service';
 export * from './providers/custom-field-component/custom-field-component.service';
 export * from './providers/guard/auth.guard';
 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-http-loader';
 export * from './providers/i18n/custom-message-format-compiler';
 export * from './providers/i18n/custom-message-format-compiler';
 export * from './providers/i18n/i18n.service';
 export * from './providers/i18n/i18n.service';

+ 65 - 0
packages/admin-ui/src/lib/settings/src/components/health-check/health-check.component.html

@@ -0,0 +1,65 @@
+<vdr-action-bar>
+    <vdr-ab-left>
+        <div class="system-status-header" *ngIf="healthCheckService.status$ | async as status">
+            <div class="status-icon">
+                <clr-icon
+                    [attr.shape]="status === 'ok' ? 'check-circle' : 'exclamation-circle'"
+                    [ngClass]="{ 'is-success': status === 'ok', 'is-danger': status !== 'ok' }"
+                    size="48"
+                ></clr-icon>
+            </div>
+            <div class="status-detail">
+                <ng-container *ngIf="status === 'ok'; else error">
+                    {{ 'system.health-all-systems-up' | translate }}
+                </ng-container>
+                <ng-template #error>
+                    {{ 'system.health-error' | translate }}
+                </ng-template>
+                <div class="last-checked">
+                    {{ 'system.health-last-checked' | translate }}:
+                    {{ healthCheckService.lastCheck$ | async | date: 'mediumTime' }}
+                </div>
+            </div>
+        </div>
+    </vdr-ab-left>
+    <vdr-ab-right>
+        <vdr-action-bar-items locationId="system-status"></vdr-action-bar-items>
+        <button class="btn btn-secondary" (click)="healthCheckService.refresh()">
+            <clr-icon shape="refresh"></clr-icon> {{ 'system.health-refresh' | translate }}
+        </button>
+    </vdr-ab-right>
+</vdr-action-bar>
+
+<table class="table">
+    <thead>
+        <tr>
+            <th class="left">
+                {{ 'common.name' | translate }}
+            </th>
+            <th class="left">
+                {{ 'system.health-status' | translate }}
+            </th>
+            <th class="left">
+                {{ 'system.health-message' | translate }}
+            </th>
+        </tr>
+    </thead>
+    <tbody>
+        <tr *ngFor="let row of healthCheckService.details$ | async">
+            <td class="align-middle left">{{ row.key }}</td>
+            <td class="align-middle left">
+                <vdr-chip [colorType]="row.result.status === 'up' ? 'success' : 'error'">
+                    <ng-container *ngIf="row.result.status === 'up'; else down">
+                        <clr-icon shape="check-circle"></clr-icon>
+                        {{ 'system.health-status-up' | translate }}
+                    </ng-container>
+                    <ng-template #down>
+                        <clr-icon shape="exclamation-circle"></clr-icon>
+                        {{ 'system.health-status-down' | translate }}
+                    </ng-template>
+                </vdr-chip>
+            </td>
+            <td class="align-middle left">{{ row.result.message }}</td>
+        </tr>
+    </tbody>
+</table>

+ 15 - 0
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;
+    }
+}

+ 12 - 0
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) {}
+}

+ 1 - 0
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-detail/country-detail.component';
 export * from './components/country-list/country-list.component';
 export * from './components/country-list/country-list.component';
 export * from './components/global-settings/global-settings.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-list/job-list.component';
 export * from './components/job-state-label/job-state-label.component';
 export * from './components/job-state-label/job-state-label.component';
 export * from './components/payment-method-detail/payment-method-detail.component';
 export * from './components/payment-method-detail/payment-method-detail.component';

+ 2 - 0
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 { CountryDetailComponent } from './components/country-detail/country-detail.component';
 import { CountryListComponent } from './components/country-list/country-list.component';
 import { CountryListComponent } from './components/country-list/country-list.component';
 import { GlobalSettingsComponent } from './components/global-settings/global-settings.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 { JobListComponent } from './components/job-list/job-list.component';
 import { JobStateLabelComponent } from './components/job-state-label/job-state-label.component';
 import { JobStateLabelComponent } from './components/job-state-label/job-state-label.component';
 import { PaymentMethodDetailComponent } from './components/payment-method-detail/payment-method-detail.component';
 import { PaymentMethodDetailComponent } from './components/payment-method-detail/payment-method-detail.component';
@@ -67,6 +68,7 @@ import { settingsRoutes } from './settings.routes';
         ZoneMemberListHeaderDirective,
         ZoneMemberListHeaderDirective,
         ZoneMemberControlsDirective,
         ZoneMemberControlsDirective,
         ZoneDetailDialogComponent,
         ZoneDetailDialogComponent,
+        HealthCheckComponent,
     ],
     ],
 })
 })
 export class SettingsModule {}
 export class SettingsModule {}

+ 8 - 0
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 { CountryDetailComponent } from './components/country-detail/country-detail.component';
 import { CountryListComponent } from './components/country-list/country-list.component';
 import { CountryListComponent } from './components/country-list/country-list.component';
 import { GlobalSettingsComponent } from './components/global-settings/global-settings.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 { JobListComponent } from './components/job-list/job-list.component';
 import { PaymentMethodDetailComponent } from './components/payment-method-detail/payment-method-detail.component';
 import { PaymentMethodDetailComponent } from './components/payment-method-detail/payment-method-detail.component';
 import { PaymentMethodListComponent } from './components/payment-method-list/payment-method-list.component';
 import { PaymentMethodListComponent } from './components/payment-method-list/payment-method-list.component';
@@ -188,6 +189,13 @@ export const settingsRoutes: Route[] = [
             breadcrumb: _('breadcrumb.job-queue'),
             breadcrumb: _('breadcrumb.job-queue'),
         },
         },
     },
     },
+    {
+        path: 'system-status',
+        component: HealthCheckComponent,
+        data: {
+            breadcrumb: _('breadcrumb.system-status'),
+        },
+    },
 ];
 ];
 
 
 export function administratorBreadcrumb(data: any, params: any) {
 export function administratorBreadcrumb(data: any, params: any) {

+ 12 - 0
packages/admin-ui/src/lib/static/i18n-messages/en.json

@@ -41,6 +41,7 @@
     "promotions": "Promotions",
     "promotions": "Promotions",
     "roles": "Roles",
     "roles": "Roles",
     "shipping-methods": "Shipping methods",
     "shipping-methods": "Shipping methods",
+    "system-status": "System status",
     "tax-categories": "Tax categories",
     "tax-categories": "Tax categories",
     "tax-rates": "Tax rates",
     "tax-rates": "Tax rates",
     "zones": "Zones"
     "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.",
     "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 }",
     "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",
     "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-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.",
     "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"
     "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",
     "sales": "Sales",
     "settings": "Settings",
     "settings": "Settings",
     "shipping-methods": "Shipping methods",
     "shipping-methods": "Shipping methods",
+    "system": "System",
+    "system-status": "System Status",
     "tax-categories": "Tax categories",
     "tax-categories": "Tax categories",
     "tax-rates": "Tax Rates",
     "tax-rates": "Tax Rates",
     "zones": "Zones"
     "zones": "Zones"
@@ -647,6 +651,14 @@
   },
   },
   "system": {
   "system": {
     "all-job-queues": "All job queues",
     "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",
     "hide-settled-jobs": "Hide settled jobs",
     "job-data": "Job data",
     "job-data": "Job data",
     "job-duration": "Duration",
     "job-duration": "Duration",