@xh/hoist 72.2.0 → 72.3.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 (37) hide show
  1. package/CHANGELOG.md +28 -2
  2. package/admin/tabs/cluster/instances/InstancesTabModel.ts +4 -3
  3. package/admin/tabs/cluster/instances/logs/LogDisplay.ts +12 -14
  4. package/admin/tabs/cluster/instances/logs/LogDisplayModel.ts +0 -2
  5. package/admin/tabs/cluster/instances/logs/LogViewer.ts +6 -5
  6. package/admin/tabs/cluster/instances/logs/LogViewerModel.ts +8 -1
  7. package/admin/tabs/cluster/instances/memory/MemoryMonitorModel.ts +1 -0
  8. package/admin/tabs/cluster/instances/services/DetailsModel.ts +1 -2
  9. package/admin/tabs/cluster/instances/services/DetailsPanel.ts +19 -14
  10. package/admin/tabs/cluster/instances/services/ServiceModel.ts +14 -6
  11. package/admin/tabs/cluster/instances/services/ServicePanel.ts +9 -10
  12. package/admin/tabs/cluster/instances/websocket/WebSocketColumns.ts +9 -0
  13. package/admin/tabs/cluster/instances/websocket/WebSocketModel.ts +2 -1
  14. package/admin/tabs/userData/roles/RoleModel.ts +1 -1
  15. package/appcontainer/AppStateModel.ts +6 -1
  16. package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +4 -1
  17. package/build/types/admin/tabs/cluster/instances/services/DetailsModel.d.ts +2 -3
  18. package/build/types/admin/tabs/cluster/instances/websocket/WebSocketColumns.d.ts +1 -0
  19. package/build/types/appcontainer/AppStateModel.d.ts +5 -1
  20. package/build/types/desktop/cmp/tab/TabSwitcher.d.ts +1 -1
  21. package/build/types/security/BaseOAuthClient.d.ts +6 -7
  22. package/build/types/security/Types.d.ts +32 -5
  23. package/build/types/security/msal/MsalClient.d.ts +12 -1
  24. package/build/types/svc/TrackService.d.ts +31 -1
  25. package/build/types/utils/js/BrowserUtils.d.ts +38 -1
  26. package/data/Store.ts +3 -0
  27. package/desktop/cmp/grid/editors/BooleanEditor.ts +15 -3
  28. package/desktop/cmp/tab/TabSwitcher.ts +1 -1
  29. package/package.json +2 -2
  30. package/security/BaseOAuthClient.ts +12 -10
  31. package/security/Types.ts +35 -6
  32. package/security/msal/MsalClient.ts +125 -20
  33. package/svc/FetchService.ts +3 -2
  34. package/svc/TrackService.ts +94 -8
  35. package/svc/WebSocketService.ts +1 -2
  36. package/tsconfig.tsbuildinfo +1 -1
  37. package/utils/js/BrowserUtils.ts +72 -21
@@ -6,15 +6,45 @@ import { HoistService, TrackOptions } from '@xh/hoist/core';
6
6
  */
7
7
  export declare class TrackService extends HoistService {
8
8
  static instance: TrackService;
9
+ private clientHealthReportSources;
9
10
  private oncePerSessionSent;
10
11
  private pending;
11
12
  initAsync(): Promise<void>;
12
- get conf(): any;
13
+ get conf(): ActivityTrackingConfig;
13
14
  get enabled(): boolean;
14
15
  /** Track User Activity. */
15
16
  track(options: TrackOptions | string): void;
17
+ /**
18
+ * Register a new source for client health report data. No-op if background health report is
19
+ * not generally enabled via `xhActivityTrackingConfig.clientHealthReport.intervalMins`.
20
+ *
21
+ * @param key - key under which to report the data - can be used to remove this source later.
22
+ * @param callback - function returning serializable to include with each report.
23
+ */
24
+ addClientHealthReportSource(key: string, callback: () => any): void;
25
+ /** Unregister a previously-enabled source for client health report data. */
26
+ removeClientHealthReportSource(key: string): void;
16
27
  private pushPendingAsync;
17
28
  private pushPendingBuffered;
18
29
  private toServerJson;
19
30
  private logMessage;
31
+ private sendClientHealthReport;
20
32
  }
