@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
package/CHANGELOG.md CHANGED
@@ -1,12 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## v72.5.0 - 2025-04-14
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
+ * Enabled telemetry reporting from `WebSocketService`.
10
+ * Updated `MenuItem.actionFn()` to receive the click event as an additional argument.
11
+ * Support for reporting App Build, Tab Id, and Load Id in websocket admin page.
12
+
13
+ ## v72.4.0 - 2025-04-09
14
+
15
+ ### 🎁 New Features
16
+
17
+ * Added new methods for formatting timestamps within JSON objects. See `withFormattedTimestamps`
18
+ and `timestampReplacer` in the `@xh/hoist/format` package.
19
+ * Added new `ViewManagerConfig.viewMenuItemFn` option to support custom rendering of pinned views in
20
+ the drop-down menu.
21
+
22
+ ### ⚙️ Technical
23
+
24
+ * Added dedicated `ClientHealthService` for managing client health report. Additional enhancements
25
+ to health report to include information about web sockets, idle time, and page state.
26
+
3
27
  ## v72.3.0 - 2025-04-08
4
28
 
5
29
  ### 🎁 New Features
6
30
 
7
31
  * Added support for posting a "Client Health Report" track message on a configurable interval. This
8
32
  message will include basic client information, and can be extended to include any other desired
9
- data via `XH.trackService.addClientHealthReportSource()`. Enable by updating your app's
33
+ data via `XH.clientHealthService.addSource()`. Enable by updating your app's
10
34
  `xhActivityTrackingConfig` to include `clientHealthReport: {intervalMins: XXXX}`.
11
35
  * Enabled opt-in support for telemetry in `MsalClient`, leveraging hooks built-in to MSAL to collect
12
36
  timing and success/failure count for all events emitted by the library.
@@ -16,11 +40,6 @@
16
40
 
17
41
  * Improved fetch request tracking to include time spent loading headers as specified by application.
18
42
 
19
- ### ⚙️ Technical
20
-
21
- * Update shape of returned `BrowserUtils.getClientDeviceInfo()` to nest several properties under new
22
- top-level `window` key and report JS heap size / usage values under the `memory` block in MB.
23
-
24
43
  ### 📚 Libraries
25
44
 
26
45
  * @azure/msal-browser `3.28 → 4.8.0`
@@ -5,17 +5,17 @@
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {exportFilename} from '@xh/hoist/admin/AdminUtils';
8
- import {GroupingChooserModel} from '@xh/hoist/cmp/grouping';
8
+ import * as Col from '@xh/hoist/admin/columns';
9
9
  import {FilterChooserModel} from '@xh/hoist/cmp/filter';
10
10
  import {FormModel} from '@xh/hoist/cmp/form';
11
11
  import {GridModel, TreeStyle} from '@xh/hoist/cmp/grid';
12
+ import {GroupingChooserModel} from '@xh/hoist/cmp/grouping';
12
13
  import {HoistModel, LoadSpec, managed, XH} from '@xh/hoist/core';
13
14
  import {Cube, CubeFieldSpec, FieldSpec} from '@xh/hoist/data';
14
15
  import {fmtNumber} from '@xh/hoist/format';
15
16
  import {action, computed, makeObservable} from '@xh/hoist/mobx';
16
17
  import {LocalDate} from '@xh/hoist/utils/datetime';
17
- import * as Col from '@xh/hoist/admin/columns';
18
- import {isEmpty, round} from 'lodash';
18
+ import {compact, isEmpty, round} from 'lodash';
19
19
  import moment from 'moment';
20
20
 
21
21
  export const PERSIST_ACTIVITY = {localStorageKey: 'xhAdminActivityState'};
@@ -109,14 +109,13 @@ export class ActivityTrackingModel extends HoistModel {
109
109
  ] as CubeFieldSpec[]
110
110
  });
111
111
 
