@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
@@ -5,11 +5,10 @@
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {HoistService, PlainObject, TrackOptions, XH} from '@xh/hoist/core';
8
- import {Timer} from '@xh/hoist/utils/async';
9
- import {MINUTES, SECONDS} from '@xh/hoist/utils/datetime';
8
+ import {SECONDS} from '@xh/hoist/utils/datetime';
10
9
  import {isOmitted} from '@xh/hoist/utils/impl';
11
- import {debounced, getClientDeviceInfo, stripTags, withDefault} from '@xh/hoist/utils/js';
12
- import {isEmpty, isNil, isString, round} from 'lodash';
10
+ import {debounced, stripTags, withDefault} from '@xh/hoist/utils/js';
11
+ import {isEmpty, isNil, isString} from 'lodash';
13
12
 
14
13
  /**
15
14
  * Primary service for tracking any activity that an application's admins want to track.
@@ -19,21 +18,10 @@ import {isEmpty, isNil, isString, round} from 'lodash';
19
18
  export class TrackService extends HoistService {
20
19
  static instance: TrackService;
21
20
 
22
- private clientHealthReportSources: Map<string, () => any> = new Map();
23
21
  private oncePerSessionSent = new Map();
24
22
  private pending: PlainObject[] = [];
25
23
 
26
24
  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
-
37
25
  window.addEventListener('beforeunload', () => this.pushPendingAsync());
38
26
  }
39
27
 
@@ -103,25 +91,10 @@ export class TrackService extends HoistService {
103
91
  }
104
92
 
105
93
  /**
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.
94
+ * Flush the queue of pending activity tracking messages to the server.
95
+ * @internal - apps should generally allow this service to manage w/its internal debounce.
111
96
  */
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
-
121
- //------------------
122
- // Implementation
123
- //------------------
124
- private async pushPendingAsync() {
97
+ async pushPendingAsync() {
125
98
  const {pending} = this;
126
99
  if (isEmpty(pending)) return;
127
100
 
@@ -133,6 +106,9 @@ export class TrackService extends HoistService {
133
106
  });
134
107
  }
135
108
 
109
+ //------------------
110
+ // Implementation
111
+ //------------------
136
112
  @debounced(10 * SECONDS)
137
113
  private pushPendingBuffered() {
138
114
  this.pushPendingAsync();
@@ -176,42 +152,6 @@ export class TrackService extends HoistService {
176
152
 
177
153
  this.logInfo(...consoleMsgs);
178
154
  }
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
155
  }
216
156
 