33
+ interface ActivityTrackingConfig {
34
+ clientHealthReport?: Partial<TrackOptions> & {
35
+ intervalMins: number;
36
+ };
37
+ enabled: boolean;
38
+ logData: boolean;
39
+ maxDataLength: number;
40
+ maxRows?: {
41
+ default: number;
42
+ options: number[];
43
+ };
44
+ levels?: Array<{
45
+ username: string | '*';
46
+ category: string | '*';
47
+ severity: 'DEBUG' | 'INFO' | 'WARN';
48
+ }>;
49
+ }
50
+ export {};
@@ -1,4 +1,41 @@
1
1
  /**
2
2
  * Extract information (if available) about the client browser's window, screen, and network speed.
3
3
  */
4
- export declare function getClientDeviceInfo(): any;
4
+ export declare function getClientDeviceInfo(): ClientDeviceInfo;
5
+ export interface ClientDeviceInfo {
6
+ window: {
7
+ devicePixelRatio: number;
8
+ screenX: number;
9
+ screenY: number;
10
+ innerWidth: number;
11
+ innerHeight: number;
12
+ outerWidth: number;
13
+ outerHeight: number;
14
+ };
15
+ screen?: {
16
+ availWidth: number;
17
+ availHeight: number;
18
+ width: number;
19
+ height: number;
20
+ colorDepth: number;
21
+ pixelDepth: number;
22
+ availLeft: number;
23
+ availTop: number;
24
+ orientation?: {
25
+ angle: number;
26
+ type: string;
27
+ };
28
+ };
29
+ connection?: {
30
+ downlink: number;
31
+ effectiveType: string;
32
+ rtt: number;
33
+ };
34
+ memory: {
35
+ modelCount: number;
36
+ usedPctLimit?: number;
37
+ jsHeapSizeLimit?: number;
38
+ totalJSHeapSize?: number;
39
+ usedJSHeapSize?: number;
40
+ };
41
+ }
package/data/Store.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  isEmpty,
19
19
  isFunction,
20
20
  isNil,
21
+ isNull,
21
22
  isString,
22
23
  values,
23
24
  remove as lodashRemove,
@@ -692,6 +693,8 @@ export class Store extends HoistBase {
692
693
  * for backwards compat with app code predating support for multiple {@link summaryRecords}.
693
694
  */
694
695
  get summaryRecord(): StoreRecord {
696
+ if (isNull(this.summaryRecords)) return null;
697
+
695
698
  throwIf(
696
699
  this.summaryRecords.length > 1,
697
700
  'Store has multiple summary records - must access via Store.summaryRecords.'
@@ -49,11 +49,23 @@ export const [BooleanEditor, booleanEditor] = hoistCmp.withFactory<BooleanEditor
49
49
  }
50
50
  });
51
51
 
52
- function useInstantEditor({onValueChange, initialValue, stopEditing}: CustomCellEditorProps, ref) {
52
+ function useInstantEditor(
53
+ {onValueChange, initialValue, stopEditing, eventKey, eGridCell}: CustomCellEditorProps,
54
+ ref
55
+ ) {
56
+ // Don't toggle if the user has tabbed into the editor. See https://github.com/xh/hoist-react/issues/3943.
57
+ // Fortunately, `eventKey` is null for tab, so we can use that to accept other keyboard events.
58
+ // Unfortunately, it is also null for mouse events, so we check if the grid cell is currently
59
+ // underneath the mouse position via `:hover` selector.
53
60
  useEffect(() => {
54
- onValueChange(!initialValue);
61
+ const els = document.querySelectorAll(':hover'),
62
+ topEl = els[els.length - 1];
63
+
64
+ if (eventKey || topEl === eGridCell) {
65
+ onValueChange(!initialValue);
66
+ }
55
67
  stopEditing();
56
- }, [stopEditing, initialValue, onValueChange]);
68
+ }, [stopEditing, initialValue, onValueChange, eventKey, eGridCell]);
57
69
 
