diff --git a/app/components/system-status/index.hbs b/app/components/system-status/index.hbs
index dcc9d969a6..409c4827af 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 ce91498e15..901d4be0b7 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 9a657f044a..5b2b814557 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 c278ebfcc5..7ac7e1272c 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 09e3c7faf7..679e734e8a 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,10 @@ class WebsocketStub extends Service {
async connect() {}
async configure() {}
+
+ getSocketInstance() {
+ return new SocketIO('https://socket.app.test');
+ }
}
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 e94999dfe5..cc552dad45 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 6795c54e40..21876a16b6 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 3c9e8b5d5c..7f1eaa4d83 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 7e20392eb5..32b4028fb2 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;
}