@xh/hoist 72.3.0 → 72.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +25 -6
  2. package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +22 -14
  3. package/admin/tabs/activity/tracking/detail/ActivityDetailModel.ts +2 -2
  4. package/admin/tabs/cluster/instances/BaseInstanceModel.ts +1 -29
  5. package/admin/tabs/cluster/instances/services/DetailsPanel.ts +2 -1
  6. package/admin/tabs/cluster/instances/websocket/WebSocketColumns.ts +24 -2
  7. package/admin/tabs/cluster/instances/websocket/WebSocketModel.ts +76 -25
  8. package/admin/tabs/cluster/instances/websocket/WebSocketPanel.ts +2 -2
  9. package/admin/tabs/cluster/objects/DetailModel.ts +4 -40
  10. package/admin/tabs/cluster/objects/DetailPanel.ts +2 -1
  11. package/appcontainer/AppContainerModel.ts +2 -0
  12. package/appcontainer/AppStateModel.ts +40 -8
  13. package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +1 -1
  14. package/build/types/admin/tabs/cluster/instances/BaseInstanceModel.d.ts +1 -3
  15. package/build/types/admin/tabs/cluster/instances/websocket/WebSocketColumns.d.ts +4 -1
  16. package/build/types/admin/tabs/cluster/instances/websocket/WebSocketModel.d.ts +5 -2
  17. package/build/types/admin/tabs/cluster/objects/DetailModel.d.ts +1 -3
  18. package/build/types/appcontainer/AppStateModel.d.ts +2 -0
  19. package/build/types/cmp/viewmanager/ViewManagerModel.d.ts +7 -0
  20. package/build/types/core/XH.d.ts +11 -1
  21. package/build/types/core/types/Interfaces.d.ts +2 -2
  22. package/build/types/format/FormatDate.d.ts +22 -1
  23. package/build/types/format/FormatMisc.d.ts +3 -2
  24. package/build/types/security/Types.d.ts +0 -25
  25. package/build/types/security/msal/MsalClient.d.ts +42 -4
  26. package/build/types/svc/ClientHealthService.d.ts +64 -0
  27. package/build/types/svc/TrackService.d.ts +3 -11
  28. package/build/types/svc/WebSocketService.d.ts +38 -15
  29. package/build/types/svc/index.d.ts +1 -0
  30. package/build/types/utils/js/index.d.ts +0 -1
  31. package/cmp/viewmanager/ViewManagerModel.ts +10 -1
  32. package/core/XH.ts +26 -1
  33. package/core/types/Interfaces.ts +2 -2
  34. package/data/filter/BaseFilterFieldSpec.ts +6 -2
  35. package/desktop/appcontainer/AboutDialog.ts +14 -0
  36. package/desktop/cmp/button/AppMenuButton.ts +1 -1
  37. package/desktop/cmp/contextmenu/ContextMenu.ts +1 -1
  38. package/desktop/cmp/viewmanager/ViewMenu.ts +11 -9
  39. package/format/FormatDate.ts +45 -3
  40. package/format/FormatMisc.ts +6 -4
  41. package/kit/onsen/theme.scss +5 -0
  42. package/mobile/appcontainer/AboutDialog.scss +1 -1
  43. package/mobile/appcontainer/AboutDialog.ts +24 -1
  44. package/mobile/cmp/menu/impl/Menu.ts +2 -2
  45. package/package.json +1 -1
  46. package/security/Types.ts +0 -27
  47. package/security/msal/MsalClient.ts +77 -25
  48. package/svc/ClientHealthService.ts +179 -0
  49. package/svc/TrackService.ts +9 -69
  50. package/svc/WebSocketService.ts +74 -33
  51. package/svc/index.ts +1 -0
  52. package/tsconfig.tsbuildinfo +1 -1
  53. package/utils/js/index.ts +0 -1
  54. package/build/types/utils/js/BrowserUtils.d.ts +0 -41
  55. package/utils/js/BrowserUtils.ts +0 -103