112
- const enableValues = true;
113
112
  this.filterChooserModel = new FilterChooserModel({
114
113
  fieldSpecs: [
115
- {field: 'category', enableValues},
114
+ {field: 'category'},
116
115
  {field: 'correlationId'},
117
- {field: 'username', displayName: 'User', enableValues},
118
- {field: 'device', enableValues},
119
- {field: 'browser', enableValues},
116
+ {field: 'username', displayName: 'User'},
117
+ {field: 'device'},
118
+ {field: 'browser'},
120
119
  {
121
120
  field: 'elapsed',
122
121
  valueRenderer: v => {
@@ -330,12 +329,21 @@ export class ActivityTrackingModel extends HoistModel {
330
329
  }
331
330
 
332
331
  private async loadLookupsAsync() {
333
- const lookups = await XH.fetchJson({url: 'trackLogAdmin/lookups'});
334
-
335
- this.filterChooserModel.fieldSpecs.forEach(spec => {
336
- const {field} = spec;
337
- if (lookups[field]) spec.values = lookups[field];
338
- });
332
+ try {
333
+ const lookups = await XH.fetchJson({url: 'trackLogAdmin/lookups'});
334
+ this.filterChooserModel.fieldSpecs.forEach(spec => {
335
+ const {field} = spec,
336
+ lookup = lookups[field] ? compact(lookups[field]) : null;
337
+
338
+ if (!isEmpty(lookup)) {
339
+ spec.values = lookup;
340
+ spec.enableValues = true;
341
+ spec.hasExplicitValues = true;
342
+ }
343
+ });
344
+ } catch (e) {
345
+ XH.handleException(e, {title: 'Error loading lookups for filtering'});
346
+ }
339
347
  }
340
348
 
341
349
  @computed
@@ -9,9 +9,9 @@ import * as Col from '@xh/hoist/admin/columns';
9
9
  import {FormModel} from '@xh/hoist/cmp/form';
10
10
  import {GridModel} from '@xh/hoist/cmp/grid';
11
11
  import {HoistModel, lookup, managed} from '@xh/hoist/core';
12
- import {fmtJson} from '@xh/hoist/format';
13
12
  import {action, computed, makeObservable, observable} from '@xh/hoist/mobx';
14
13
  import {ActivityTrackingModel} from '../ActivityTrackingModel';
14
+ import {fmtJson, timestampReplacer} from '@xh/hoist/format';
15
15
 
16
16
  export class ActivityDetailModel extends HoistModel {
17
17
  @lookup(ActivityTrackingModel) activityTrackingModel: ActivityTrackingModel;
@@ -118,7 +118,7 @@ export class ActivityDetailModel extends HoistModel {
118
118
  let formattedTrackData = trackData;
119
119
  if (formattedTrackData) {
120
120
  try {
121
- formattedTrackData = fmtJson(trackData);
121
+ formattedTrackData = fmtJson(trackData, {replacer: timestampReplacer()});
122
122
  } catch (ignored) {}
123
123
  }
124
124
 
@@ -5,10 +5,7 @@
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {InstancesTabModel} from '@xh/hoist/admin/tabs/cluster/instances/InstancesTabModel';
8
- import {HoistModel, LoadSpec, lookup, PlainObject, XH} from '@xh/hoist/core';
9
- import {fmtDateTimeSec, fmtJson} from '@xh/hoist/format';
10
- import {DAYS} from '@xh/hoist/utils/datetime';
11
- import {cloneDeep, forOwn, isArray, isNumber, isPlainObject} from 'lodash';
8
+ import {HoistModel, LoadSpec, lookup, XH} from '@xh/hoist/core';
12
9
  import {createRef} from 'react';
13
10
  import {isDisplayed} from '@xh/hoist/utils/js';
14
11
 
@@ -21,12 +18,6 @@ export class BaseInstanceModel extends HoistModel {
21
18
  return this.parent.instanceName;
22
19
  }
23
20
 
24
- fmtStats(stats: PlainObject): string {
25
- stats = cloneDeep(stats);
26
- this.processTimestamps(stats);
27
- return fmtJson(JSON.stringify(stats));
28
- }
29
-
30
21
  handleLoadException(e: unknown, loadSpec: LoadSpec) {
31
22
  const instanceNotFound = this.isInstanceNotFound(e),
32
23
  connDown = this.parent.lastLoadException,
@@ -49,23 +40,4 @@ export class BaseInstanceModel extends HoistModel {
49
40
  private isInstanceNotFound(e: unknown): boolean {
50
41
  return e['name'] == 'InstanceNotFoundException';
51
42
  }
52
-
53
- private processTimestamps(stats: PlainObject) {
54
- forOwn(stats, (v, k) => {
55
- // Convert numbers that look like recent timestamps to date values.
56
- if (
57
- (k.endsWith('Time') ||
58
- k.endsWith('Date') ||
59
- k.endsWith('Timestamp') ||
60
- k == 'timestamp') &&
61
- isNumber(v) &&
62
- v > Date.now() - 365 * DAYS
63
- ) {
64
- stats[k] = v ? fmtDateTimeSec(v, {fmt: 'MMM DD HH:mm:ss.SSS'}) : null;
65
- }
66
- if (isPlainObject(v) || isArray(v)) {
67
- this.processTimestamps(v);
68
- }
69
- });
70
- }
71
43
  }
@@ -12,6 +12,7 @@ import {panel} from '@xh/hoist/desktop/cmp/panel';
12
12
  import {jsonInput} from '@xh/hoist/desktop/cmp/input';
13
13
  import {Icon} from '@xh/hoist/icon';
14
14
  import {isEmpty} from 'lodash';
15
+ import {fmtJson, timestampReplacer} from '@xh/hoist/format';
15
16
 
16
17
  export const detailsPanel = hoistCmp.factory({
17
18
  model: creates(DetailsModel),
@@ -57,7 +58,7 @@ const stats = hoistCmp.factory<DetailsModel>({
57
58
  enableSearch: true,
58
59
  showFullscreenButton: false,
59
60
  editorProps: {lineNumbers: false},
60
- value: model.parent.fmtStats(stats)
61
+ value: fmtJson(stats, {replacer: timestampReplacer()})
61
62
  })
62
63
  );
63
64
  }
@@ -73,11 +73,33 @@ export const lastReceivedTime: ColumnSpec = {
73
73
  width: 140
74
74
  };
75
75
 
76
- export const clientAppVersion: ColumnSpec = {
76
+ export const appVersion: ColumnSpec = {
77
77
  field: {
78
- name: 'clientAppVersion',
78
+ name: 'appVersion',
79
79
  type: 'string',
80
80
  displayName: 'Client Version'
81
81
  },
82
82
  width: 120
83
83
  };
84
+
85
+ export const appBuild: ColumnSpec = {
86
+ field: {
87
+ name: 'appBuild',
88
+ type: 'string'
89
+ },
90
+ width: 120
91
+ };
92
+ export const loadId: ColumnSpec = {
93
+ field: {
94
+ name: 'loadId',
95
+ type: 'string'
96
+ },
97
+ width: 120
98
+ };
99
+ export const tabId: ColumnSpec = {
100
+ field: {
101
+ name: 'tabId',
102
+ type: 'string'
103
+ },
104
+ width: 120
105
+ };
@@ -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 => {
@@ -78,7 +92,10 @@ export class WebSocketModel extends BaseInstanceModel {
78
92
  WSCol.lastSentTime,
79
93
  WSCol.receivedMessageCount,
80
94
  WSCol.lastReceivedTime,
81
- WSCol.clientAppVersion
95
+ WSCol.appVersion,
96
+ WSCol.appBuild,
97
+ WSCol.loadId,
98
+ WSCol.tabId
82
99
  ]
83
100
  });
84
101
 
@@ -107,9 +124,8 @@ export class WebSocketModel extends BaseInstanceModel {
107
124
  }
108
125
  }
109
126
 
110
- async forceSuspendAsync() {
111
- const {selectedRecords} = this.gridModel;
112
- if (isEmpty(selectedRecords)) return;
127
+ async forceSuspendAsync(toRecs: StoreRecord[]) {
128
+ if (isEmpty(toRecs)) return;
113
129
 
114
130
  const message = await XH.prompt<string>({
115
131
  title: 'Please confirm...',
@@ -123,7 +139,7 @@ export class WebSocketModel extends BaseInstanceModel {
123
139
  },
124
140
  message: div(
125
141
  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.`
142
+ `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
143
  ),
128
144
  p('Enter an optional message below to display within the suspended app.')
129
145
  ),
@@ -134,23 +150,58 @@ export class WebSocketModel extends BaseInstanceModel {
134
150
  });
135
151
 
136
152
  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()}
153
+ await this.bulkPush({
154
+ toRecs,
155
+ topic: XH.webSocketService.FORCE_APP_SUSPEND_TOPIC,
156
+ message,
157
+ trackMessage: 'Suspended clients via WebSocket'
153
158
  });
154
159
  }
155
160
  }
161
+
162
+ async requestHealthReportAsync(toRecs: StoreRecord[]) {
163
+ await this.bulkPush({
164
+ toRecs,
165
+ topic: XH.webSocketService.REQ_CLIENT_HEALTH_RPT_TOPIC
166
+ });
167
+ XH.successToast(
168
+ `Client health report requested for ${pluralize('client', toRecs.length, true)} - available in User Activity shortly...`
169
+ );
170
+ }
171
+
172
+ //------------------
173
+ // Implementation
174
+ //------------------
175
+ private async bulkPush({
176
+ toRecs,
177
+ topic,
178
+ message,
179
+ trackMessage
180
+ }: {
181
+ toRecs?: StoreRecord[];
182
+ topic: string;
183
+ message?: string;
184
+ trackMessage?: string;
185
+ }) {
186
+ if (isEmpty(toRecs)) return;
187
+
188
+ const tasks = toRecs.map(rec =>
189
+ XH.fetchJson({
190
+ url: 'webSocketAdmin/pushToChannel',
191
+ params: {
192
+ channelKey: rec.data.key,
193
+ instance: this.instanceName,
194
+ topic,
195
+ message
196
+ }
197
+ })
198
+ );
199
+
200
+ await Promise.allSettled(tasks).track({
201
+ category: 'Audit',
202
+ message: trackMessage,
203
+ data: {users: toRecs.map(it => it.data.user).sort()},
204
+ omit: !trackMessage
205
+ });
206
+ }
156
207
  }
@@ -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'}}),
@@ -6,21 +6,11 @@
6
6
  */
7
7
  import {ClusterObjectsModel} from '@xh/hoist/admin/tabs/cluster/objects/ClusterObjectsModel';
8
8
  import {ColumnSpec, GridModel} from '@xh/hoist/cmp/grid';
9
- import {HoistModel, lookup, managed, PlainObject, XH} from '@xh/hoist/core';
9
+ import {HoistModel, lookup, managed, XH} from '@xh/hoist/core';
10
10
  import {StoreRecord} from '@xh/hoist/data';
11
- import {fmtDateTimeSec, fmtJson} from '@xh/hoist/format';
12
11
  import {action, makeObservable, observable} from '@xh/hoist/mobx';
13
- import {DAYS} from '@xh/hoist/utils/datetime';
14
- import {
15
- cloneDeep,
16
- forOwn,
17
- isArray,
18
- isEmpty,
19
- isEqual,
20
- isNumber,
21
- isPlainObject,
22
- without
23
- } from 'lodash';
12
+ import {isEmpty, isEqual, without} from 'lodash';
13
+ import {withFormattedTimestamps} from '@xh/hoist/format';
24
14
 
25
15
  export class DetailModel extends HoistModel {
26
16
  @lookup(ClusterObjectsModel)
@@ -66,12 +56,6 @@ export class DetailModel extends HoistModel {
66
56
  });
67
57
  }
68
58
 
69
- fmtStats(stats: PlainObject): string {
70
- stats = cloneDeep(stats);
71
- this.processTimestamps(stats);
72
- return fmtJson(JSON.stringify(stats));
73
- }
74
-
75
59
  //----------------------
76
60
  // Implementation
77
61
  //----------------------
@@ -95,8 +79,7 @@ export class DetailModel extends HoistModel {
95
79
  const gridModel = this.createGridModel(diffFields, otherFields);
96
80
  gridModel.loadData(
97
81
  instanceNames.map(instanceName => {
98
- const data = cloneDeep(adminStatsByInstance[instanceName] ?? {});
99
- this.processTimestamps(data);
82
+ const data = withFormattedTimestamps(adminStatsByInstance[instanceName] ?? {});
100
83
  return {instanceName, ...data};
101
84
  })
102
85
  );
@@ -136,23 +119,4 @@ export class DetailModel extends HoistModel {
136
119
  }
137
120
  return ret;
138
121
  }
139
-
140
- private processTimestamps(stats: PlainObject) {
141
- forOwn(stats, (v, k) => {
142
- // Convert numbers that look like recent timestamps to date values.
143
- if (
144
- (k.endsWith('Time') ||
145
- k.endsWith('Date') ||
146
- k.endsWith('Timestamp') ||
147
- k == 'timestamp') &&
148
- isNumber(v) &&
149
- v > Date.now() - 365 * DAYS
150
- ) {
151
- stats[k] = v ? fmtDateTimeSec(v, {fmt: 'MMM DD HH:mm:ss.SSS'}) : null;
152
- }
153
- if (isPlainObject(v) || isArray(v)) {
154
- this.processTimestamps(v);
155
- }
156
- });
157
- }
158
122
  }
@@ -12,6 +12,7 @@ import {panel} from '@xh/hoist/desktop/cmp/panel';
12
12
  import {Icon} from '@xh/hoist/icon';
13
13
  import {DetailModel} from './DetailModel';
14
14
  import './ClusterObjects.scss';
15
+ import {fmtJson, timestampReplacer} from '@xh/hoist/format';
15
16
 
16
17
  export const detailPanel = hoistCmp.factory({
17
18
  model: creates(DetailModel),
@@ -42,7 +43,7 @@ export const detailPanel = hoistCmp.factory({
42
43
  height: '100%',
43
44
  showFullscreenButton: false,
44
45
  editorProps: {lineNumbers: false},
45
- value: model.fmtStats(selectedAdminStats)
46
+ value: fmtJson(selectedAdminStats, {replacer: timestampReplacer()})
46
47
  })
47
48
  })
48
49
  ]
@@ -25,6 +25,7 @@ import {
25
25
  AlertBannerService,
26
26
  AutoRefreshService,
27
27
  ChangelogService,
28
+ ClientHealthService,
28
29
  ConfigService,
29
30
  EnvironmentService,
30
31
  FetchService,
@@ -237,6 +238,7 @@ export class AppContainerModel extends HoistModel {
237
238
  AlertBannerService,
238
239
  AutoRefreshService,
239
240
  ChangelogService,
241
+ ClientHealthService,
240
242
  IdleService,
241
243
  InspectorService,
242
244
  GridAutosizeService,
@@ -4,11 +4,10 @@
4
4
  *
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
- import {AppState, AppSuspendData, HoistModel, XH} from '@xh/hoist/core';
7
+ import {AppState, AppSuspendData, HoistModel, PlainObject, XH} from '@xh/hoist/core';
8
8
  import {action, makeObservable, observable} from '@xh/hoist/mobx';
9
9
  import {Timer} from '@xh/hoist/utils/async';
10
- import {getClientDeviceInfo} from '@xh/hoist/utils/js';
11
- import {camelCase, isBoolean, isString, mapKeys} from 'lodash';
10
+ import {camelCase, isBoolean, isString, mapKeys, pick} from 'lodash';
12
11
 
13
12
  /**
14
13
  * Support for Core Hoist Application state and loading.
@@ -93,13 +92,14 @@ export class AppStateModel extends HoistModel {
93
92
  timestamp: loadStarted,
94
93
  elapsed: Date.now() - loadStarted - (timings.LOGIN_REQUIRED ?? 0),
95
94
  data: {
96
- appVersion: XH.appVersion,
97
- appBuild: XH.appBuild,
98
- locationHref: window.location.href,
95
+ loadId: XH.loadId,
96
+ tabId: XH.tabId,
99
97
  timings: mapKeys(timings, (v, k) => camelCase(k)),
100
- ...getClientDeviceInfo()
98
+ clientHealth: XH.clientHealthService.getReport(),
99
+ window: this.getWindowData(),
100
+ screen: this.getScreenData()
101
101
  },
102
- logData: ['appVersion', 'appBuild'],
102
+ logData: ['loadId', 'tabId'],
103
103
  omit: !XH.appSpec.trackAppLoad
104
104
  })
105
105
  });
@@ -115,4 +115,36 @@ export class AppStateModel extends HoistModel {
115
115
  });
116
116
  });
117
117
  }
118
+
119
+ private getScreenData(): PlainObject {
120
+ const screen = window.screen as any;
121
+ if (!screen) return null;
122
+
123
+ const ret: PlainObject = pick(screen, [
124
+ 'availWidth',
125
+ 'availHeight',
126
+ 'width',
127
+ 'height',
128
+ 'colorDepth',
129
+ 'pixelDepth',
130
+ 'availLeft',
131
+ 'availTop'
132
+ ]);
133
+ if (screen.orientation) {
134
+ ret.orientation = pick(screen.orientation, ['angle', 'type']);
135
+ }
136
+ return ret;
137
+ }
138
+
139
+ private getWindowData(): PlainObject {
140
+ return pick(window, [
141
+ 'devicePixelRatio',
142
+ 'screenX',
143
+ 'screenY',
144
+ 'innerWidth',
145
+ 'innerHeight',
146
+ 'outerWidth',
147
+ 'outerHeight'
148
+ ]);
149
+ }
118
150
  }
@@ -1,7 +1,7 @@
1
- import { GroupingChooserModel } from '@xh/hoist/cmp/grouping';
2
1
  import { FilterChooserModel } from '@xh/hoist/cmp/filter';
3
2
  import { FormModel } from '@xh/hoist/cmp/form';
4
3
  import { GridModel } from '@xh/hoist/cmp/grid';
4
+ import { GroupingChooserModel } from '@xh/hoist/cmp/grouping';
5
5
  import { HoistModel, LoadSpec } from '@xh/hoist/core';
6
6
  import { Cube } from '@xh/hoist/data';
7
7
  import { LocalDate } from '@xh/hoist/utils/datetime';
@@ -1,13 +1,11 @@
1
1
  /// <reference types="react" />
2
2
  import { InstancesTabModel } from '@xh/hoist/admin/tabs/cluster/instances/InstancesTabModel';
3
- import { HoistModel, LoadSpec, PlainObject } from '@xh/hoist/core';
3
+ import { HoistModel, LoadSpec } from '@xh/hoist/core';
4
4
  export declare class BaseInstanceModel extends HoistModel {
5
5
  viewRef: import("react").RefObject<HTMLElement>;
6
6
  parent: InstancesTabModel;
7
7
  get instanceName(): string;
8
- fmtStats(stats: PlainObject): string;
9
8
  handleLoadException(e: unknown, loadSpec: LoadSpec): void;
10
9
  get isVisible(): boolean;
11
10
  private isInstanceNotFound;
12
- private processTimestamps;
13
11
  }
@@ -6,4 +6,7 @@ export declare const sentMessageCount: ColumnSpec;
6
6
  export declare const lastSentTime: ColumnSpec;
7
7
  export declare const receivedMessageCount: ColumnSpec;
8
8
  export declare const lastReceivedTime: ColumnSpec;
9
- export declare const clientAppVersion: ColumnSpec;
9
+ export declare const appVersion: ColumnSpec;
10
+ export declare const appBuild: ColumnSpec;
11
+ export declare const loadId: ColumnSpec;
12
+ export declare const tabId: ColumnSpec;
@@ -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
  }