@xh/hoist 73.0.0-SNAPSHOT.1744281639439 → 73.0.0-SNAPSHOT.1744315927263

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.
package/CHANGELOG.md CHANGED
@@ -2,15 +2,22 @@
2
2
 
3
3
  ## v73.0.0-SNAPSHOT - unreleased
4
4
 
5
+ ### 🎁 New Features
6
+
7
+ * Added option from the Admin Console > Websockets tab to request a client health report from any
8
+ connected clients.
9
+
5
10
  ## v72.4.0 - 2025-04-09
6
11
 
7
12
  ### 🎁 New Features
8
- * New methods for formatting timestamps within nested JSON objects. See `withFormattedTimestamps`
13
+
14
+ * Added new methods for formatting timestamps within JSON objects. See `withFormattedTimestamps`
9
15
  and `timestampReplacer` in the `@xh/hoist/format` package.
10
- * `ViewManagerConfig` takes new optional key `viewMenuItemFn` to allow ViewManager implementations
11
- to customize the menu items for views in the view manager menu.
16
+ * Added new `ViewManagerConfig.viewMenuItemFn` option to support custom rendering of pinned views in
17
+ the drop-down menu.
12
18
 
13
19
  ### ⚙️ Technical
20
+
14
21
  * Added dedicated `ClientHealthService` for managing client health report. Additional enhancements
15
22
  to health report to include information about web sockets, idle time, and page state.
16
23
 
@@ -30,11 +37,6 @@
30
37
 
31
38
  * Improved fetch request tracking to include time spent loading headers as specified by application.
32
39
 
33
- ### ⚙️ Technical
34
-
35
- * Update shape of returned `BrowserUtils.getClientDeviceInfo()` to nest several properties under new
36
- top-level `window` key and report JS heap size / usage values under the `memory` block in MB.
37
-
38
40
  ### 📚 Libraries
39
41
 
40
42
  * @azure/msal-browser `3.28 → 4.8.0`
@@ -5,20 +5,21 @@
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {exportFilenameWithDate} from '@xh/hoist/admin/AdminUtils';
8
+ import {AppModel} from '@xh/hoist/admin/AppModel';
8
9
  import * as Col from '@xh/hoist/admin/columns';
9
10
  import {BaseInstanceModel} from '@xh/hoist/admin/tabs/cluster/instances/BaseInstanceModel';
10
11
  import {GridModel} from '@xh/hoist/cmp/grid';
11
12
  import {div, p} from '@xh/hoist/cmp/layout';
12
13
  import {LoadSpec, managed, XH} from '@xh/hoist/core';
14
+ import {RecordActionSpec, StoreRecord} from '@xh/hoist/data';
13
15
  import {textInput} from '@xh/hoist/desktop/cmp/input';
14
16
  import {Icon} from '@xh/hoist/icon';
15
17
  import {makeObservable, observable, runInAction} from '@xh/hoist/mobx';
16
18
  import {Timer} from '@xh/hoist/utils/async';
17
19
  import {SECONDS} from '@xh/hoist/utils/datetime';
20
+ import {pluralize} from '@xh/hoist/utils/js';
18
21
  import {isEmpty} from 'lodash';
19
22
  import * as WSCol from './WebSocketColumns';
20
- import {RecordActionSpec} from '@xh/hoist/data';
21
- import {AppModel} from '@xh/hoist/admin/AppModel';
22
23
 
