@xh/hoist 73.0.0-SNAPSHOT.1744315607129 → 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
  }
@@ -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.1744315607129",
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",
@@ -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);