@@ -12,7 +12,7 @@ import {Icon} from '@xh/hoist/icon';
12
12
  import {menu, menuDivider, menuItem} from '@xh/hoist/kit/blueprint';
13
13
  import {pluralize} from '@xh/hoist/utils/js';
14
14
  import {Dictionary} from 'express-serve-static-core';
15
- import {each, filter, groupBy, isEmpty, orderBy, some, startCase} from 'lodash';
15
+ import {each, filter, groupBy, isEmpty, isFunction, orderBy, some, startCase} from 'lodash';
16
16
  import {ReactNode} from 'react';
17
17
  import {ViewManagerLocalModel} from './ViewManagerLocalModel';
18
18
 
@@ -162,12 +162,14 @@ function viewMenuItem(view: ViewInfo, model: ViewManagerModel): ReactNode {
162
162
  if (!view.isOwned && view.owner) title.push(view.owner);
163
163
  if (view.description) title.push(view.description);
164
164
 
165
- return menuItem({
166
- className: 'xh-view-manager__menu-item',
167
- key: view.token,
168
- text: view.name,
169
- title: title.join(' | '),
170
- icon,
171
- onClick: () => model.selectViewAsync(view).catchDefault()
172
- });
165
+ return isFunction(model.viewMenuItemFn)
166
+ ? model.viewMenuItemFn(view, model)
167
+ : menuItem({
168
+ className: 'xh-view-manager__menu-item',
169
+ key: view.token,
170
+ text: view.name,
171
+ title: title.join(' | '),
172
+ icon,
173
+ onClick: () => model.selectViewAsync(view).catchDefault()
174
+ });
173
175
  }
@@ -4,11 +4,11 @@
4
4
  *
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
- import {isLocalDate, LocalDate} from '@xh/hoist/utils/datetime';
8
- import {defaults, isString} from 'lodash';
7
+ import {DAYS, isLocalDate, LocalDate} from '@xh/hoist/utils/datetime';
8
+ import {defaults, isFinite, isString} from 'lodash';
9
9
  import moment from 'moment';
10
10
  import {ReactNode} from 'react';
11
- import {DateLike} from '../core/types/Types';
11
+ import {DateLike, PlainObject} from '../core/types/Types';
12
12
  import {fmtSpan, FormatOptions} from './FormatMisc';
13
13
  import {createRenderer} from './FormatUtils';
14
14
  import {saveOriginal} from './impl/Utils';
@@ -148,6 +148,48 @@ export function fmtCompactDate(v: DateLike, opts?: CompactDateFormatOptions) {
148
148
  return fmtDate(v, dateOpts);
149
149
  }
150
150
 
151
+ export interface TimestampReplacerConfig {
152
+ /**
153
+ * Suffixes used to identify keys that may hold timestamps.
154
+ * Defaults to ['time', 'date', 'timestamp']
155
+ */
156
+ suffixes?: string[];
157
+
158
+ /**
159
+ * Format for replaced timestamp.
160
+ * Defaults to 'MMM DD HH:mm:ss.SSS'
161
+ */
162
+ format?: string;
163
+ }
164
+
165
+ /**
166
+ * Replace timestamps in an Object with formatted strings.
167
+ */
168
+ export function withFormattedTimestamps(
169
+ obj: PlainObject,
170
+ config: TimestampReplacerConfig = {}
171
+ ): PlainObject {
172
+ return JSON.parse(JSON.stringify(obj, timestampReplacer(config)));
173
+ }
174
+
175
+ /**
176
+ * Create a replacer, suitable for JSON.stringify, that will replace timestamps with
177
+ * formatted strings.
178
+ */
179
+ export function timestampReplacer(
180
+ config: TimestampReplacerConfig = {}
181
+ ): (k: string, v: any) => any {
182
+ const suffixes = config.suffixes ?? ['time', 'date', 'timestamp'],
183
+ fmt = 'MMM DD HH:mm:ss.SSS';
184
+ return (k: string, v: any) => {
185
+ return suffixes.some(s => k.toLowerCase().endsWith(s.toLowerCase())) &&
186
+ isFinite(v) &&
187
+ v > Date.now() - 25 * 365 * DAYS // heuristic to avoid catching smaller ms ranges
188
+ ? fmtDateTime(v, {fmt})
189
+ : v;
190
+ };
191
+ }
192
+
151
193
  export const dateRenderer = createRenderer(fmtDate),
