@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 +10 -8
- package/admin/tabs/cluster/instances/websocket/WebSocketModel.ts +72 -24
- package/admin/tabs/cluster/instances/websocket/WebSocketPanel.ts +2 -2
- package/build/types/admin/tabs/cluster/instances/websocket/WebSocketModel.d.ts +5 -2
- package/build/types/svc/ClientHealthService.d.ts +1 -1
- package/build/types/svc/WebSocketService.d.ts +2 -1
- package/package.json +1 -1
- package/svc/ClientHealthService.ts +1 -1
- package/svc/WebSocketService.ts +5 -1
- package/tsconfig.tsbuildinfo +1 -1
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
|
-
|
|
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`
|
|
11
|
-
|
|
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: [
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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.
|
|
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:
|
|
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.
|
|
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.
|
|
71
|
+
* For ad-hoc troubleshooting. Apps may also configure this service to
|
|
72
72
|
* submit on regular intervals.
|
|
73
73
|
*/
|
|
74
74
|
async sendReportAsync() {
|
package/svc/WebSocketService.ts
CHANGED
|
@@ -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);
|