Skip to content

Commit

Permalink
GH-1155: Added connection health contribution to the status bar.
Browse files Browse the repository at this point in the history
Signed-off-by: Akos Kitta <[email protected]>
  • Loading branch information
kittaakos committed Feb 1, 2018
1 parent 394cb46 commit 89d45f6
Show file tree
Hide file tree
Showing 3 changed files with 140 additions and 37 deletions.
4 changes: 3 additions & 1 deletion packages/core/src/browser/frontend-application-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
161 changes: 127 additions & 34 deletions packages/core/src/browser/frontend-connection-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ConnectionStatusContribution>,
@inject(MessageService) protected readonly messageService: MessageService,
@inject(ILogger) protected readonly logger: ILogger
) { }
@inject(ContributionProvider) @named(ConnectionStatusContribution) protected readonly contributionProvider: ContributionProvider<ConnectionStatusContribution>
) {
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() {
Expand All @@ -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
(<any>this.dialog).accept();
}
break;
}
}
}
this.dialog!.open();
}

reconnected() {
if (this.dialog !== undefined) {
// tslint:disable-next-line:no-any
(<any>this.dialog).accept();
private getOrCreateDialog() {
if (this.dialog === undefined) {
this.dialog = new ConnectionStatusDialog();
}
return this.dialog;
}

}

class ConnectionStatusDialog extends AbstractDialog<void> {
Expand All @@ -124,6 +215,8 @@ class ConnectionStatusDialog extends AbstractDialog<void> {
}

protected onAfterAttach() {
// NOOP.
// We need disable the key listener for escape and return so that the dialog cannot be closed by the user.
}

}
12 changes: 10 additions & 2 deletions packages/core/src/common/connection-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

0 comments on commit 89d45f6

Please sign in to comment.