217
157
  interface ActivityTrackingConfig {
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
- import {HoistService, XH} from '@xh/hoist/core';
8
- import {Icon} from '@xh/hoist/icon';
7
+ import {HoistService, PlainObject, XH} from '@xh/hoist/core';
8
+ import {withFormattedTimestamps} from '@xh/hoist/format';
9
9
  import {action, makeObservable, observable} from '@xh/hoist/mobx';
10
10
  import {Timer} from '@xh/hoist/utils/async';
11
11
  import {SECONDS} from '@xh/hoist/utils/datetime';
@@ -41,14 +41,17 @@ 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';
45
+ readonly METADATA_FOR_HANDSHAKE = ['appVersion', 'appBuild', 'loadId', 'tabId'];
46
+
47
+ /** True if WebSockets generally enabled - set statically in code via {@link AppSpec}. */
48
+ enabled: boolean = XH.appSpec.webSocketsEnabled;
44
49
 
45
50
  /** Unique channel assigned by server upon successful connection. */
46
- @observable
47
- channelKey: string = null;
51
+ @observable channelKey: string = null;
48
52
 
49
53
  /** Last time a message was received, including heartbeat messages. */
50
- @observable
51
- lastMessageTime: Date = null;
54
+ @observable lastMessageTime: Date = null;
52
55
 
53
56
  /** Observable flag indicating service is connected and available for use. */
54
57
  get connected(): boolean {
@@ -58,11 +61,11 @@ export class WebSocketService extends HoistService {
58
61
  /** Set to true to log all sent/received messages - very chatty. */
59
62
  logMessages: boolean = false;
60
63
 
64
+ telemetry: WebSocketTelemetry = null;
65
+
61
66
  private _timer: Timer;
62
67
  private _socket: WebSocket;
63
- private _subsByTopic = {};
64
-
65
- enabled: boolean = XH.appSpec.webSocketsEnabled;
68
+ private _subsByTopic: Record<string, WebSocketSubscription[]> = {};
66
69
 
67
70
  constructor() {
68
71
  super();
@@ -71,6 +74,7 @@ export class WebSocketService extends HoistService {
71
74
 
72
75
  override async initAsync() {
73
76
  if (!this.enabled) return;
77
+
74
78
  const {environmentService} = XH;
75
79
  if (environmentService.get('webSocketsEnabled') === false) {
76
80
  this.logError(
@@ -79,6 +83,7 @@ export class WebSocketService extends HoistService {
79
83
  this.enabled = false;
80
84
  return;
81
85
  }
86
+ this.telemetry = {channelKey: null, subscriptionCount: 0, events: {}};
82
87
 
83
88
  this.connect();
84
89
 
@@ -111,6 +116,7 @@ export class WebSocketService extends HoistService {
111
116
 
112
117
  const newSub = new WebSocketSubscription(topic, fn);
113
118
  subs.push(newSub);
119
+ this.telemetry.subscriptionCount++;
114
120
  return newSub;
115
121
  }
116
122
 
@@ -122,6 +128,7 @@ export class WebSocketService extends HoistService {
122
128
  unsubscribe(subscription: WebSocketSubscription) {
123
129
  const subs = this.getSubsForTopic(subscription.topic);
124
130
  pull(subs, subscription);
131
+ this.telemetry.subscriptionCount--;
125
132
  }
126
133
 
127
134
  /**
@@ -132,13 +139,24 @@ export class WebSocketService extends HoistService {
132
139
  throwIf(!this.connected, 'Unable to send message via websocket - not connected.');
133
140
 
134
141
  this._socket.send(JSON.stringify(message));
142
+
143
+ this.noteTelemetryEvent('msgSent');
135
144
  this.maybeLogMessage('Sent message', message);
136
145
  }
137
146
 
147
+ shutdown() {
148
+ if (this._timer) this._timer.cancel();
149
+ this.disconnect();
150
+ }
151
+
152
+ getFormattedTelemetry(): PlainObject {
153
+ return withFormattedTimestamps(this.telemetry);
154
+ }
155
+
138
156
  //------------------------
139
157
  // Implementation
140
158
  //------------------------
141
- connect() {
159
+ private connect() {
142
160
  try {
143
161
  // Create new socket and wire up events. Be sure to ignore obsolete sockets
144
162
  const s = new WebSocket(this.buildWebSocketUrl());
@@ -162,7 +180,7 @@ export class WebSocketService extends HoistService {
162
180
  this.updateConnectedStatus();
163
181
  }
164
182
 
165
- disconnect() {
183
+ private disconnect() {
166
184
  if (this._socket) {
167
185
  this._socket.close();
168
186
  this._socket = null;
@@ -170,12 +188,13 @@ export class WebSocketService extends HoistService {
170
188
  this.updateConnectedStatus();
171
189
  }
172
190
 
173
- heartbeatOrReconnect() {
191
+ private heartbeatOrReconnect() {
174
192
  this.updateConnectedStatus();
175
193
  if (this.connected) {
176
194
  this.sendMessage({topic: this.HEARTBEAT_TOPIC, data: 'ping'});
177
195
  } else {
178
196
  this.logWarn('Heartbeat found websocket not connected - attempting to reconnect...');
197
+ this.noteTelemetryEvent('heartbeatReconnectAttempt');
179
198
  this.disconnect();
180
199
  this.connect();
181
200
  }
@@ -183,34 +202,33 @@ export class WebSocketService extends HoistService {
183
202
 
184
203
  private onServerInstanceChange() {
185
204
  this.logWarn('Server instance changed - attempting to connect to new instance.');
205
+ this.noteTelemetryEvent('instanceChangeReconnectAttempt');
186
206
  this.disconnect();
187
207
  this.connect();
188
208
  }
189
209
 
190
- shutdown() {
191
- if (this._timer) this._timer.cancel();
192
- this.disconnect();
193
- }
194
-
195
210
  //------------------------
196
211
  // Socket events impl
197
212
  //------------------------
198
213
  onOpen(ev) {
199
214
  this.logDebug('WebSocket connection opened', ev);
215
+ this.noteTelemetryEvent('connOpened');
200
216
  this.updateConnectedStatus();
201
217
  }
202
218
 
203
219
  onClose(ev) {
204
220
  this.logDebug('WebSocket connection closed', ev);
221
+ this.noteTelemetryEvent('connClosed');
205
222
  this.updateConnectedStatus();
206
223
  }
207
224
 
208
225
  onError(ev) {
209
226
  this.logError('WebSocket connection error', ev);
227
+ this.noteTelemetryEvent('connError');
210
228
  this.updateConnectedStatus();
211
229
  }
212
230
 
213
- onMessage(rawMsg) {
231
+ onMessage(rawMsg: MessageEvent) {
214
232
  try {
215
233
  const msg = JSON.parse(rawMsg.data),
216
234
  {topic, data} = msg;
@@ -218,6 +236,7 @@ export class WebSocketService extends HoistService {
218
236
  // Record arrival
219
237
  this.updateLastMessageTime();
220
238
  this.maybeLogMessage('Received message', rawMsg);
239
+ this.noteTelemetryEvent('msgReceived');
221
240
 
222
241
  // Hoist and app handling
223
242
  switch (topic) {
@@ -228,6 +247,9 @@ export class WebSocketService extends HoistService {
228
247
  XH.suspendApp({reason: 'SERVER_FORCE', message: data});
229
248
  XH.track({category: 'App', message: 'App suspended via WebSocket'});
230
249
  break;
250
+ case this.REQ_CLIENT_HEALTH_RPT_TOPIC:
251
+ XH.clientHealthService.sendReportAsync();
252
+ break;
231
253
  }
232
254
 
233
255
  this.notifySubscribers(msg);
@@ -240,7 +262,7 @@ export class WebSocketService extends HoistService {
240
262
  //------------------------
241
263
  // Subscription impl
242
264
  //------------------------
243
- notifySubscribers(message) {
265
+ private notifySubscribers(message) {
244
266
  const subs = this.getSubsForTopic(message.topic);
245
267
 
246
268
  subs.forEach(sub => {
@@ -252,7 +274,7 @@ export class WebSocketService extends HoistService {
252
274
  });
253
275
  }
254
276
 
255
- getSubsForTopic(topic): WebSocketSubscription[] {
277
+ private getSubsForTopic(topic: string): WebSocketSubscription[] {
256
278
  let ret = this._subsByTopic[topic];
257
279
  if (!ret) {
258
280
  ret = this._subsByTopic[topic] = [];
@@ -263,7 +285,7 @@ export class WebSocketService extends HoistService {
263
285
  //------------------------
264
286
  // Other impl
265
287
  //------------------------
266
- updateConnectedStatus() {
288
+ private updateConnectedStatus() {
267
289
  const socketOpen = this._socket?.readyState === WebSocket.OPEN;
268
290
  if (!socketOpen && this.channelKey) {
269
291
  this.installChannelKey(null);
@@ -271,33 +293,32 @@ export class WebSocketService extends HoistService {
271
293
  }
272
294
 
273
295
  @action
274
- installChannelKey(key) {
296
+ private installChannelKey(key: string) {
275
297
  this.channelKey = key;
298
+ this.telemetry.channelKey = key;
276
299
  }
277
300
 
278
301
  @action
279
- updateLastMessageTime() {
302
+ private updateLastMessageTime() {
280
303
  this.lastMessageTime = new Date();
281
304
  }
282
305
 
283
- buildWebSocketUrl() {
306
+ private buildWebSocketUrl() {
284
307
  const protocol = window.location.protocol == 'https:' ? 'wss:' : 'ws:',
285
- endpoint = 'xhWebSocket?clientAppVersion=' + XH.appVersion;
308
+ endpoint = `xhWebSocket?${this.METADATA_FOR_HANDSHAKE.map(key => `${key}=${XH[key]}`).join('&')}`;
286
309
  return XH.isDevelopmentMode
287
310
  ? `${protocol}//${XH.baseUrl.split('//')[1]}${endpoint}`
288
311
  : `${protocol}//${window.location.host}${XH.baseUrl}${endpoint}`;
289
312
  }
290
313
 
291
- showTestMessageAlert(message) {
292
- XH.alert({
293
- title: 'Test Message',
294
- icon: Icon.bullhorn(),
295
- message
296
- });
314
+ private maybeLogMessage(...args) {
315
+ if (this.logMessages) this.logDebug(args);
297
316
  }
298
317
 
299
- maybeLogMessage(...args) {
300
- if (this.logMessages) this.logDebug(args);
318
+ private noteTelemetryEvent(eventKey: keyof WebSocketTelemetry['events']) {
319
+ const evtTel = (this.telemetry.events[eventKey] ??= {count: 0, lastTime: null});
320
+ evtTel.count++;
321
+ evtTel.lastTime = Date.now();
301
322
  }
302
323
  }
303
324
 
@@ -323,3 +344,23 @@ export interface WebSocketMessage {
323
344
  topic: string;
324
345
  data?: any;
325
346
  }
347
+
348
+ /** Telemetry collected by this service + included in {@link ClientHealthService} reporting. */
349
+ export interface WebSocketTelemetry {
350
+ channelKey: string;
351
+ subscriptionCount: number;
352
+ events: {
353
+ connOpened?: WebSocketEventTelemetry;
354
+ connClosed?: WebSocketEventTelemetry;
355
+ connError?: WebSocketEventTelemetry;
356
+ msgReceived?: WebSocketEventTelemetry;
357
+ msgSent?: WebSocketEventTelemetry;
358
+ heartbeatReconnectAttempt?: WebSocketEventTelemetry;
359
+ instanceChangeReconnectAttempt?: WebSocketEventTelemetry;
360
+ };
361
+ }
362
+
363
+ export interface WebSocketEventTelemetry {
364
+ count: number;
365
+ lastTime: number;
366
+ }
package/svc/index.ts CHANGED
@@ -19,5 +19,6 @@ export * from './JsonBlobService';
19
19
  export * from './PrefService';
20
20
  export * from './TrackService';
21
21
  export * from './WebSocketService';
22
+ export * from './ClientHealthService';
22
23
  export * from './storage/LocalStorageService';
23
24
  export * from './storage/SessionStorageService';