Skip to content

Commit

Permalink
feat(admin-ui): Add system health status page
Browse files Browse the repository at this point in the history
Relates to #289
  • Loading branch information
michaelbromley committed May 5, 2020
1 parent e2c6a00 commit b3411f2
Show file tree
Hide file tree
Showing 14 changed files with 220 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@
</ng-container>
<section class="nav-group">
<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>
</nav>
6 changes: 3 additions & 3 deletions packages/admin-ui/src/lib/core/src/core.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(', ')}]`,
);
}
Expand Down Expand Up @@ -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],
};
Expand Down
18 changes: 6 additions & 12 deletions packages/admin-ui/src/lib/core/src/data/data.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<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({
fragmentMatcher: new IntrospectionFragmentMatcher({
introspectionQueryResultData: introspectionResult,
Expand Down Expand Up @@ -76,7 +70,7 @@ export function createApollo(
}
}),
createUploadLink({
uri: `${host}${port}/${adminApiPath}`,
uri: `${serverLocation}/${adminApiPath}`,
fetch: fetchAdapter.fetch,
}),
]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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}`;
}
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions packages/admin-ui/src/lib/core/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions packages/admin-ui/src/lib/settings/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions packages/admin-ui/src/lib/settings/src/settings.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,6 +68,7 @@ import { settingsRoutes } from './settings.routes';
ZoneMemberListHeaderDirective,
ZoneMemberControlsDirective,
ZoneDetailDialogComponent,
HealthCheckComponent,
],
})
export class SettingsModule {}
8 changes: 8 additions & 0 deletions packages/admin-ui/src/lib/settings/src/settings.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions packages/admin-ui/src/lib/static/i18n-messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down

0 comments on commit b3411f2

Please sign in to comment.