58
70
  return null;
59
71
  }
@@ -41,7 +41,7 @@ import {CSSProperties, ReactElement, KeyboardEvent} from 'react';
41
41
  *
42
42
  * Overflowing tabs can be displayed in a dropdown menu if `enableOverflow` is true.
43
43
  * Note that in order for tabs to overflow, the TabSwitcher or it's wrapper must have a
44
- * a maximum width.
44
+ * maximum width.
45
45
  */
46
46
  export const [TabSwitcher, tabSwitcher] = hoistCmp.withFactory<TabSwitcherProps>({
47
47
  displayName: 'TabSwitcher',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "72.2.0",
3
+ "version": "72.3.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",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@auth0/auth0-spa-js": "~2.1.3",
33
- "@azure/msal-browser": "~3.28.0",
33
+ "@azure/msal-browser": "~4.8.0",
34
34
  "@blueprintjs/core": "^5.10.5",
35
35
  "@blueprintjs/datetime": "^5.3.7",
36
36
  "@blueprintjs/datetime2": "^2.3.7",
@@ -64,14 +64,13 @@ export interface BaseOAuthClientConfig<S extends AccessTokenSpec> {
64
64
  idScopes?: string[];
65
65
 
66
66
  /**
67
- * Optional map of access tokens to be loaded and maintained.
67
+ * Optional spec for access tokens to be loaded and maintained to support access to one or more
68
+ * different back-end resources, distinct from the core Hoist auth flow via ID token.
68
69
  *
69
- * Map of code to a spec for an access token. The code is app-determined and
70
- * will simply be used to get the loaded token via tha getAccessToken() method. The
71
- * spec is implementation specific, but will typically include scopes to be loaded
72
- * for the access token and potentially other meta-data required by the underlying provider.
73
- *
74
- * Use this map to gain targeted access tokens for different back-end resources.
70
+ * Map of key to a spec for an access token. The key is an arbitrary, app-determined string
71
+ * used to retrieve the loaded token via {@link getAccessTokenAsync}. The spec is implementation
72
+ * specific, but will typically include scopes to be loaded for the access token and potentially
73
+ * other metadata required by the underlying provider.
75
74
  */
76
75
  accessTokens?: Record<string, S>;
77
76
  }
@@ -120,7 +119,7 @@ export abstract class BaseOAuthClient<
120
119
  throwIf(!config.clientId, 'Missing OAuth clientId. Please review your configuration.');
121
120
 
122
121
  this.idScopes = union(['openid', 'email'], config.idScopes);
123
- this.accessSpecs = this.config.accessTokens;
122
+ this.accessSpecs = this.config.accessTokens ?? {};
124
123
  }
125
124
 
126
125
  /**
@@ -164,7 +163,10 @@ export abstract class BaseOAuthClient<
164
163
  * Get an Access token.
165
164
  */
166
165
  async getAccessTokenAsync(key: string): Promise<Token> {
167
- return this.fetchAccessTokenAsync(this.accessSpecs[key], true);
166
+ const spec = this.accessSpecs[key];
167
+ if (!spec) throw XH.exception(`No access token spec configured for key "${key}"`);
168
+
169
+ return this.fetchAccessTokenAsync(spec, true);
168
170
  }
169
171
 
170
172
  /**
@@ -314,7 +316,7 @@ export abstract class BaseOAuthClient<
314
316
  const eagerOnly = opts?.eagerOnly ?? false,
315
317
  useCache = opts?.useCache ?? true,
316
318
  accessSpecs = eagerOnly
317
- ? pickBy(this.accessSpecs, spec => spec.fetchMode === 'eager')
319
+ ? pickBy(this.accessSpecs, spec => spec.fetchMode !== 'lazy') // specs are eager by default - opt-in to lazy
318
320
  : this.accessSpecs,
319
321
  ret: TokenMap = {};
320
322
 
package/security/Types.ts CHANGED
@@ -7,16 +7,45 @@
7
7
 
8
8
  import {Token} from './Token';
9
9
 
10
- export type TokenMap = Record<string, Token>;
11
-
12
10
  export interface AccessTokenSpec {
13
11
  /**
14
- * Mode governing when the access token should be requested from provider.
15
- * eager (default) - initiate lookup on initialization, but do not block on failure.
16
- * lazy - lookup when requested by client.
12
+ * Mode governing when the access token should be requested from provider:
13
+ * - eager (or undefined) - load on overall initialization, but do not block on failure.
14
+ * Useful for tokens that an app is almost certain to require during a user session.
15
+ * - lazy - defer loading until first requested by client. Useful for tokens that might
16
+ * never be needed by the app during a given user session.
17
17
  */
18
- fetchMode: 'eager' | 'lazy';
18
+ fetchMode?: 'eager' | 'lazy';
19
19
 
20
20
  /** Scopes for the desired access token.*/
21
21
  scopes: string[];
22
22
  }
