From 89d45f6c70b0f3d51ff1117bf0507cec95a7132b Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Thu, 1 Feb 2018 15:35:35 +0100 Subject: [PATCH] GH-1155: Added connection health contribution to the status bar. Signed-off-by: Akos Kitta --- .../browser/frontend-application-module.ts | 4 +- .../src/browser/frontend-connection-status.ts | 161 ++++++++++++++---- packages/core/src/common/connection-status.ts | 12 +- 3 files changed, 140 insertions(+), 37 deletions(-) diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 6c5363f7693db..51c9d51f3a759 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -33,7 +33,7 @@ import { StatusBar, StatusBarImpl } from "./status-bar/status-bar"; import { LabelParser } from './label-parser'; import { LabelProvider, LabelProviderContribution, DefaultUriLabelProviderContribution } from "./label-provider"; import { ThemingCommandContribution, ThemeService } from './theming'; -import { FrontendConnectionStatusService, ApplicationConnectionStatusContribution } from './frontend-connection-status'; +import { FrontendConnectionStatusService, ApplicationConnectionStatusContribution, ConnectionStatusStatusBarContribution } from './frontend-connection-status'; import { ConnectionStatusContribution } from '../common/connection-status'; import '../../src/browser/style/index.css'; @@ -112,10 +112,12 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo bind(CommandContribution).to(ThemingCommandContribution).inSingletonScope(); bind(ApplicationConnectionStatusContribution).toSelf().inSingletonScope(); + bind(ConnectionStatusStatusBarContribution).toSelf().inSingletonScope(); bindContributionProvider(bind, ConnectionStatusContribution); bind(FrontendConnectionStatusService).toSelf().inRequestScope(); bind(FrontendApplicationContribution).toDynamicValue(ctx => ctx.container.get(FrontendConnectionStatusService)); bind(ConnectionStatusContribution).toDynamicValue(ctx => ctx.container.get(ApplicationConnectionStatusContribution)).inSingletonScope(); + bind(ConnectionStatusContribution).toDynamicValue(ctx => ctx.container.get(ConnectionStatusStatusBarContribution)).inSingletonScope(); }); const theme = ThemeService.get().getCurrentTheme().id; diff --git a/packages/core/src/browser/frontend-connection-status.ts b/packages/core/src/browser/frontend-connection-status.ts index a74a5a71475f3..6e5ab36f07dd7 100644 --- a/packages/core/src/browser/frontend-connection-status.ts +++ b/packages/core/src/browser/frontend-connection-status.ts @@ -10,8 +10,10 @@ import { ILogger } from '../common/logger'; import { AbstractDialog } from './dialogs'; import { MessageService } from '../common/message-service'; import { ContributionProvider } from '../common/contribution-provider'; -import { ConnectionStatusContribution } from '../common/connection-status'; +import { ConnectionState, ConnectionStatusChangeEvent, ConnectionStatusContribution } from '../common/connection-status'; import { FrontendApplicationContribution } from './frontend-application'; +import { FrontendApplication } from '@theia/core/lib/browser'; +import { StatusBar, StatusBarAlignment } from '@theia/core/lib/browser/status-bar/status-bar'; @injectable() export class ConnectionStatusOptions { @@ -42,45 +44,37 @@ export class ConnectionStatusOptions { @injectable() export class FrontendConnectionStatusService implements FrontendApplicationContribution { - private failedAttempts = 0; + private connectionState: ConnectionStateMachine; // tslint:disable-next-line:no-any private timer: any | undefined; constructor( @inject(ConnectionStatusOptions) @optional() protected readonly options: ConnectionStatusOptions = ConnectionStatusOptions.DEFAULT, - @inject(ContributionProvider) @named(ConnectionStatusContribution) protected readonly contributionProvider: ContributionProvider, - @inject(MessageService) protected readonly messageService: MessageService, - @inject(ILogger) protected readonly logger: ILogger - ) { } + @inject(ContributionProvider) @named(ConnectionStatusContribution) protected readonly contributionProvider: ContributionProvider + ) { + this.connectionState = new ConnectionStateMachine({ threshold: this.options.retry }); + } onStart() { this.timer = setInterval(() => { const xhr = new XMLHttpRequest(); xhr.timeout = this.options.requestTimeout; - xhr.open('GET', `${window.location.href}alive`); xhr.onreadystatechange = event => { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status === 200) { - if (this.failedAttempts >= this.options.retry) { - const message = 'Connection to the backend was re-established.'; - this.logger.info(message); - this.messageService.info(message); - this.contributionProvider.getContributions().forEach(contribution => contribution.reconnected()); - this.failedAttempts = 0; - } - } else { - this.failedAttempts++; - if (this.failedAttempts === this.options.retry) { - const message = 'Connection to the backend was lost.'; - this.logger.error(message); - this.messageService.error(message); - this.contributionProvider.getContributions().forEach(contribution => contribution.connectionLost()); - } - } + const { readyState, status } = xhr; + if (readyState === XMLHttpRequest.DONE) { + const success = status === 200; + this.connectionState = this.connectionState.next(success); + this.contributionProvider.getContributions().forEach(contribution => contribution.onStatusChange(this.connectionState)); } }; - try { xhr.send(); } catch { } + xhr.onerror = () => { + this.connectionState = this.connectionState.next(false); + this.contributionProvider.getContributions().forEach(contribution => contribution.onStatusChange(this.connectionState)); + }; + xhr.open('GET', `${window.location.href}alive`); + try { xhr.send(); } catch { /* NOOP */ } }, this.options.retryInterval); + this.contributionProvider.getContributions().forEach(contribution => contribution.onStatusChange(this.connectionState)); } onStop() { @@ -92,24 +86,121 @@ export class FrontendConnectionStatusService implements FrontendApplicationContr } +@injectable() +export class ConnectionStatusStatusBarContribution implements ConnectionStatusContribution { + + constructor( @inject(StatusBar) protected statusBar: StatusBar) { + } + + onStatusChange(event: ConnectionStatusChangeEvent) { + this.statusBar.removeElement('connection-status'); + const text = `$(${this.getStatusIcon(event.health)})`; + const tooltip = event.health ? `Connection health: ${event.health}%` : 'No connection'; + this.statusBar.setElement('connection-status', { + alignment: StatusBarAlignment.RIGHT, + text, + priority: 0, + tooltip + }); + } + + private getStatusIcon(health: number | undefined) { + if (health === undefined || health === 0) { + return 'exclamation-circle'; + } + if (health < 25) { + return 'frown-o'; + } + if (health < 50) { + return 'meh-o'; + } + return 'smile-o'; + } + +} + +export class ConnectionStateMachine implements ConnectionStatusChangeEvent { + + static readonly MAX_HISTORY = 100; + + public readonly health: number; + + constructor( + private readonly props: { readonly threshold: number }, + public readonly state: ConnectionState = ConnectionState.CONNECTED, + private readonly history: boolean[] = []) { + + if (this.state === ConnectionState.CONNECTION_LOST) { + this.health = 0; + } else { + this.health = this.history.length === 0 ? 100 : Math.round((this.history.filter(success => success).length / this.history.length) * 100); + } + } + + next(success: boolean): ConnectionStateMachine { + const newHistory = this.updateHistory(success); + // Initial optimism. + let hasConnection = true; + if (newHistory.length > this.props.threshold) { + hasConnection = newHistory.slice(-this.props.threshold).some(s => s); + } + // Ideally, we do not switch back to online if we see any `true` items but, let's say, after three consecutive `true`s. + return new ConnectionStateMachine(this.props, hasConnection ? ConnectionState.CONNECTED : ConnectionState.CONNECTION_LOST, newHistory); + } + + private updateHistory(success: boolean) { + const updated = [...this.history, success]; + if (updated.length > ConnectionStateMachine.MAX_HISTORY) { + updated.shift(); + } + return updated; + } + +} + @injectable() export class ApplicationConnectionStatusContribution implements ConnectionStatusContribution { private dialog: ConnectionStatusDialog | undefined; + private state = ConnectionState.CONNECTED; - connectionLost() { - if (this.dialog === undefined) { - this.dialog = new ConnectionStatusDialog(); + constructor( + @inject(MessageService) protected readonly messageService: MessageService, + @inject(ILogger) protected readonly logger: ILogger + ) { } + + onStatusChange(event: ConnectionStatusChangeEvent): void { + if (this.state !== event.state) { + this.state = event.state; + switch (event.state) { + case ConnectionState.CONNECTION_LOST: { + const message = 'Connection to the backend was lost.'; + this.logger.error(message); + this.messageService.error(message); + this.getOrCreateDialog().open(); + break; + } + case ConnectionState.CONNECTED: { + const message = 'Connection to the backend was re-established.'; + this.logger.info(message); + this.messageService.info(message); + if (this.dialog !== undefined) { + // tslint:disable-next-line:no-any + (this.dialog).accept(); + } + break; + } + } } - this.dialog!.open(); } - reconnected() { - if (this.dialog !== undefined) { - // tslint:disable-next-line:no-any - (this.dialog).accept(); + private getOrCreateDialog() { + if (this.dialog === undefined) { + this.dialog = new ConnectionStatusDialog(); } + return this.dialog; } + } class ConnectionStatusDialog extends AbstractDialog { @@ -124,6 +215,8 @@ class ConnectionStatusDialog extends AbstractDialog { } protected onAfterAttach() { + // NOOP. + // We need disable the key listener for escape and return so that the dialog cannot be closed by the user. } } diff --git a/packages/core/src/common/connection-status.ts b/packages/core/src/common/connection-status.ts index 30e4e69613f64..78faa0b2bb63d 100644 --- a/packages/core/src/common/connection-status.ts +++ b/packages/core/src/common/connection-status.ts @@ -8,8 +8,16 @@ export const ConnectionStatusContribution = Symbol('ConnectionStatusContribution'); export interface ConnectionStatusContribution { - connectionLost(): void; + onStatusChange(event: ConnectionStatusChangeEvent): void; - reconnected(): void; +} + +export interface ConnectionStatusChangeEvent { + readonly state: ConnectionState, + readonly health?: number +} +export enum ConnectionState { + CONNECTED, + CONNECTION_LOST }