152
194
  dateTimeRenderer = createRenderer(fmtDateTime),
153
195
  dateTimeSecRenderer = createRenderer(fmtDateTimeSec),
@@ -5,8 +5,9 @@
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {span} from '@xh/hoist/cmp/layout';
8
- import {capitalize, isNil, kebabCase, map} from 'lodash';
8
+ import {capitalize, isNil, isString, kebabCase, map} from 'lodash';
9
9
  import {CSSProperties, ReactNode} from 'react';
10
+ import {PlainObject} from '@xh/hoist/core';
10
11
 
11
12
  export interface FormatOptions<T = any> {
12
13
  /** Display value for null values. */
@@ -63,11 +64,12 @@ export interface JSONFormatOptions {
63
64
  }
64
65
 
65
66
  /**
66
- * Pretty-print a JSON string, adding line breaks and indentation.
67
+ * Pretty-print a JSON string or (JSON Object), adding line breaks and indentation.
67
68
  */
68
- export function fmtJson(str: string, opts?: JSONFormatOptions): string {
69
+ export function fmtJson(v: string | PlainObject, opts?: JSONFormatOptions): string {
69
70
  const {replacer = undefined, space = 2} = opts ?? {};
70
- return isNil(str) ? '' : JSON.stringify(JSON.parse(str), replacer, space);
71
+ if (isString(v)) v = JSON.parse(v);
72
+ return isNil(v) ? '' : JSON.stringify(v, replacer, space);
71
73
  }
72
74
 
73
75
  /**
@@ -118,6 +118,11 @@ body.xh-app.xh-mobile {
118
118
  color: var(--xh-text-color);
119
119
  }
120
120
 
121
+ // Toast - ensure z-index exceeds dialogs
122
+ ons-toast {
123
+ z-index: 30001 !important;
124
+ }
125
+
121
126
  // Remove outlines
122
127
  .tabbar__button:focus {
123
128
  outline: none;
@@ -6,7 +6,7 @@
6
6
 
7
7
  th {
8
8
  text-align: right;
9
- vertical-align: top;
9
+ vertical-align: middle;
10
10
  white-space: nowrap;
11
11
  background-color: var(--xh-bg-alt);
12
12
  border-bottom: var(--xh-border-solid);
@@ -30,7 +30,30 @@ export const aboutDialog = hoistCmp.factory({
30
30
  className: 'xh-about-dialog',
31
31
  item: model.getTable(),
32
32
  isOpen: model.isOpen,
33
- bbar: [filler(), button({text: 'Close', onClick: () => model.hide()})]
33
+ bbar: [
34
+ button({
35
+ text: 'Send Client Health Report',
36
+ icon: Icon.health(),
37
+ omit: !XH.clientHealthService.enabled,
38
+ onClick: async () => {
39
+ try {
40
+ await XH.clientHealthService.sendReportAsync();
41
+ XH.successToast({
42
+ message: 'Client health report submitted.',
43
+ timeout: 2000
44
+ });
45
+ } catch (e) {
46
+ XH.handleException('Error sending client health report', e);
47
+ }
48
+ }
49
+ }),
50
+ filler(),
51
+ button({
52
+ text: 'Close',
53
+ outlined: true,
54
+ onClick: () => model.hide()
55
+ })
56
+ ]
34
57
  });
35
58
  }
36
59
  });
@@ -98,9 +98,9 @@ class LocalMenuModel extends HoistModel {
98
98
  omit: hidden,
99
99
  onTouchStart: () => (this.pressedIdx = idx),
100
100
  onTouchEnd: () => (this.pressedIdx = null),
101
- onClick: () => {
101
+ onClick: e => {
102
102
  this.pressedIdx = null;
103
- if (actionFn) actionFn();
103
+ if (actionFn) actionFn(e);
104
104
  onDismiss();
105
105
  }
106
106
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "72.3.0",
3
+ "version": "72.5.0",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": "github:xh/hoist-react",
6
6
  "homepage": "https://xh.io",
package/security/Types.ts CHANGED
@@ -22,30 +22,3 @@ export interface AccessTokenSpec {
22
22
  }
23
23
 
24
24
  export type TokenMap = Record<string, Token>;
25
-
26
- /** Aggregated telemetry results, produced by {@link MsalClient} when enabled via config. */
27
- export interface TelemetryResults {
28
- /** Stats by event type - */
29
- events: Record<string, TelemetryEventResults>;
30
- }
31
-
32
- /** Aggregated telemetry results for a single type of event. */
33
- export interface TelemetryEventResults {
34
- firstTime: Date;
35
- lastTime: Date;
36
- successCount: number;
37
- failureCount: number;
38
- /** Timing info (in ms) for event instances reported with duration. */
39
- duration: {
40
- count: number;
41
- total: number;
42
- average: number;
43
- worst: number;
44
- };
45
- lastFailure?: {
46
- time: Date;
47
- duration: number;
48
- code: string;
49
- name: string;
50
- };
51
- }
@@ -14,12 +14,13 @@ import {
14
14
  PopupRequest,
15
15
  SilentRequest
16
16
  } from '@azure/msal-browser';
17
- import {XH} from '@xh/hoist/core';
17
+ import {AppState, PlainObject, XH} from '@xh/hoist/core';
18
18
  import {Token} from '@xh/hoist/security/Token';
19
19
  import {logDebug, logError, logInfo, logWarn, mergeDeep, throwIf} from '@xh/hoist/utils/js';
20
+ import {withFormattedTimestamps} from '@xh/hoist/format';
20
21
  import {flatMap, union, uniq} from 'lodash';
21
22
  import {BaseOAuthClient, BaseOAuthClientConfig} from '../BaseOAuthClient';
22
- import {AccessTokenSpec, TelemetryResults, TokenMap} from '../Types';
23
+ import {AccessTokenSpec, TokenMap} from '../Types';
23
24
 
24
25
  export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
25
26
  /**
@@ -38,8 +39,7 @@ export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
38
39
 
39
40
  /**
40
41
  * True to enable support for built-in telemetry provided by this class's internal MSAL client.
41
- * Captured performance events will be summarized via {@link telemetryResults}.
42
- * See https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/performance.md
42
+ * Captured performance events will be summarized as {@link MsalClientTelemetry}.
43
43
  */
44
44
  enableTelemetry?: boolean;
45
45
 
@@ -114,7 +114,7 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
114
114
  private initialTokenLoad: boolean;
115
115
 
116
116
  /** Enable telemetry via `enableTelemetry` ctor config, or via {@link enableTelemetry}. */
117
- telemetryResults: TelemetryResults = {events: {}};
117
+ telemetry: MsalClientTelemetry = null;
118
118
  private _telemetryCbHandle: string = null;
119
119
 
120
120
  constructor(config: MsalClientConfig) {
@@ -266,34 +266,47 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
266
266
  //------------------------
267
267
  // Telemetry
268
268
  //------------------------
269
+ getFormattedTelemetry(): PlainObject {
270
+ return withFormattedTimestamps(this.telemetry);
271
+ }
272
+
269
273
  enableTelemetry(): void {
270
274
  if (this._telemetryCbHandle) {
271
- this.logInfo('Telemetry already enabled', this.telemetryResults);
275
+ this.logInfo('Telemetry already enabled', this.getFormattedTelemetry());
272
276
  return;
273
277
  }
274
278
 
275
- this.telemetryResults = {events: {}};
279
+ this.telemetry = {
280
+ summary: {
281
+ successCount: 0,
282
+ failureCount: 0,
283
+ maxDuration: 0,
284
+ lastFailureTime: null
285
+ },
286
+ events: {}
287
+ };
276
288
 
277
289
  this._telemetryCbHandle = this.client.addPerformanceCallback(events => {
278
290
  events.forEach(e => {
279
291
  try {
280
- const {events} = this.telemetryResults,
292
+ const {summary, events} = this.telemetry,
281
293
  {name, startTimeMs, durationMs, success, errorName, errorCode} = e,
282
- eTime = startTimeMs ? new Date(startTimeMs) : new Date();
294
+ eTime = startTimeMs ?? Date.now();
283
295
 
284
296
  const eResult = (events[name] ??= {
285
297
  firstTime: eTime,
286
298
  lastTime: eTime,
287
299
  successCount: 0,
288
- failureCount: 0,
289
- duration: {count: 0, total: 0, average: 0, worst: 0},
290
- lastFailure: null
300
+ failureCount: 0
291
301
  });
292
302
  eResult.lastTime = eTime;
293
303
 
294
304
  if (success) {
305
+ summary.successCount++;
295
306
  eResult.successCount++;
296
307
  } else {
308
+ summary.failureCount++;
309
+ summary.lastFailureTime = eTime;
297
310
  eResult.failureCount++;
298
311
  eResult.lastFailure = {
299
312
  time: eTime,
@@ -304,11 +317,17 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
304
317
  }
305
318
 
306
319
  if (durationMs) {
307
- const {duration} = eResult;
320
+ const duration = (eResult.duration ??= {
321
+ count: 0,
322
+ total: 0,
323
+ average: 0,
324
+ max: 0
325
+ });
308
326
  duration.count++;
309
327
  duration.total += durationMs;
310
328
  duration.average = Math.round(duration.total / duration.count);
311
- duration.worst = Math.max(duration.worst, durationMs);
329
+ duration.max = Math.max(duration.max, durationMs);
330
+ summary.maxDuration = Math.max(summary.maxDuration, durationMs);
312
331
  }
313
332
  } catch (e) {
314
333
  this.logError(`Error processing telemetry event`, e);
@@ -316,18 +335,13 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
316
335
  });
317
336
  });
318
337
 
319
- // Ask TrackService to include in background health check report, if enabled on that service.
320
- // Handle TrackService not yet initialized (common, this client likely initialized before.)
338
+ // Wait for clientHealthService (this client likely initialized during earlier AUTHENTICATING.)
321
339
  this.addReaction({
322
- when: () => XH.appIsRunning,
323
- run: () =>
324
- XH.trackService.addClientHealthReportSource(
325
- 'msalClient',
326
- () => this.telemetryResults
327
- )
340
+ when: () => XH.appState === AppState.INITIALIZING_APP,
341
+ run: () => XH.clientHealthService.addSource('msalClient', () => this.telemetry)
328
342
  });
329
343
 
330
- this.logInfo('Telemetry enabled');
344
+ this.logDebug('Telemetry enabled');
331
345
  }
332
346
 
333
347
  disableTelemetry(): void {
@@ -339,8 +353,8 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
339
353
  this.client.removePerformanceCallback(this._telemetryCbHandle);
340
354
  this._telemetryCbHandle = null;
341
355
 
342
- XH.trackService.removeClientHealthReportSource('msalClient');
343
- this.logInfo('Telemetry disabled', this.telemetryResults);
356
+ XH.clientHealthService.removeSource('msalClient');
357
+ this.logInfo('Telemetry disabled', this.getFormattedTelemetry());
344
358
  }
345
359
 
346
360
  //------------------------
@@ -434,3 +448,41 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
434
448
  this.logDebug('User Authenticated', account.username);
435
449
  }
436
450
  }
451
+
452
+ /**
453
+ * Telemetry produced by this client (if enabled) + included in {@link ClientHealthService}
454
+ * reporting. Leverages MSAL's opt-in support for emitting performance events.
455
+ * See https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/performance.md
456
+ */
457
+ interface MsalClientTelemetry {
458
+ /** Stats across all events */
459
+ summary: {
460
+ successCount: number;
461
+ failureCount: number;
462
+ maxDuration: number;
463
+ lastFailureTime: number;
464
+ };
465
+ /** Stats by event type */
466
+ events: Record<string, MsalEventTelemetry>;
467
+ }
468
+
469
+ /** Aggregated telemetry results for a single type of event. */
470
+ interface MsalEventTelemetry {
471
+ firstTime: number;
472
+ lastTime: number;
473
+ successCount: number;
474
+ failureCount: number;
475
+ /** Timing info (in ms) for event instances reported with duration. */
476
+ duration?: {
477
+ count: number;
478
+ total: number;
479
+ average: number;
480
+ max: number;
481
+ };
482
+ lastFailure?: {
483
+ time: number;
484
+ duration: number;
485
+ code: string;
486
+ name: string;
487
+ };
488
+ }
@@ -0,0 +1,179 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright © 2025 Extremely Heavy Industries Inc.
6
+ */
7
+ import {HoistService, PageState, PlainObject, TrackOptions, XH} from '@xh/hoist/core';
8
+ import {WebSocketTelemetry} from '@xh/hoist/svc/WebSocketService';
9
+ import {Timer} from '@xh/hoist/utils/async';
10
+ import {MINUTES} from '@xh/hoist/utils/datetime';
11
+ import {withFormattedTimestamps} from '@xh/hoist/format';
12
+ import {pick, round} from 'lodash';
13
+
14
+ /**
15
+ * Service for gathering data about the current state and health of the client app, for submission
16
+ * to the server or review on the console during interactive troubleshooting.
17
+ *
18
+ * Hoist sends this data once on application load and can be configured to send at regular intervals
19
+ * throughout a user's session via the `xhActivityTracking.clientHealthReport` app config. Reports
20
+ * are submitted via activity tracking for review within the Admin Console.
21
+ */
22
+ export class ClientHealthService extends HoistService {
23
+ static instance: ClientHealthService;
24
+
25
+ private sources: Map<string, () => any> = new Map();
26
+
27
+ override async initAsync() {
28
+ const {clientHealthReport} = XH.trackService.conf;
29
+ Timer.create({
30
+ runFn: () => this.sendReportInternal(),
31
+ interval: clientHealthReport.intervalMins * MINUTES,
32
+ delay: true
33
+ });
34
+ }
35
+
36
+ get enabled(): boolean {
37
+ return XH.trackService.enabled;
38
+ }
39
+
40
+ /** @returns a customizable report with metrics capturing client app/session state. */
41
+ getReport(): ClientHealthReport {
42
+ return {
43
+ general: this.getGeneral(),
44
+ memory: this.getMemory(),
45
+ connection: this.getConnection(),
46
+ webSockets: XH.webSocketService.telemetry,
47
+ ...this.getCustom()
48
+ };
49
+ }
50
+
51
+ /** @returns a report, formatted for easier viewing in console. **/
52
+ getFormattedReport(): PlainObject {
53
+ return withFormattedTimestamps(this.getReport());
54
+ }
55
+
56
+ /**
57
+ * Register a new source for app-specific data to be sent with each report.
58
+ * @param key - key under which to report the data - can be used to remove this source later.
59
+ * @param callback - function returning serializable to include with each report.
60
+ */
61
+ addSource(key: string, callback: () => any) {
62
+ this.sources.set(key, callback);
63
+ }
64
+
65
+ /** Unregister a previously-enabled source for client health report data. */
66
+ removeSource(key: string) {
67
+ this.sources.delete(key);
68
+ }
69
+
70
+ /**
71
+ * Generate and submit a report to the server, via TrackService.
72
+ *
73
+ * For ad-hoc troubleshooting. Apps may also configure this service to
74
+ * submit on regular intervals.
75
+ */
76
+ async sendReportAsync() {
77
+ this.sendReportInternal({severity: 'INFO'});
78
+ await XH.trackService.pushPendingAsync();
79
+ }
80
+
81
+ // -----------------------------------
82
+ // Generate individual report sections
83
+ //------------------------------------
84
+ getGeneral(): GeneralData {
85
+ const startTime = XH.appContainerModel.appStateModel.loadStarted,
86
+ elapsedMins = (ts: number) => round((Date.now() - ts) / 60_000, 1);
87
+
88
+ return {
89
+ startTime,
90
+ durationMins: elapsedMins(startTime),
91
+ idleMins: elapsedMins(XH.lastActivityMs),
92
+ pageState: XH.pageState
93
+ };
94
+ }
95
+
96
+ getConnection(): ConnectionData {
97
+ const nav = window.navigator as any;
98
+ if (!nav.connection) return null;
99
+ return pick(nav.connection, ['downlink', 'effectiveType', 'rtt']);
100
+ }
101
+
102
+ getMemory(): MemoryData {
103
+ const perf = window.performance as any;
104
+ if (!perf?.memory) return null;
105
+
106
+ const ret: MemoryData = {modelCount: XH.getModels().length};
107
+ ['jsHeapSizeLimit', 'totalJSHeapSize', 'usedJSHeapSize'].forEach(key => {
108
+ const raw = perf.memory[key];
109
+ if (raw) ret[key] = round(raw / 1024 / 1024); // convert to MB
110
+ });
111
+
112
+ const {jsHeapSizeLimit: limit, usedJSHeapSize: used} = ret;
113
+ if (limit && used) {
114
+ ret.usedPctLimit = round((used / limit) * 100);
115
+ }
116
+
117
+ return ret;
118
+ }
119
+
120
+ getCustom(): PlainObject {
121
+ const ret = {};
122
+ this.sources.forEach((cb, k) => {
123
+ try {
124
+ ret[k] = cb();
125
+ } catch (e) {
126
+ ret[k] = `Error: ${e.message}`;
127
+ this.logWarn(`Error running client health report callback for [${k}]`, e);
128
+ }
129
+ });
130
+ return ret;
131
+ }
132
+
133
+ //---------------------
134
+ // Implementation
135
+ //---------------------
136
+ private sendReportInternal(opts: Partial<TrackOptions> = {}) {
137
+ const {intervalMins, ...rest} = XH.trackService.conf.clientHealthReport ?? {};
138
+
139
+ XH.track({
140
+ category: 'App',
141
+ message: 'Submitted health report',
142
+ ...rest,
143
+ ...opts,
144
+ data: {
145
+ loadId: XH.loadId,
146
+ tabId: XH.tabId,
147
+ ...this.getReport()
148
+ }
149
+ });
150
+ }
151
+ }
152
+
153
+ export interface GeneralData {
154
+ startTime: number;
155
+ durationMins: number;
156
+ idleMins: number;
157
+ pageState: PageState;
158
+ }
159
+
160
+ export interface ConnectionData {
161
+ downlink: number;
162
+ effectiveType: string;
163
+ rtt: number;
164
+ }
165
+
166
+ export interface MemoryData {
167
+ modelCount: number;
168
+ usedPctLimit?: number;
169
+ jsHeapSizeLimit?: number;
170
+ totalJSHeapSize?: number;
171
+ usedJSHeapSize?: number;
172
+ }
173
+
174
+ export interface ClientHealthReport {
175
+ general: GeneralData;
176
+ connection: ConnectionData;
177
+ memory: MemoryData;
178
+ webSockets: WebSocketTelemetry;
179
+ }