From 78eddc973aa7030d95fa8fadce6451965575d5fc Mon Sep 17 00:00:00 2001 From: Sam David Date: Fri, 25 Oct 2024 23:19:54 +0530 Subject: [PATCH] add websocket health check implementation & test --- app/components/system-status/index.hbs | 2 +- app/components/system-status/index.ts | 112 +++++++++++++- app/routes/authenticated.ts | 27 ++-- app/services/{websocket.js => websocket.ts} | 144 +++++++++++++----- .../deprecated-routes/status-redirect-test.js | 8 + .../components/system-status-test.js | 120 +++++++++++++-- translations/en.json | 1 + translations/ja.json | 1 + types/irene/index.d.ts | 5 + 9 files changed, 349 insertions(+), 71 deletions(-) rename app/services/{websocket.js => websocket.ts} (63%) diff --git a/app/components/system-status/index.hbs b/app/components/system-status/index.hbs index dcc9d969a..409c4827a 100644 --- a/app/components/system-status/index.hbs +++ b/app/components/system-status/index.hbs @@ -31,7 +31,7 @@ - + {{#if r.columnValue.component}} {{#let (component r.columnValue.component) as |Component|}} diff --git a/app/components/system-status/index.ts b/app/components/system-status/index.ts index ce91498e1..901d4be0b 100644 --- a/app/components/system-status/index.ts +++ b/app/components/system-status/index.ts @@ -1,20 +1,36 @@ import Component from '@glimmer/component'; -import { task } from 'ember-concurrency'; -import { isNotFoundError, AjaxError } from 'ember-ajax/errors'; -import ENV from 'irene/config/environment'; +import { task, timeout } from 'ember-concurrency'; +import { isNotFoundError, type AjaxError } from 'ember-ajax/errors'; import { inject as service } from '@ember/service'; -import DevicefarmService from 'irene/services/devicefarm'; import { tracked } from '@glimmer/tracking'; -import IntlService from 'ember-intl/services/intl'; +import { next } from '@ember/runloop'; +import { waitForPromise } from '@ember/test-waiters'; +import type Store from '@ember-data/store'; +import type IntlService from 'ember-intl/services/intl'; + +import ENV from 'irene/config/environment'; +import type DevicefarmService from 'irene/services/devicefarm'; +import type WebsocketService from 'irene/services/websocket'; + +import type { + SocketHealthMessage, + SocketInstance, +} from 'irene/services/websocket'; export default class SystemStatusComponent extends Component { @service declare devicefarm: DevicefarmService; + @service declare websocket: WebsocketService; @service declare ajax: any; + @service declare session: any; + @service declare store: Store; @service declare intl: IntlService; @tracked isStorageWorking = false; @tracked isDeviceFarmWorking = false; @tracked isAPIServerWorking = false; + @tracked isWebsocketWorking = false; + + socket?: SocketInstance; constructor(owner: unknown, args: object) { super(owner, args); @@ -22,6 +38,24 @@ export default class SystemStatusComponent extends Component { this.getStorageStatus.perform(); this.getDeviceFarmStatus.perform(); this.getAPIServerStatus.perform(); + + if (this.showRealtimeServerStatus) { + next(this, () => this.getWebsocketHealthStatus.perform()); + } + } + + willDestroy(): void { + super.willDestroy(); + + this.handleSocketHealthCheckCleanUp(); + } + + get isAuthenticated() { + return this.session.isAuthenticated; + } + + get showRealtimeServerStatus() { + return this.isAuthenticated; } get columns() { @@ -32,7 +66,7 @@ export default class SystemStatusComponent extends Component { } get rows() { - return [ + const statusRows = [ { id: 'storage', system: this.intl.t('storage'), @@ -53,6 +87,17 @@ export default class SystemStatusComponent extends Component { isWorking: this.isAPIServerWorking, }, ]; + + if (this.showRealtimeServerStatus) { + statusRows.push({ + id: 'websocket', + system: this.intl.t('realtimeServer'), + isRunning: this.getWebsocketHealthStatus.isRunning, + isWorking: this.isWebsocketWorking, + }); + } + + return statusRows; } getStorageStatus = task({ drop: true }, async () => { @@ -84,6 +129,61 @@ export default class SystemStatusComponent extends Component { this.isAPIServerWorking = false; } }); + + getWebsocketHealthStatus = task({ drop: true }, async () => { + const userId = this.session.data.authenticated.user_id; + this.socket = this.websocket.getSocketInstance(); + + try { + this.socket.on( + 'websocket_health_check', + this.onWebsocketHealthCheck.bind(this) + ); + + const user = await this.store.findRecord('user', userId); + + if (user.socketId) { + await waitForPromise( + this.triggerWebsocketHealthCheck.perform(user.socketId) + ); + } + } catch (_) { + this.isWebsocketWorking = false; + + this.handleSocketHealthCheckCleanUp(); + } + }); + + triggerWebsocketHealthCheck = task(async (socketId: string) => { + this.socket?.emit('subscribe', { room: socketId }); + + // wait for user to join room + await timeout(1000); + + await this.ajax.post('/api/websocket_health_check', { data: {} }); + + // wait for socket to notify + await timeout(1000); + }); + + onWebsocketHealthCheck(data: SocketHealthMessage) { + this.isWebsocketWorking = + ENV.environment === 'test' + ? JSON.parse(`${data}`).is_healthy + : data.is_healthy; + + this.handleSocketHealthCheckCleanUp(); + } + + handleSocketHealthCheckCleanUp() { + this.socket?.off( + 'websocket_health_check', + this.onWebsocketHealthCheck, + this + ); + + this.websocket.closeSocketConnection(); + } } declare module '@glint/environment-ember-loose/registry' { diff --git a/app/routes/authenticated.ts b/app/routes/authenticated.ts index 9a657f044..5b2b81455 100644 --- a/app/routes/authenticated.ts +++ b/app/routes/authenticated.ts @@ -2,19 +2,20 @@ import { inject as service } from '@ember/service'; import { isEmpty } from '@ember/utils'; import Route from '@ember/routing/route'; import { action } from '@ember/object'; -import Transition from '@ember/routing/transition'; -import Store from '@ember-data/store'; import { all } from 'rsvp'; -import IntlService from 'ember-intl/services/intl'; -import RouterService from '@ember/routing/router-service'; - -import MeService from 'irene/services/me'; -import DatetimeService from 'irene/services/datetime'; -import TrialService from 'irene/services/trial'; -import IntegrationService from 'irene/services/integration'; -import OrganizationService from 'irene/services/organization'; -import ConfigurationService from 'irene/services/configuration'; -import UserModel from 'irene/models/user'; +import type Transition from '@ember/routing/transition'; +import type Store from '@ember-data/store'; +import type IntlService from 'ember-intl/services/intl'; +import type RouterService from '@ember/routing/router-service'; + +import type MeService from 'irene/services/me'; +import type DatetimeService from 'irene/services/datetime'; +import type TrialService from 'irene/services/trial'; +import type IntegrationService from 'irene/services/integration'; +import type OrganizationService from 'irene/services/organization'; +import type ConfigurationService from 'irene/services/configuration'; +import type WebsocketService from 'irene/services/websocket'; +import type UserModel from 'irene/models/user'; import { CSBMap } from 'irene/router'; import ENV from 'irene/config/environment'; import triggerAnalytics from 'irene/utils/trigger-analytics'; @@ -26,7 +27,7 @@ export default class AuthenticatedRoute extends Route { @service declare datetime: DatetimeService; @service declare trial: TrialService; @service declare rollbar: any; - @service declare websocket: any; + @service declare websocket: WebsocketService; @service declare integration: IntegrationService; @service declare store: Store; @service('notifications') declare notify: NotificationService; diff --git a/app/services/websocket.js b/app/services/websocket.ts similarity index 63% rename from app/services/websocket.js rename to app/services/websocket.ts index c278ebfcc..7ac7e1272 100644 --- a/app/services/websocket.js +++ b/app/services/websocket.ts @@ -1,96 +1,136 @@ import { inject as service } from '@ember/service'; import { singularize } from 'ember-inflector'; import Service from '@ember/service'; -import ENUMS from 'irene/enums'; -import ENV from 'irene/config/environment'; import { task } from 'ember-concurrency'; import { debounce } from '@ember/runloop'; +import type Store from '@ember-data/store'; + +import ENUMS from 'irene/enums'; +import ENV from 'irene/config/environment'; +import type ConfigurationService from './configuration'; +import type RealtimeService from './realtime'; +import type AkNotificationsService from './ak-notifications'; +import type NetworkService from './network'; +import type UserModel from 'irene/models/user'; + +export interface SocketInstance { + on: (event: string, handler: (args: any) => void, target?: object) => void; + off: (event: string, handler: (args: any) => void, target?: object) => void; + emit: (event: string, data: unknown) => void; + reconnect: () => void; + close: () => void; +} + +export interface SocketHealthMessage { + is_healthy: boolean; +} + +type ModelNameIdMapper = Record; export default class WebsocketService extends Service { - @service store; - @service configuration; - @service logger; - @service realtime; - @service notifications; - @service akNotifications; - @service network; - @service('socket-io') socketIOService; + @service declare store: Store; + @service declare configuration: ConfigurationService; + @service declare logger: any; + @service declare realtime: RealtimeService; + @service declare notifications: NotificationService; + @service declare akNotifications: AkNotificationsService; + @service declare network: NetworkService; + @service('socket-io') socketIOService: any; connectionPath = '/websocket'; - currentUser = null; - currentSocketID = null; - connectedSocket = null; - modelNameIdMapper = {}; - - reset() { - this.currentUser = null; - this.currentSocketID = null; - this.connectedSocket = null; - } + currentUser: UserModel | null = null; + currentSocketID: string | null = null; + connectedSocket: SocketInstance | null = null; + modelNameIdMapper: ModelNameIdMapper = {}; getHost() { const hostFromConfig = this.configuration.serverData.websocket; + if (hostFromConfig) { return hostFromConfig; } + if (this.network.host) { return this.network.host; } + return '/'; } - async configure(user) { + getSocketInstance(): SocketInstance { + const host = this.getHost(); + + this.logger.debug(`Connecting websocket to ${host}`); + + return this.socketIOService.socketFor(host, { + path: this.connectionPath, + }); + } + + closeSocketConnection() { + const host = this.getHost(); + + this.socketIOService.closeSocketFor(host); + } + + async configure(user: UserModel) { if (!user) { return; } + const socketId = user.socketId; + if (!socketId) { return; } + this.currentSocketID = socketId; this.currentUser = user; + await this.connect(); } async connect() { - const host = this.getHost(); - this.logger.debug(`Connecting websocket to ${host}`); - const socket = this.socketIOService.socketFor(host, { - path: this.connectionPath, - }); + const socket = this.getSocketInstance(); + this.connectedSocket = socket; this._initializeSocket(socket); } - async _initializeSocket(socket) { + async _initializeSocket(socket: SocketInstance) { socket.on('connect', this.onConnect, this); socket.on('object', this.onObject, this); socket.on('newobject', this.onNewObject, this); socket.on('message', this.onMessage, this); socket.on('counter', this.onCounter, this); socket.on('notification', this.onNotification, this); + socket.on('close', (event) => { this.logger.warning('socket close called. Trying to reconnect', event); + socket.reconnect(); }); } onConnect() { - const socket = this.connectedSocket; this.logger.debug('Connecting to room: ' + this.currentSocketID); - socket.emit('subscribe', { + + this.connectedSocket?.emit('subscribe', { room: this.currentSocketID, }); } - onObject(data = {}) { + onObject(data: { id?: string; type?: string } = {}) { if (!data) { this.logger.error(`invalid data for onObject`); + return; } + if (!data.id || !data.type) { this.logger.error(`invalid data for onObject ${JSON.stringify(data)}`); + return; } @@ -102,19 +142,22 @@ export default class WebsocketService extends Service { this.enqueuePullModel.perform(modelName, objectID); } - onNewObject(...args) { + onNewObject(...args: { id?: string; type?: string }[]) { return this.onObject(...args); } - onNotification(data) { + onNotification(data?: { unread_count: number }) { if (!data) { this.logger.error(`invalid data for onNotification`); + return; } + if (!('unread_count' in data)) { this.logger.error( `invalid data for onNotification ${JSON.stringify(data)}` ); + return; } @@ -123,64 +166,81 @@ export default class WebsocketService extends Service { }); } - onMessage(data) { + onMessage(data?: { message?: string; notifyType?: number }) { if (!data) { this.logger.error(`invalid data for onMessage`); + return; } if (!data.message || !data.notifyType) { this.logger.error(`invalid data for onMessage ${JSON.stringify(data)}`); + return; } + const message = data.message; const notifyType = data.notifyType; if (notifyType === ENUMS.NOTIFY.INFO) { this.notifications.info(message, ENV.notifications); } + if (notifyType === ENUMS.NOTIFY.SUCCESS) { this.notifications.success(message, ENV.notifications); } + if (notifyType === ENUMS.NOTIFY.WARNING) { this.notifications.warning(message, ENV.notifications); } + if (notifyType === ENUMS.NOTIFY.ALERT) { this.notifications.alert(message, ENV.notifications); } + if (notifyType === ENUMS.NOTIFY.ERROR) { this.notifications.error(message, { autoClear: false, }); } + this.logger.debug(`${notifyType}: ${message}`); } - onCounter(data) { + onCounter(data?: { type?: string }) { if (!data) { this.logger.error(`invalid data for onCounter`); + return; } + if (!data.type) { this.logger.error(`invalid data for onCounter ${JSON.stringify(data)}`); + return; } - this.realtime.incrementProperty(`${data.type}Counter`); + this.realtime.incrementProperty( + `${data.type}Counter` as keyof RealtimeService + ); + this.logger.debug(`Realtime increment for ${data.type}`); } // this will ensure task run sequentially - enqueuePullModel = task({ enqueue: true }, async (modelName, id) => { - // store all unique modelName & id until debounce handler - this.modelNameIdMapper[`${modelName}-${id}`] = { modelName, id }; + enqueuePullModel = task( + { enqueue: true }, + async (modelName: string, id: string) => { + // store all unique modelName & id until debounce handler + this.modelNameIdMapper[`${modelName}-${id}`] = { modelName, id }; - // debounce and pass copy of mapper object - debounce(this, this.handlePullModel, { ...this.modelNameIdMapper }, 300); - }); + // debounce and pass copy of mapper object + debounce(this, this.handlePullModel, { ...this.modelNameIdMapper }, 300); + } + ); // debounce handler - handlePullModel(mapper) { + handlePullModel(mapper: ModelNameIdMapper) { // reset global mapper for next set of messages this.modelNameIdMapper = {}; diff --git a/tests/acceptance/deprecated-routes/status-redirect-test.js b/tests/acceptance/deprecated-routes/status-redirect-test.js index 09e3c7faf..82c0dca3a 100644 --- a/tests/acceptance/deprecated-routes/status-redirect-test.js +++ b/tests/acceptance/deprecated-routes/status-redirect-test.js @@ -3,6 +3,8 @@ import { visit, currentURL } from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; import Service from '@ember/service'; +import { SocketIO } from 'mock-socket'; + import { setupRequiredEndpoints } from '../../helpers/acceptance-utils'; class IntegrationStub extends Service { @@ -23,6 +25,12 @@ class WebsocketStub extends Service { async connect() {} async configure() {} + + getSocketInstance() { + return new SocketIO('https://socket.app.test'); + } + + closeSocketConnection() {} } module('Acceptance | Status Route Redirect', function (hooks) { diff --git a/tests/integration/components/system-status-test.js b/tests/integration/components/system-status-test.js index e94999dfe..cc552dad4 100644 --- a/tests/integration/components/system-status-test.js +++ b/tests/integration/components/system-status-test.js @@ -1,10 +1,13 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; -import { render } from '@ember/test-helpers'; +import { find, render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { setupIntl, t } from 'ember-intl/test-support'; import { Response } from 'miragejs'; +import { authenticateSession } from 'ember-simple-auth/test-support'; +import { SocketIO, Server } from 'mock-socket'; +import { faker } from '@faker-js/faker'; import Service from '@ember/service'; @@ -13,15 +16,31 @@ class ConfigurationStub extends Service { themeData = {}; imageData = {}; serverData = { - devicefarmURL: 'https://devicefarm.appknox.com', + devicefarmURL: 'https://devicefarm.app.com', + websocket: 'https://socket.app.test', }; } +class WebsocketStub extends Service { + getSocketInstance() { + return new SocketIO('https://socket.app.test'); + } + + closeSocketConnection() {} +} + module('Integration | Component | system-status', function (hooks) { setupRenderingTest(hooks); setupMirage(hooks); setupIntl(hooks); + hooks.beforeEach(async function () { + this.server.create('user'); + + this.owner.register('service:configuration', ConfigurationStub); + this.owner.register('service:websocket', WebsocketStub); + }); + test('it renders system status details', async function (assert) { await render(hbs``); @@ -57,8 +76,6 @@ module('Integration | Component | system-status', function (hooks) { 'it renders correct status for devicefarm and api status', [{ fail: false }, { fail: true }], async function (assert, { fail }) { - this.owner.register('service:configuration', ConfigurationStub); - this.server.get('/status', () => { return fail ? new Response(403) @@ -73,7 +90,7 @@ module('Integration | Component | system-status', function (hooks) { ); }); - this.server.get('https://devicefarm.appknox.com/devicefarm/ping', () => { + this.server.get('https://devicefarm.app.com/devicefarm/ping', () => { return fail ? new Response(404) : new Response(200, {}, { ping: 'pong' }); @@ -94,15 +111,100 @@ module('Integration | Component | system-status', function (hooks) { .exists() .containsText(t('systemStatus')); + const statusEntities = [ + { + id: 'storage', + label: 't:storage:()', + message: 't:proxyWarning:()', + }, + { + id: 'devicefarm', + label: 't:devicefarm:()', + }, + { + id: 'api-server', + label: 't:api:() t:server:()', + }, + ]; + + for (const { id, label, message } of statusEntities) { + const row = find(`[data-test-system-status-rows="${id}"]`); + + assert + .dom(row.querySelector('[data-test-system-status-systems]')) + .hasText(label); + + if (fail) { + assert + .dom(row.querySelector('[data-test-system-status-unreachable]')) + .hasText( + message ? 't:unreachable:() ' + message : 't:unreachable:()' + ); + } else { + assert + .dom(row.querySelector('[data-test-system-status-operational]')) + .hasText('t:operational:()'); + } + } + } + ); + + test.each( + 'websocket connection test', + [{ fail: false }, { fail: true }], + async function (assert, { fail }) { + assert.expect(5); + + await authenticateSession({ + authToken: faker.git.commitSha(), + user_id: '1', + }); + + const configuration = this.owner.lookup('service:configuration'); + + const mockServer = new Server(configuration.serverData.websocket); + + this.server.get('/users/:id', (schema, req) => { + return schema.users.find(req.params.id); + }); + + this.server.post('/websocket_health_check', () => { + mockServer.emit( + 'websocket_health_check', + JSON.stringify({ is_healthy: !fail }) + ); + }); + + await render(hbs``); + + assert.dom('[data-test-system-status]').exists(); + + assert + .dom('[data-test-system-status-title]') + .exists() + .containsText('t:systemStatus:()'); + + const websocketRow = find('[data-test-system-status-rows="websocket"]'); + + assert + .dom(websocketRow.querySelector('[data-test-system-status-systems]')) + .hasText('t:realtimeServer:()'); + if (fail) { assert - .dom('[data-test-system-status-unreachable]') - .exists({ count: 3 }); + .dom( + websocketRow.querySelector('[data-test-system-status-unreachable]') + ) + .hasText('t:unreachable:()'); } else { assert - .dom('[data-test-system-status-operational]') - .exists({ count: 3 }); + .dom( + websocketRow.querySelector('[data-test-system-status-operational]') + ) + .hasText('t:operational:()'); } + + mockServer.stop(); } ); }); diff --git a/translations/en.json b/translations/en.json index 6795c54e4..21876a16b 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1184,6 +1184,7 @@ "read": "Read", "realDevice": "Real Device", "reason": "Reason", + "realtimeServer": "Realtime Server", "reasonForOverride": "Reason for override", "reconnect": "Reconnet", "reconnectGotoSettings": "is not integrated, something went wrong you can go to settings & reconnect by", diff --git a/translations/ja.json b/translations/ja.json index 3c9e8b5d5..7f1eaa4d8 100644 --- a/translations/ja.json +++ b/translations/ja.json @@ -1184,6 +1184,7 @@ "read": "Read", "realDevice": "Real Device", "reason": "Reason", + "realtimeServer": "Realtime Server", "reasonForOverride": "Reason for override", "reconnect": "Reconnet", "reconnectGotoSettings": "is not integrated, something went wrong you can go to settings & reconnect by", diff --git a/types/irene/index.d.ts b/types/irene/index.d.ts index 7e20392eb..32b4028fb 100644 --- a/types/irene/index.d.ts +++ b/types/irene/index.d.ts @@ -133,6 +133,11 @@ declare global { options?: Omit ) => void; + alert: ( + message: string, + options?: Omit + ) => void; + setDefaultAutoClear: (value: boolean) => void; }