23
+
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
+ }
@@ -7,6 +7,8 @@
7
7
  import * as msal from '@azure/msal-browser';
8
8
  import {
9
9
  AccountInfo,
10
+ BrowserPerformanceClient,
11
+ Configuration,
10
12
  IPublicClientApplication,
11
13
  LogLevel,
12
14
  PopupRequest,
@@ -14,10 +16,10 @@ import {
14
16
  } from '@azure/msal-browser';
15
17
  import {XH} from '@xh/hoist/core';
16
18
  import {Token} from '@xh/hoist/security/Token';
17
- import {AccessTokenSpec, TokenMap} from '../Types';
18
19
  import {logDebug, logError, logInfo, logWarn, mergeDeep, throwIf} from '@xh/hoist/utils/js';
19
20
  import {flatMap, union, uniq} from 'lodash';
20
21
  import {BaseOAuthClient, BaseOAuthClientConfig} from '../BaseOAuthClient';
22
+ import {AccessTokenSpec, TelemetryResults, TokenMap} from '../Types';
21
23
 
22
24
  export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
23
25
  /**
@@ -34,6 +36,13 @@ export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
34
36
  */
35
37
  domainHint?: string;
36
38
 
39
+ /**
40
+ * 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
43
+ */
44
+ enableTelemetry?: boolean;
45
+
37
46
  /**
38
47
  * If specified, the client will use this value when initializing the app to enforce a minimum
39
48
  * amount of time during which no further auth flow with the provider should be necessary.
@@ -104,6 +113,10 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
104
113
  private account: AccountInfo; // Authenticated account
105
114
  private initialTokenLoad: boolean;
106
115
 
116
+ /** Enable telemetry via `enableTelemetry` ctor config, or via {@link enableTelemetry}. */
117
+ telemetryResults: TelemetryResults = {events: {}};
118
+ private _telemetryCbHandle: string = null;
119
+
107
120
  constructor(config: MsalClientConfig) {
108
121
  super({
109
122
  initRefreshTokenExpirationOffsetSecs: -1,
@@ -118,6 +131,9 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
118
131
  //-------------------------------------------
119
132
  protected override async doInitAsync(): Promise<TokenMap> {
120
133
  const client = (this.client = await this.createClientAsync());
134
+ if (this.config.enableTelemetry) {
135
+ this.enableTelemetry();
136
+ }
121
137
 
122
138
  // 0) Handle redirect return
123
139
  const redirectResp = await client.handleRedirectPromise();
@@ -247,6 +263,86 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
247
263
  : await client.logoutPopup(opts);
248
264
  }
249
265
 
266
+ //------------------------
267
+ // Telemetry
268
+ //------------------------
269
+ enableTelemetry(): void {
270
+ if (this._telemetryCbHandle) {
271
+ this.logInfo('Telemetry already enabled', this.telemetryResults);
272
+ return;
273
+ }
274
+
275
+ this.telemetryResults = {events: {}};
276
+
277
+ this._telemetryCbHandle = this.client.addPerformanceCallback(events => {
278
+ events.forEach(e => {
279
+ try {
280
+ const {events} = this.telemetryResults,
281
+ {name, startTimeMs, durationMs, success, errorName, errorCode} = e,
282
+ eTime = startTimeMs ? new Date(startTimeMs) : new Date();
283
+
284
+ const eResult = (events[name] ??= {
285
+ firstTime: eTime,
286
+ lastTime: eTime,
287
+ successCount: 0,
288
+ failureCount: 0,
289
+ duration: {count: 0, total: 0, average: 0, worst: 0},
290
+ lastFailure: null
291
+ });
292
+ eResult.lastTime = eTime;
293
+
294
+ if (success) {
295
+ eResult.successCount++;
296
+ } else {
297
+ eResult.failureCount++;
298
+ eResult.lastFailure = {
299
+ time: eTime,
300
+ duration: e.durationMs,
301
+ code: errorCode,
302
+ name: errorName
303
+ };
304
+ }
305
+
306
+ if (durationMs) {
307
+ const {duration} = eResult;
308
+ duration.count++;
309
+ duration.total += durationMs;
310
+ duration.average = Math.round(duration.total / duration.count);
311
+ duration.worst = Math.max(duration.worst, durationMs);
312
+ }
313
+ } catch (e) {
314
+ this.logError(`Error processing telemetry event`, e);
315
+ }
316
+ });
317
+ });
318
+
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.)
321
+ this.addReaction({
322
+ when: () => XH.appIsRunning,
323
+ run: () =>
324
+ XH.trackService.addClientHealthReportSource(
325
+ 'msalClient',
326
+ () => this.telemetryResults
327
+ )
328
+ });
329
+
330
+ this.logInfo('Telemetry enabled');
331
+ }
332
+
333
+ disableTelemetry(): void {
334
+ if (!this._telemetryCbHandle) {
335
+ this.logInfo('Telemetry already disabled');
336
+ return;
337
+ }
338
+
339
+ this.client.removePerformanceCallback(this._telemetryCbHandle);
340
+ this._telemetryCbHandle = null;
341
+
342
+ XH.trackService.removeClientHealthReportSource('msalClient');
343
+ this.logInfo('Telemetry disabled', this.telemetryResults);
344
+ }
345
+
250
346
  //------------------------
251
347
  // Private implementation
252
348
  //------------------------
@@ -263,29 +359,38 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
263
359
  }
264
360
 
265
361
  private async createClientAsync(): Promise<IPublicClientApplication> {
266
- const {clientId, authority, msalLogLevel, msalClientOptions} = this.config;
362
+ const {clientId, authority, msalLogLevel, msalClientOptions, enableTelemetry} = this.config;
267
363
  throwIf(!authority, 'Missing MSAL authority. Please review your configuration.');
268
364
 
269
- return msal.PublicClientApplication.createPublicClientApplication(
270
- mergeDeep(
271
- {
272
- auth: {
273
- clientId,
274
- authority,
275
- postLogoutRedirectUri: this.postLogoutRedirectUrl
276
- },
277
- system: {
278
- loggerOptions: {
279
- loggerCallback: this.logFromMsal,
280
- logLevel: msalLogLevel
281
- }
282
- },
283
- cache: {
284
- cacheLocation: 'localStorage' // allows sharing auth info across tabs.
365
+ const mergedConf: Configuration = mergeDeep(
366
+ {
367
+ auth: {
368
+ clientId,
369
+ authority,
370
+ postLogoutRedirectUri: this.postLogoutRedirectUrl
371
+ },
372
+ system: {
373
+ loggerOptions: {
374
+ loggerCallback: this.logFromMsal,
375
+ logLevel: msalLogLevel
285
376
  }
286
377
  },
287
- msalClientOptions
288
- )
378
+ cache: {
379
+ cacheLocation: 'localStorage' // allows sharing auth info across tabs.
380
+ }
381
+ },
382
+ msalClientOptions
383
+ );
384
+
385
+ return msal.PublicClientApplication.createPublicClientApplication(
386
+ enableTelemetry
387
+ ? {
388
+ ...mergedConf,
389
+ telemetry: {
390
+ client: new BrowserPerformanceClient(mergedConf)
391
+ }
392
+ }
393
+ : mergedConf
289
394
  );
290
395
  }
291
396
 
@@ -217,8 +217,9 @@ export class FetchService extends HoistService {
217
217
  //-----------------------
218
218
  private async fetchInternalAsync(opts: FetchOptions): Promise<any> {
219
219
  opts = this.withCorrelationId(opts);
220
- opts = await this.withDefaultHeadersAsync(opts);
221
- let ret = this.managedFetchAsync(opts);
220
+
221
+ // Core Promise - chained with custom headers callback to ensure that work is included in overall tracked time.
222
+ let ret = this.withDefaultHeadersAsync(opts).then(opts => this.managedFetchAsync(opts));
222
223
 
223
224
  // Apply tracking
224
225
  const {correlationId, loadSpec, track} = opts;
@@ -5,10 +5,11 @@
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {HoistService, PlainObject, TrackOptions, XH} from '@xh/hoist/core';
8
- import {SECONDS} from '@xh/hoist/utils/datetime';
8
+ import {Timer} from '@xh/hoist/utils/async';
9
+ import {MINUTES, SECONDS} from '@xh/hoist/utils/datetime';
9
10
  import {isOmitted} from '@xh/hoist/utils/impl';
10
- import {debounced, stripTags, withDefault} from '@xh/hoist/utils/js';
11
- import {isEmpty, isNil, isString} from 'lodash';
11
+ import {debounced, getClientDeviceInfo, stripTags, withDefault} from '@xh/hoist/utils/js';
12
+ import {isEmpty, isNil, isString, round} from 'lodash';
12
13
 
13
14
  /**
14
15
  * Primary service for tracking any activity that an application's admins want to track.
@@ -18,23 +19,38 @@ import {isEmpty, isNil, isString} from 'lodash';
18
19
  export class TrackService extends HoistService {
19
20
  static instance: TrackService;
20
21
 
22
+ private clientHealthReportSources: Map<string, () => any> = new Map();
21
23
  private oncePerSessionSent = new Map();
22
24
  private pending: PlainObject[] = [];
23
25
 
24
26
  override async initAsync() {
27
+ const {clientHealthReport} = this.conf;
28
+ if (clientHealthReport?.intervalMins > 0) {
29
+ Timer.create({
30
+ runFn: () => this.sendClientHealthReport(),
31
+ interval: clientHealthReport.intervalMins,
32
+ intervalUnits: MINUTES,
33
+ delay: true
34
+ });
35
+ }
36
+
25
37
  window.addEventListener('beforeunload', () => this.pushPendingAsync());
26
38
  }
27
39
 
28
- get conf() {
29
- return XH.getConf('xhActivityTrackingConfig', {
40
+ get conf(): ActivityTrackingConfig {
41
+ const appConfig = XH.getConf('xhActivityTrackingConfig', {});
42
+ return {
43
+ clientHealthReport: {intervalMins: -1},
30
44
  enabled: true,
45
+ logData: false,
31
46
  maxDataLength: 2000,
32
47
  maxRows: {
33
48
  default: 10000,
34
49
  options: [1000, 5000, 10000, 25000]
35
50
  },
36
- logData: false
37
- });
51
+ levels: [{username: '*', category: '*', severity: 'INFO'}],
52
+ ...appConfig
53
+ };
38
54
  }
39
55
 
40
56
  get enabled(): boolean {
@@ -79,13 +95,29 @@ export class TrackService extends HoistService {
79
95
  sent.set(key, true);
80
96
  }
81
97
 
82
- // Otherwise - log and for next batch,
98
+ // Otherwise - log and queue to send with next debounced push to server.
83
99
  this.logMessage(options);
84
100
 
85
101
  this.pending.push(this.toServerJson(options));
86
102
  this.pushPendingBuffered();
87
103
  }
88
104
 
105
+ /**
106
+ * Register a new source for client health report data. No-op if background health report is
107
+ * not generally enabled via `xhActivityTrackingConfig.clientHealthReport.intervalMins`.
108
+ *
109
+ * @param key - key under which to report the data - can be used to remove this source later.
110
+ * @param callback - function returning serializable to include with each report.
111
+ */
112
+ addClientHealthReportSource(key: string, callback: () => any) {
113
+ this.clientHealthReportSources.set(key, callback);
114
+ }
115
+
116
+ /** Unregister a previously-enabled source for client health report data. */
117
+ removeClientHealthReportSource(key: string) {
118
+ this.clientHealthReportSources.delete(key);
119
+ }
120
+
89
121
  //------------------
90
122
  // Implementation
91
123
  //------------------
@@ -144,4 +176,58 @@ export class TrackService extends HoistService {
144
176
 
145
177
  this.logInfo(...consoleMsgs);
146
178
  }
179
+
180
+ private sendClientHealthReport() {
181
+ const {
182
+ intervalMins,
183
+ severity: defaultSeverity,
184
+ ...rest
185
+ } = this.conf.clientHealthReport ?? {},
186
+ {loadStarted} = XH.appContainerModel.appStateModel;
187
+
188
+ const data = {
189
+ session: {
190
+ started: loadStarted,
191
+ durationMins: round((Date.now() - loadStarted) / 60_000, 1)
192
+ },
193
+ ...getClientDeviceInfo()
194
+ };
195
+
196
+ let severity = defaultSeverity ?? 'INFO';
197
+ this.clientHealthReportSources.forEach((cb, k) => {
198
+ try {
199
+ data[k] = cb();
200
+ if (data[k]?.severity === 'WARN') severity = 'WARN';
201
+ } catch (e) {
202
+ data[k] = `Error: ${e.message}`;
203
+ this.logWarn(`Error running client health report callback for [${k}]`, e);
204
+ }
205
+ });
206
+
207
+ this.track({
208
+ category: 'App',
209
+ message: 'Submitted health report',
210
+ severity,
211
+ ...rest,
212
+ data
213
+ });
214
+ }
215
+ }
216
+
217
+ interface ActivityTrackingConfig {
218
+ clientHealthReport?: Partial<TrackOptions> & {
219
+ intervalMins: number;
220
+ };
221
+ enabled: boolean;
222
+ logData: boolean;
223
+ maxDataLength: number;
224
+ maxRows?: {
225
+ default: number;
226
+ options: number[];
227
+ };
228
+ levels?: Array<{
229
+ username: string | '*';
230
+ category: string | '*';
231
+ severity: 'DEBUG' | 'INFO' | 'WARN';
232
+ }>;
147
233
  }
@@ -282,8 +282,7 @@ export class WebSocketService extends HoistService {
282
282
 
283
283
  buildWebSocketUrl() {
284
284
  const protocol = window.location.protocol == 'https:' ? 'wss:' : 'ws:',
285
- endpoint = 'xhWebSocket';
286
-
285
+ endpoint = 'xhWebSocket?clientAppVersion=' + XH.appVersion;
287
286
  return XH.isDevelopmentMode
288
287
  ? `${protocol}//${XH.baseUrl.split('//')[1]}${endpoint}`
289
288
  : `${protocol}//${window.location.host}${XH.baseUrl}${endpoint}`;