23
24
  export class WebSocketModel extends BaseInstanceModel {
24
25
  @observable
@@ -34,11 +35,19 @@ export class WebSocketModel extends BaseInstanceModel {
34
35
  text: 'Force suspend',
35
36
  icon: Icon.stopCircle(),
36
37
  intent: 'danger',
37
- actionFn: () => this.forceSuspendAsync(),
38
+ actionFn: ({selectedRecords}) => this.forceSuspendAsync(selectedRecords),
38
39
  displayFn: () => ({hidden: AppModel.readonly}),
39
40
  recordsRequired: true
40
41
  };
41
42
 
43
+ reqHealthReportAction: RecordActionSpec = {
44
+ text: 'Request Health Report',
45
+ icon: Icon.health(),
46
+ actionFn: ({selectedRecords}) => this.requestHealthReportAsync(selectedRecords),
47
+ recordsRequired: true,
48
+ hidden: !XH.trackService.enabled
49
+ };
50
+
42
51
  constructor() {
43
52
  super();
44
53
  makeObservable(this);
@@ -48,7 +57,12 @@ export class WebSocketModel extends BaseInstanceModel {
48
57
  enableExport: true,
49
58
  exportOptions: {filename: exportFilenameWithDate('ws-connections')},
50
59
  selModel: 'multiple',
51
- contextMenu: [this.forceSuspendAction, '-', ...GridModel.defaultContextMenu],
60
+ contextMenu: [
61
+ this.forceSuspendAction,
62
+ this.reqHealthReportAction,
63
+ '-',
64
+ ...GridModel.defaultContextMenu
65
+ ],
52
66
  store: {
53
67
  idSpec: 'key',
54
68
  processRawData: row => {
@@ -107,9 +121,8 @@ export class WebSocketModel extends BaseInstanceModel {
107
121
  }
108
122
  }
109
123
 
110
- async forceSuspendAsync() {
111
- const {selectedRecords} = this.gridModel;
112
- if (isEmpty(selectedRecords)) return;
124
+ async forceSuspendAsync(toRecs: StoreRecord[]) {
125
+ if (isEmpty(toRecs)) return;
113
126
 
114
127
  const message = await XH.prompt<string>({
115
128
  title: 'Please confirm...',
@@ -123,7 +136,7 @@ export class WebSocketModel extends BaseInstanceModel {
123
136
  },
124
137
  message: div(
125
138
  p(
126
- `This action will force ${selectedRecords.length} connected client(s) into suspended mode, halting all background refreshes and other activity, masking the UI, and requiring users to reload the app to continue.`
139
+ `This action will force ${toRecs.length} connected client(s) into suspended mode, halting all background refreshes and other activity, masking the UI, and requiring users to reload the app to continue.`
127
140
  ),
128
141
  p('Enter an optional message below to display within the suspended app.')
129
142
  ),
@@ -134,23 +147,58 @@ export class WebSocketModel extends BaseInstanceModel {
134
147
  });
135
148
 
136
149
  if (message !== false) {
137
- const tasks = selectedRecords.map(rec =>
138
- XH.fetchJson({
139
- url: 'webSocketAdmin/pushToChannel',
140
- params: {
141
- channelKey: rec.data.key,
142
- topic: XH.webSocketService.FORCE_APP_SUSPEND_TOPIC,
143
- instance: this.instanceName,
144
- message
145
- }
146
- })
147
- );
148
-
149
- await Promise.allSettled(tasks).track({
150
- category: 'Audit',
151
- message: 'Suspended clients via WebSocket',
152
- data: {users: selectedRecords.map(it => it.data.user).sort()}
150
+ await this.bulkPush({
151
+ toRecs,
152
+ topic: XH.webSocketService.FORCE_APP_SUSPEND_TOPIC,
153
+ message,
154
+ trackMessage: 'Suspended clients via WebSocket'
153
155
  });
154
156
  }
155
157
  }
158
+
159
+ async requestHealthReportAsync(toRecs: StoreRecord[]) {
160
+ await this.bulkPush({
161
+ toRecs,
162
+ topic: XH.webSocketService.REQ_CLIENT_HEALTH_RPT_TOPIC
163
+ });
164
+ XH.successToast(
165
+ `Client health report requested for ${pluralize('client', toRecs.length, true)} - available in User Activity shortly...`
166
+ );
167
+ }
168
+
169
+ //------------------
170
+ // Implementation
171
+ //------------------
172
+ private async bulkPush({
173
+ toRecs,
174
+ topic,
175
+ message,
176
+ trackMessage
177
+ }: {
178
+ toRecs?: StoreRecord[];
179
+ topic: string;
180
+ message?: string;
181
+ trackMessage?: string;
182
+ }) {
183
+ if (isEmpty(toRecs)) return;
184
+
185
+ const tasks = toRecs.map(rec =>
186
+ XH.fetchJson({
187
+ url: 'webSocketAdmin/pushToChannel',
188
+ params: {
189
+ channelKey: rec.data.key,
190
+ instance: this.instanceName,
191
+ topic,
192
+ message
193
+ }
194
+ })
195
+ );
196
+
197
+ await Promise.allSettled(tasks).track({
198
+ category: 'Audit',
199
+ message: trackMessage,
200
+ data: {users: toRecs.map(it => it.data.user).sort()},
201
+ omit: !trackMessage
202
+ });
203
+ }
156
204
  }
@@ -16,7 +16,7 @@ import {panel} from '@xh/hoist/desktop/cmp/panel';
16
16
  import {recordActionBar} from '@xh/hoist/desktop/cmp/record';
17
17
  import {toolbarSep} from '@xh/hoist/desktop/cmp/toolbar';
18
18
 
19
- export const webSocketPanel = hoistCmp.factory({
19
+ export const webSocketPanel = hoistCmp.factory<WebSocketModel>({
20
20
  model: creates(WebSocketModel),
21
21
 
22
22
  render({model}) {
@@ -26,7 +26,7 @@ export const webSocketPanel = hoistCmp.factory({
26
26
  bbar: [
27
27
  recordActionBar({
28
28
  selModel: model.gridModel.selModel,
29
- actions: [model.forceSuspendAction]
29
+ actions: [model.forceSuspendAction, model.reqHealthReportAction]
30
30
  }),
31
31
  filler(),
32
32
  relativeTimestamp({bind: 'lastRefresh', options: {prefix: 'Refreshed'}}),
@@ -1,13 +1,16 @@
1
1
  import { BaseInstanceModel } from '@xh/hoist/admin/tabs/cluster/instances/BaseInstanceModel';
2
2
  import { GridModel } from '@xh/hoist/cmp/grid';
3
3
  import { LoadSpec } from '@xh/hoist/core';
4
- import { RecordActionSpec } from '@xh/hoist/data';
4
+ import { RecordActionSpec, StoreRecord } from '@xh/hoist/data';
5
5
  export declare class WebSocketModel extends BaseInstanceModel {
6
6
  lastRefresh: number;
7
7
  gridModel: GridModel;
8
8
  private _timer;
9
9
  forceSuspendAction: RecordActionSpec;
10
+ reqHealthReportAction: RecordActionSpec;
10
11
  constructor();
11
12
  doLoadAsync(loadSpec: LoadSpec): Promise<void>;
12
- forceSuspendAsync(): Promise<void>;
13
+ forceSuspendAsync(toRecs: StoreRecord[]): Promise<void>;
14
+ requestHealthReportAsync(toRecs: StoreRecord[]): Promise<void>;
15
+ private bulkPush;
13
16
  }
@@ -14,7 +14,14 @@ export interface AccessTokenSpec {
14
14
  export type TokenMap = Record<string, Token>;
15
15
  /** Aggregated telemetry results, produced by {@link MsalClient} when enabled via config. */
16
16
  export interface TelemetryResults {
17
- /** Stats by event type - */
17
+ /** Stats across all events */
18
+ summary: {
19
+ successCount: number;
20
+ failureCount: number;
21
+ maxDuration: number;
22
+ lastFailureTime: number;
23
+ };
24
+ /** Stats by event type */
18
25
  events: Record<string, TelemetryEventResults>;
19
26
  }
20
27
  /** Aggregated telemetry results for a single type of event. */
@@ -24,11 +31,11 @@ export interface TelemetryEventResults {
24
31
  successCount: number;
25
32
  failureCount: number;
26
33
  /** Timing info (in ms) for event instances reported with duration. */
27
- duration: {
34
+ duration?: {
28
35
  count: number;
29
36
  total: number;
30
37
  average: number;
31
- worst: number;
38
+ max: number;
32
39
  };
33
40
  lastFailure?: {
34
41
  time: number;
@@ -27,7 +27,7 @@ export declare class ClientHealthService extends HoistService {
27
27
  /**
28
28
  * Generate and submit a report to the server, via TrackService.
29
29
  *
30
- * For ad-hoc troubleshooting. Apps may also configure this service to
30
+ * For ad-hoc troubleshooting. Apps may also configure this service to
31
31
  * submit on regular intervals.
32
32
  */
33
33
  sendReportAsync(): Promise<void>;
@@ -27,6 +27,7 @@ export declare class WebSocketService extends HoistService {
27
27
  readonly HEARTBEAT_TOPIC = "xhHeartbeat";
28
28
  readonly REG_SUCCESS_TOPIC = "xhRegistrationSuccess";
29
29
  readonly FORCE_APP_SUSPEND_TOPIC = "xhForceAppSuspend";
30
+ readonly REQ_CLIENT_HEALTH_RPT_TOPIC = "xhRequestClientHealthReport";
30
31
  /** Unique channel assigned by server upon successful connection. */
31
32
  channelKey: string;
32
33
  /** Last time a message was received, including heartbeat messages. */
@@ -69,7 +70,7 @@ export declare class WebSocketService extends HoistService {
69
70
  onOpen(ev: any): void;
70
71
  onClose(ev: any): void;
71
72
  onError(ev: any): void;
72
- onMessage(rawMsg: any): void;
73
+ onMessage(rawMsg: MessageEvent): void;
73
74
  notifySubscribers(message: any): void;
74
75
  getSubsForTopic(topic: any): WebSocketSubscription[];
75
76
  updateConnectedStatus(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "73.0.0-SNAPSHOT.1744281639439",
3
+ "version": "73.0.0-SNAPSHOT.1744315927263",
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
@@ -25,7 +25,14 @@ export type TokenMap = Record<string, Token>;
25
25
 
26
26
  /** Aggregated telemetry results, produced by {@link MsalClient} when enabled via config. */
27
27
  export interface TelemetryResults {
28
- /** Stats by event type - */
28
+ /** Stats across all events */
29
+ summary: {
30
+ successCount: number;
31
+ failureCount: number;
32
+ maxDuration: number;
33
+ lastFailureTime: number;
34
+ };
35
+ /** Stats by event type */
29
36
  events: Record<string, TelemetryEventResults>;
30
37
  }
31
38
 
@@ -36,11 +43,11 @@ export interface TelemetryEventResults {
36
43
  successCount: number;
37
44
  failureCount: number;
38
45
  /** Timing info (in ms) for event instances reported with duration. */
39
- duration: {
46
+ duration?: {
40
47
  count: number;
41
48
  total: number;
42
49
  average: number;
43
- worst: number;
50
+ max: number;
44
51
  };
45
52
  lastFailure?: {
46
53
  time: number;
@@ -115,7 +115,7 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
115
115
  private initialTokenLoad: boolean;
116
116
 
117
117
  /** Enable telemetry via `enableTelemetry` ctor config, or via {@link enableTelemetry}. */
118
- telemetryResults: TelemetryResults = {events: {}};
118
+ telemetryResults: TelemetryResults = null;
119
119
  private _telemetryCbHandle: string = null;
120
120
 
121
121
  constructor(config: MsalClientConfig) {
@@ -277,12 +277,20 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
277
277
  return;
278
278
  }
279
279
 
280
- this.telemetryResults = {events: {}};
280
+ this.telemetryResults = {
281
+ summary: {
282
+ successCount: 0,
283
+ failureCount: 0,
284
+ maxDuration: 0,
285
+ lastFailureTime: null
286
+ },
287
+ events: {}
288
+ };
281
289
 
282
290
  this._telemetryCbHandle = this.client.addPerformanceCallback(events => {
283
291
  events.forEach(e => {
284
292
  try {
285
- const {events} = this.telemetryResults,
293
+ const {summary, events} = this.telemetryResults,
286
294
  {name, startTimeMs, durationMs, success, errorName, errorCode} = e,
287
295
  eTime = startTimeMs ?? Date.now();
288
296
 
@@ -290,15 +298,16 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
290
298
  firstTime: eTime,
291
299
  lastTime: eTime,
292
300
  successCount: 0,
293
- failureCount: 0,
294
- duration: {count: 0, total: 0, average: 0, worst: 0},
295
- lastFailure: null
301
+ failureCount: 0
296
302
  });
297
303
  eResult.lastTime = eTime;
298
304
 
299
305
  if (success) {
306
+ summary.successCount++;
300
307
  eResult.successCount++;
301
308
  } else {
309
+ summary.failureCount++;
310
+ summary.lastFailureTime = eTime;
302
311
  eResult.failureCount++;
303
312
  eResult.lastFailure = {
304
313
  time: eTime,
@@ -309,11 +318,17 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
309
318
  }
310
319
 
311
320
  if (durationMs) {
312
- const {duration} = eResult;
321
+ const duration = (eResult.duration ??= {
322
+ count: 0,
323
+ total: 0,
324
+ average: 0,
325
+ max: 0
326
+ });
313
327
  duration.count++;
314
328
  duration.total += durationMs;
315
329
  duration.average = Math.round(duration.total / duration.count);
316
- duration.worst = Math.max(duration.worst, durationMs);
330
+ duration.max = Math.max(duration.max, durationMs);
331
+ summary.maxDuration = Math.max(summary.maxDuration, durationMs);
317
332
  }
318
333
  } catch (e) {
319
334
  this.logError(`Error processing telemetry event`, e);
@@ -68,7 +68,7 @@ export class ClientHealthService extends HoistService {
68
68
  /**
69
69
  * Generate and submit a report to the server, via TrackService.
70
70
  *
71
- * For ad-hoc troubleshooting. Apps may also configure this service to
71
+ * For ad-hoc troubleshooting. Apps may also configure this service to
72
72
  * submit on regular intervals.
73
73
  */
74
74
  async sendReportAsync() {
@@ -41,6 +41,7 @@ export class WebSocketService extends HoistService {
41
41
  readonly HEARTBEAT_TOPIC = 'xhHeartbeat';
42
42
  readonly REG_SUCCESS_TOPIC = 'xhRegistrationSuccess';
43
43
  readonly FORCE_APP_SUSPEND_TOPIC = 'xhForceAppSuspend';
44
+ readonly REQ_CLIENT_HEALTH_RPT_TOPIC = 'xhRequestClientHealthReport';
44
45
 
45
46
  /** Unique channel assigned by server upon successful connection. */
46
47
  @observable
@@ -210,7 +211,7 @@ export class WebSocketService extends HoistService {
210
211
  this.updateConnectedStatus();
211
212
  }
212
213
 
213
- onMessage(rawMsg) {
214
+ onMessage(rawMsg: MessageEvent) {
214
215
  try {
215
216
  const msg = JSON.parse(rawMsg.data),
216
217
  {topic, data} = msg;
@@ -228,6 +229,9 @@ export class WebSocketService extends HoistService {
228
229
  XH.suspendApp({reason: 'SERVER_FORCE', message: data});
229
230
  XH.track({category: 'App', message: 'App suspended via WebSocket'});
230
231
  break;
232
+ case this.REQ_CLIENT_HEALTH_RPT_TOPIC:
233
+ XH.clientHealthService.sendReportAsync();
234
+ break;
231
235
  }
232
236
 
233
237
  this.notifySubscribers(msg);