@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.
- package/CHANGELOG.md +25 -6
- package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +22 -14
- package/admin/tabs/activity/tracking/detail/ActivityDetailModel.ts +2 -2
- package/admin/tabs/cluster/instances/BaseInstanceModel.ts +1 -29
- package/admin/tabs/cluster/instances/services/DetailsPanel.ts +2 -1
- package/admin/tabs/cluster/instances/websocket/WebSocketColumns.ts +24 -2
- package/admin/tabs/cluster/instances/websocket/WebSocketModel.ts +76 -25
- package/admin/tabs/cluster/instances/websocket/WebSocketPanel.ts +2 -2
- package/admin/tabs/cluster/objects/DetailModel.ts +4 -40
- package/admin/tabs/cluster/objects/DetailPanel.ts +2 -1
- package/appcontainer/AppContainerModel.ts +2 -0
- package/appcontainer/AppStateModel.ts +40 -8
- package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +1 -1
- package/build/types/admin/tabs/cluster/instances/BaseInstanceModel.d.ts +1 -3
- package/build/types/admin/tabs/cluster/instances/websocket/WebSocketColumns.d.ts +4 -1
- package/build/types/admin/tabs/cluster/instances/websocket/WebSocketModel.d.ts +5 -2
- package/build/types/admin/tabs/cluster/objects/DetailModel.d.ts +1 -3
- package/build/types/appcontainer/AppStateModel.d.ts +2 -0
- package/build/types/cmp/viewmanager/ViewManagerModel.d.ts +7 -0
- package/build/types/core/XH.d.ts +11 -1
- package/build/types/core/types/Interfaces.d.ts +2 -2
- package/build/types/format/FormatDate.d.ts +22 -1
- package/build/types/format/FormatMisc.d.ts +3 -2
- package/build/types/security/Types.d.ts +0 -25
- package/build/types/security/msal/MsalClient.d.ts +42 -4
- package/build/types/svc/ClientHealthService.d.ts +64 -0
- package/build/types/svc/TrackService.d.ts +3 -11
- package/build/types/svc/WebSocketService.d.ts +38 -15
- package/build/types/svc/index.d.ts +1 -0
- package/build/types/utils/js/index.d.ts +0 -1
- package/cmp/viewmanager/ViewManagerModel.ts +10 -1
- package/core/XH.ts +26 -1
- package/core/types/Interfaces.ts +2 -2
- package/data/filter/BaseFilterFieldSpec.ts +6 -2
- package/desktop/appcontainer/AboutDialog.ts +14 -0
- package/desktop/cmp/button/AppMenuButton.ts +1 -1
- package/desktop/cmp/contextmenu/ContextMenu.ts +1 -1
- package/desktop/cmp/viewmanager/ViewMenu.ts +11 -9
- package/format/FormatDate.ts +45 -3
- package/format/FormatMisc.ts +6 -4
- package/kit/onsen/theme.scss +5 -0
- package/mobile/appcontainer/AboutDialog.scss +1 -1
- package/mobile/appcontainer/AboutDialog.ts +24 -1
- package/mobile/cmp/menu/impl/Menu.ts +2 -2
- package/package.json +1 -1
- package/security/Types.ts +0 -27
- package/security/msal/MsalClient.ts +77 -25
- package/svc/ClientHealthService.ts +179 -0
- package/svc/TrackService.ts +9 -69
- package/svc/WebSocketService.ts +74 -33
- package/svc/index.ts +1 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/utils/js/index.ts +0 -1
- package/build/types/utils/js/BrowserUtils.d.ts +0 -41
- package/utils/js/BrowserUtils.ts +0 -103
|
@@ -12,7 +12,7 @@ import {Icon} from '@xh/hoist/icon';
|
|
|
12
12
|
import {menu, menuDivider, menuItem} from '@xh/hoist/kit/blueprint';
|
|
13
13
|
import {pluralize} from '@xh/hoist/utils/js';
|
|
14
14
|
import {Dictionary} from 'express-serve-static-core';
|
|
15
|
-
import {each, filter, groupBy, isEmpty, orderBy, some, startCase} from 'lodash';
|
|
15
|
+
import {each, filter, groupBy, isEmpty, isFunction, orderBy, some, startCase} from 'lodash';
|
|
16
16
|
import {ReactNode} from 'react';
|
|
17
17
|
import {ViewManagerLocalModel} from './ViewManagerLocalModel';
|
|
18
18
|
|
|
@@ -162,12 +162,14 @@ function viewMenuItem(view: ViewInfo, model: ViewManagerModel): ReactNode {
|
|
|
162
162
|
if (!view.isOwned && view.owner) title.push(view.owner);
|
|
163
163
|
if (view.description) title.push(view.description);
|
|
164
164
|
|
|
165
|
-
return
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
165
|
+
return isFunction(model.viewMenuItemFn)
|
|
166
|
+
? model.viewMenuItemFn(view, model)
|
|
167
|
+
: menuItem({
|
|
168
|
+
className: 'xh-view-manager__menu-item',
|
|
169
|
+
key: view.token,
|
|
170
|
+
text: view.name,
|
|
171
|
+
title: title.join(' | '),
|
|
172
|
+
icon,
|
|
173
|
+
onClick: () => model.selectViewAsync(view).catchDefault()
|
|
174
|
+
});
|
|
173
175
|
}
|
package/format/FormatDate.ts
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Copyright © 2025 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
|
-
import {isLocalDate, LocalDate} from '@xh/hoist/utils/datetime';
|
|
8
|
-
import {defaults, isString} from 'lodash';
|
|
7
|
+
import {DAYS, isLocalDate, LocalDate} from '@xh/hoist/utils/datetime';
|
|
8
|
+
import {defaults, isFinite, isString} from 'lodash';
|
|
9
9
|
import moment from 'moment';
|
|
10
10
|
import {ReactNode} from 'react';
|
|
11
|
-
import {DateLike} from '../core/types/Types';
|
|
11
|
+
import {DateLike, PlainObject} from '../core/types/Types';
|
|
12
12
|
import {fmtSpan, FormatOptions} from './FormatMisc';
|
|
13
13
|
import {createRenderer} from './FormatUtils';
|
|
14
14
|
import {saveOriginal} from './impl/Utils';
|
|
@@ -148,6 +148,48 @@ export function fmtCompactDate(v: DateLike, opts?: CompactDateFormatOptions) {
|
|
|
148
148
|
return fmtDate(v, dateOpts);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
export interface TimestampReplacerConfig {
|
|
152
|
+
/**
|
|
153
|
+
* Suffixes used to identify keys that may hold timestamps.
|
|
154
|
+
* Defaults to ['time', 'date', 'timestamp']
|
|
155
|
+
*/
|
|
156
|
+
suffixes?: string[];
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Format for replaced timestamp.
|
|
160
|
+
* Defaults to 'MMM DD HH:mm:ss.SSS'
|
|
161
|
+
*/
|
|
162
|
+
format?: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Replace timestamps in an Object with formatted strings.
|
|
167
|
+
*/
|
|
168
|
+
export function withFormattedTimestamps(
|
|
169
|
+
obj: PlainObject,
|
|
170
|
+
config: TimestampReplacerConfig = {}
|
|
171
|
+
): PlainObject {
|
|
172
|
+
return JSON.parse(JSON.stringify(obj, timestampReplacer(config)));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Create a replacer, suitable for JSON.stringify, that will replace timestamps with
|
|
177
|
+
* formatted strings.
|
|
178
|
+
*/
|
|
179
|
+
export function timestampReplacer(
|
|
180
|
+
config: TimestampReplacerConfig = {}
|
|
181
|
+
): (k: string, v: any) => any {
|
|
182
|
+
const suffixes = config.suffixes ?? ['time', 'date', 'timestamp'],
|
|
183
|
+
fmt = 'MMM DD HH:mm:ss.SSS';
|
|
184
|
+
return (k: string, v: any) => {
|
|
185
|
+
return suffixes.some(s => k.toLowerCase().endsWith(s.toLowerCase())) &&
|
|
186
|
+
isFinite(v) &&
|
|
187
|
+
v > Date.now() - 25 * 365 * DAYS // heuristic to avoid catching smaller ms ranges
|
|
188
|
+
? fmtDateTime(v, {fmt})
|
|
189
|
+
: v;
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
151
193
|
export const dateRenderer = createRenderer(fmtDate),
|
|
152
194
|
dateTimeRenderer = createRenderer(fmtDateTime),
|
|
153
195
|
dateTimeSecRenderer = createRenderer(fmtDateTimeSec),
|
package/format/FormatMisc.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* Copyright © 2025 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
7
|
import {span} from '@xh/hoist/cmp/layout';
|
|
8
|
-
import {capitalize, isNil, kebabCase, map} from 'lodash';
|
|
8
|
+
import {capitalize, isNil, isString, kebabCase, map} from 'lodash';
|
|
9
9
|
import {CSSProperties, ReactNode} from 'react';
|
|
10
|
+
import {PlainObject} from '@xh/hoist/core';
|
|
10
11
|
|
|
11
12
|
export interface FormatOptions<T = any> {
|
|
12
13
|
/** Display value for null values. */
|
|
@@ -63,11 +64,12 @@ export interface JSONFormatOptions {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
/**
|
|
66
|
-
* Pretty-print a JSON string, adding line breaks and indentation.
|
|
67
|
+
* Pretty-print a JSON string or (JSON Object), adding line breaks and indentation.
|
|
67
68
|
*/
|
|
68
|
-
export function fmtJson(
|
|
69
|
+
export function fmtJson(v: string | PlainObject, opts?: JSONFormatOptions): string {
|
|
69
70
|
const {replacer = undefined, space = 2} = opts ?? {};
|
|
70
|
-
|
|
71
|
+
if (isString(v)) v = JSON.parse(v);
|
|
72
|
+
return isNil(v) ? '' : JSON.stringify(v, replacer, space);
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
/**
|
package/kit/onsen/theme.scss
CHANGED
|
@@ -30,7 +30,30 @@ export const aboutDialog = hoistCmp.factory({
|
|
|
30
30
|
className: 'xh-about-dialog',
|
|
31
31
|
item: model.getTable(),
|
|
32
32
|
isOpen: model.isOpen,
|
|
33
|
-
bbar: [
|
|
33
|
+
bbar: [
|
|
34
|
+
button({
|
|
35
|
+
text: 'Send Client Health Report',
|
|
36
|
+
icon: Icon.health(),
|
|
37
|
+
omit: !XH.clientHealthService.enabled,
|
|
38
|
+
onClick: async () => {
|
|
39
|
+
try {
|
|
40
|
+
await XH.clientHealthService.sendReportAsync();
|
|
41
|
+
XH.successToast({
|
|
42
|
+
message: 'Client health report submitted.',
|
|
43
|
+
timeout: 2000
|
|
44
|
+
});
|
|
45
|
+
} catch (e) {
|
|
46
|
+
XH.handleException('Error sending client health report', e);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}),
|
|
50
|
+
filler(),
|
|
51
|
+
button({
|
|
52
|
+
text: 'Close',
|
|
53
|
+
outlined: true,
|
|
54
|
+
onClick: () => model.hide()
|
|
55
|
+
})
|
|
56
|
+
]
|
|
34
57
|
});
|
|
35
58
|
}
|
|
36
59
|
});
|
|
@@ -98,9 +98,9 @@ class LocalMenuModel extends HoistModel {
|
|
|
98
98
|
omit: hidden,
|
|
99
99
|
onTouchStart: () => (this.pressedIdx = idx),
|
|
100
100
|
onTouchEnd: () => (this.pressedIdx = null),
|
|
101
|
-
onClick:
|
|
101
|
+
onClick: e => {
|
|
102
102
|
this.pressedIdx = null;
|
|
103
|
-
if (actionFn) actionFn();
|
|
103
|
+
if (actionFn) actionFn(e);
|
|
104
104
|
onDismiss();
|
|
105
105
|
}
|
|
106
106
|
});
|
package/package.json
CHANGED
package/security/Types.ts
CHANGED
|
@@ -22,30 +22,3 @@ export interface AccessTokenSpec {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export type TokenMap = Record<string, Token>;
|
|
25
|
-
|
|
26
|
-
/** Aggregated telemetry results, produced by {@link MsalClient} when enabled via config. */
|
|
27
|
-
export interface TelemetryResults {
|
|
28
|
-
/** Stats by event type - */
|
|
29
|
-
events: Record<string, TelemetryEventResults>;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Aggregated telemetry results for a single type of event. */
|
|
33
|
-
export interface TelemetryEventResults {
|
|
34
|
-
firstTime: Date;
|
|
35
|
-
lastTime: Date;
|
|
36
|
-
successCount: number;
|
|
37
|
-
failureCount: number;
|
|
38
|
-
/** Timing info (in ms) for event instances reported with duration. */
|
|
39
|
-
duration: {
|
|
40
|
-
count: number;
|
|
41
|
-
total: number;
|
|
42
|
-
average: number;
|
|
43
|
-
worst: number;
|
|
44
|
-
};
|
|
45
|
-
lastFailure?: {
|
|
46
|
-
time: Date;
|
|
47
|
-
duration: number;
|
|
48
|
-
code: string;
|
|
49
|
-
name: string;
|
|
50
|
-
};
|
|
51
|
-
}
|
|
@@ -14,12 +14,13 @@ import {
|
|
|
14
14
|
PopupRequest,
|
|
15
15
|
SilentRequest
|
|
16
16
|
} from '@azure/msal-browser';
|
|
17
|
-
import {XH} from '@xh/hoist/core';
|
|
17
|
+
import {AppState, PlainObject, XH} from '@xh/hoist/core';
|
|
18
18
|
import {Token} from '@xh/hoist/security/Token';
|
|
19
19
|
import {logDebug, logError, logInfo, logWarn, mergeDeep, throwIf} from '@xh/hoist/utils/js';
|
|
20
|
+
import {withFormattedTimestamps} from '@xh/hoist/format';
|
|
20
21
|
import {flatMap, union, uniq} from 'lodash';
|
|
21
22
|
import {BaseOAuthClient, BaseOAuthClientConfig} from '../BaseOAuthClient';
|
|
22
|
-
import {AccessTokenSpec,
|
|
23
|
+
import {AccessTokenSpec, TokenMap} from '../Types';
|
|
23
24
|
|
|
24
25
|
export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
|
|
25
26
|
/**
|
|
@@ -38,8 +39,7 @@ export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
|
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* True to enable support for built-in telemetry provided by this class's internal MSAL client.
|
|
41
|
-
* Captured performance events will be summarized
|
|
42
|
-
* See https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/performance.md
|
|
42
|
+
* Captured performance events will be summarized as {@link MsalClientTelemetry}.
|
|
43
43
|
*/
|
|
44
44
|
enableTelemetry?: boolean;
|
|
45
45
|
|
|
@@ -114,7 +114,7 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
114
114
|
private initialTokenLoad: boolean;
|
|
115
115
|
|
|
116
116
|
/** Enable telemetry via `enableTelemetry` ctor config, or via {@link enableTelemetry}. */
|
|
117
|
-
|
|
117
|
+
telemetry: MsalClientTelemetry = null;
|
|
118
118
|
private _telemetryCbHandle: string = null;
|
|
119
119
|
|
|
120
120
|
constructor(config: MsalClientConfig) {
|
|
@@ -266,34 +266,47 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
266
266
|
//------------------------
|
|
267
267
|
// Telemetry
|
|
268
268
|
//------------------------
|
|
269
|
+
getFormattedTelemetry(): PlainObject {
|
|
270
|
+
return withFormattedTimestamps(this.telemetry);
|
|
271
|
+
}
|
|
272
|
+
|
|
269
273
|
enableTelemetry(): void {
|
|
270
274
|
if (this._telemetryCbHandle) {
|
|
271
|
-
this.logInfo('Telemetry already enabled', this.
|
|
275
|
+
this.logInfo('Telemetry already enabled', this.getFormattedTelemetry());
|
|
272
276
|
return;
|
|
273
277
|
}
|
|
274
278
|
|
|
275
|
-
this.
|
|
279
|
+
this.telemetry = {
|
|
280
|
+
summary: {
|
|
281
|
+
successCount: 0,
|
|
282
|
+
failureCount: 0,
|
|
283
|
+
maxDuration: 0,
|
|
284
|
+
lastFailureTime: null
|
|
285
|
+
},
|
|
286
|
+
events: {}
|
|
287
|
+
};
|
|
276
288
|
|
|
277
289
|
this._telemetryCbHandle = this.client.addPerformanceCallback(events => {
|
|
278
290
|
events.forEach(e => {
|
|
279
291
|
try {
|
|
280
|
-
const {events} = this.
|
|
292
|
+
const {summary, events} = this.telemetry,
|
|
281
293
|
{name, startTimeMs, durationMs, success, errorName, errorCode} = e,
|
|
282
|
-
eTime = startTimeMs
|
|
294
|
+
eTime = startTimeMs ?? Date.now();
|
|
283
295
|
|
|
284
296
|
const eResult = (events[name] ??= {
|
|
285
297
|
firstTime: eTime,
|
|
286
298
|
lastTime: eTime,
|
|
287
299
|
successCount: 0,
|
|
288
|
-
failureCount: 0
|
|
289
|
-
duration: {count: 0, total: 0, average: 0, worst: 0},
|
|
290
|
-
lastFailure: null
|
|
300
|
+
failureCount: 0
|
|
291
301
|
});
|
|
292
302
|
eResult.lastTime = eTime;
|
|
293
303
|
|
|
294
304
|
if (success) {
|
|
305
|
+
summary.successCount++;
|
|
295
306
|
eResult.successCount++;
|
|
296
307
|
} else {
|
|
308
|
+
summary.failureCount++;
|
|
309
|
+
summary.lastFailureTime = eTime;
|
|
297
310
|
eResult.failureCount++;
|
|
298
311
|
eResult.lastFailure = {
|
|
299
312
|
time: eTime,
|
|
@@ -304,11 +317,17 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
304
317
|
}
|
|
305
318
|
|
|
306
319
|
if (durationMs) {
|
|
307
|
-
const
|
|
320
|
+
const duration = (eResult.duration ??= {
|
|
321
|
+
count: 0,
|
|
322
|
+
total: 0,
|
|
323
|
+
average: 0,
|
|
324
|
+
max: 0
|
|
325
|
+
});
|
|
308
326
|
duration.count++;
|
|
309
327
|
duration.total += durationMs;
|
|
310
328
|
duration.average = Math.round(duration.total / duration.count);
|
|
311
|
-
duration.
|
|
329
|
+
duration.max = Math.max(duration.max, durationMs);
|
|
330
|
+
summary.maxDuration = Math.max(summary.maxDuration, durationMs);
|
|
312
331
|
}
|
|
313
332
|
} catch (e) {
|
|
314
333
|
this.logError(`Error processing telemetry event`, e);
|
|
@@ -316,18 +335,13 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
316
335
|
});
|
|
317
336
|
});
|
|
318
337
|
|
|
319
|
-
//
|
|
320
|
-
// Handle TrackService not yet initialized (common, this client likely initialized before.)
|
|
338
|
+
// Wait for clientHealthService (this client likely initialized during earlier AUTHENTICATING.)
|
|
321
339
|
this.addReaction({
|
|
322
|
-
when: () => XH.
|
|
323
|
-
run: () =>
|
|
324
|
-
XH.trackService.addClientHealthReportSource(
|
|
325
|
-
'msalClient',
|
|
326
|
-
() => this.telemetryResults
|
|
327
|
-
)
|
|
340
|
+
when: () => XH.appState === AppState.INITIALIZING_APP,
|
|
341
|
+
run: () => XH.clientHealthService.addSource('msalClient', () => this.telemetry)
|
|
328
342
|
});
|
|
329
343
|
|
|
330
|
-
this.
|
|
344
|
+
this.logDebug('Telemetry enabled');
|
|
331
345
|
}
|
|
332
346
|
|
|
333
347
|
disableTelemetry(): void {
|
|
@@ -339,8 +353,8 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
339
353
|
this.client.removePerformanceCallback(this._telemetryCbHandle);
|
|
340
354
|
this._telemetryCbHandle = null;
|
|
341
355
|
|
|
342
|
-
XH.
|
|
343
|
-
this.logInfo('Telemetry disabled', this.
|
|
356
|
+
XH.clientHealthService.removeSource('msalClient');
|
|
357
|
+
this.logInfo('Telemetry disabled', this.getFormattedTelemetry());
|
|
344
358
|
}
|
|
345
359
|
|
|
346
360
|
//------------------------
|
|
@@ -434,3 +448,41 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
434
448
|
this.logDebug('User Authenticated', account.username);
|
|
435
449
|
}
|
|
436
450
|
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Telemetry produced by this client (if enabled) + included in {@link ClientHealthService}
|
|
454
|
+
* reporting. Leverages MSAL's opt-in support for emitting performance events.
|
|
455
|
+
* See https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/performance.md
|
|
456
|
+
*/
|
|
457
|
+
interface MsalClientTelemetry {
|
|
458
|
+
/** Stats across all events */
|
|
459
|
+
summary: {
|
|
460
|
+
successCount: number;
|
|
461
|
+
failureCount: number;
|
|
462
|
+
maxDuration: number;
|
|
463
|
+
lastFailureTime: number;
|
|
464
|
+
};
|
|
465
|
+
/** Stats by event type */
|
|
466
|
+
events: Record<string, MsalEventTelemetry>;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Aggregated telemetry results for a single type of event. */
|
|
470
|
+
interface MsalEventTelemetry {
|
|
471
|
+
firstTime: number;
|
|
472
|
+
lastTime: number;
|
|
473
|
+
successCount: number;
|
|
474
|
+
failureCount: number;
|
|
475
|
+
/** Timing info (in ms) for event instances reported with duration. */
|
|
476
|
+
duration?: {
|
|
477
|
+
count: number;
|
|
478
|
+
total: number;
|
|
479
|
+
average: number;
|
|
480
|
+
max: number;
|
|
481
|
+
};
|
|
482
|
+
lastFailure?: {
|
|
483
|
+
time: number;
|
|
484
|
+
duration: number;
|
|
485
|
+
code: string;
|
|
486
|
+
name: string;
|
|
487
|
+
};
|
|
488
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file belongs to Hoist, an application development toolkit
|
|
3
|
+
* developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
|
|
4
|
+
*
|
|
5
|
+
* Copyright © 2025 Extremely Heavy Industries Inc.
|
|
6
|
+
*/
|
|
7
|
+
import {HoistService, PageState, PlainObject, TrackOptions, XH} from '@xh/hoist/core';
|
|
8
|
+
import {WebSocketTelemetry} from '@xh/hoist/svc/WebSocketService';
|
|
9
|
+
import {Timer} from '@xh/hoist/utils/async';
|
|
10
|
+
import {MINUTES} from '@xh/hoist/utils/datetime';
|
|
11
|
+
import {withFormattedTimestamps} from '@xh/hoist/format';
|
|
12
|
+
import {pick, round} from 'lodash';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Service for gathering data about the current state and health of the client app, for submission
|
|
16
|
+
* to the server or review on the console during interactive troubleshooting.
|
|
17
|
+
*
|
|
18
|
+
* Hoist sends this data once on application load and can be configured to send at regular intervals
|
|
19
|
+
* throughout a user's session via the `xhActivityTracking.clientHealthReport` app config. Reports
|
|
20
|
+
* are submitted via activity tracking for review within the Admin Console.
|
|
21
|
+
*/
|
|
22
|
+
export class ClientHealthService extends HoistService {
|
|
23
|
+
static instance: ClientHealthService;
|
|
24
|
+
|
|
25
|
+
private sources: Map<string, () => any> = new Map();
|
|
26
|
+
|
|
27
|
+
override async initAsync() {
|
|
28
|
+
const {clientHealthReport} = XH.trackService.conf;
|
|
29
|
+
Timer.create({
|
|
30
|
+
runFn: () => this.sendReportInternal(),
|
|
31
|
+
interval: clientHealthReport.intervalMins * MINUTES,
|
|
32
|
+
delay: true
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get enabled(): boolean {
|
|
37
|
+
return XH.trackService.enabled;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @returns a customizable report with metrics capturing client app/session state. */
|
|
41
|
+
getReport(): ClientHealthReport {
|
|
42
|
+
return {
|
|
43
|
+
general: this.getGeneral(),
|
|
44
|
+
memory: this.getMemory(),
|
|
45
|
+
connection: this.getConnection(),
|
|
46
|
+
webSockets: XH.webSocketService.telemetry,
|
|
47
|
+
...this.getCustom()
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @returns a report, formatted for easier viewing in console. **/
|
|
52
|
+
getFormattedReport(): PlainObject {
|
|
53
|
+
return withFormattedTimestamps(this.getReport());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Register a new source for app-specific data to be sent with each report.
|
|
58
|
+
* @param key - key under which to report the data - can be used to remove this source later.
|
|
59
|
+
* @param callback - function returning serializable to include with each report.
|
|
60
|
+
*/
|
|
61
|
+
addSource(key: string, callback: () => any) {
|
|
62
|
+
this.sources.set(key, callback);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Unregister a previously-enabled source for client health report data. */
|
|
66
|
+
removeSource(key: string) {
|
|
67
|
+
this.sources.delete(key);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Generate and submit a report to the server, via TrackService.
|
|
72
|
+
*
|
|
73
|
+
* For ad-hoc troubleshooting. Apps may also configure this service to
|
|
74
|
+
* submit on regular intervals.
|
|
75
|
+
*/
|
|
76
|
+
async sendReportAsync() {
|
|
77
|
+
this.sendReportInternal({severity: 'INFO'});
|
|
78
|
+
await XH.trackService.pushPendingAsync();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// -----------------------------------
|
|
82
|
+
// Generate individual report sections
|
|
83
|
+
//------------------------------------
|
|
84
|
+
getGeneral(): GeneralData {
|
|
85
|
+
const startTime = XH.appContainerModel.appStateModel.loadStarted,
|
|
86
|
+
elapsedMins = (ts: number) => round((Date.now() - ts) / 60_000, 1);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
startTime,
|
|
90
|
+
durationMins: elapsedMins(startTime),
|
|
91
|
+
idleMins: elapsedMins(XH.lastActivityMs),
|
|
92
|
+
pageState: XH.pageState
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getConnection(): ConnectionData {
|
|
97
|
+
const nav = window.navigator as any;
|
|
98
|
+
if (!nav.connection) return null;
|
|
99
|
+
return pick(nav.connection, ['downlink', 'effectiveType', 'rtt']);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
getMemory(): MemoryData {
|
|
103
|
+
const perf = window.performance as any;
|
|
104
|
+
if (!perf?.memory) return null;
|
|
105
|
+
|
|
106
|
+
const ret: MemoryData = {modelCount: XH.getModels().length};
|
|
107
|
+
['jsHeapSizeLimit', 'totalJSHeapSize', 'usedJSHeapSize'].forEach(key => {
|
|
108
|
+
const raw = perf.memory[key];
|
|
109
|
+
if (raw) ret[key] = round(raw / 1024 / 1024); // convert to MB
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const {jsHeapSizeLimit: limit, usedJSHeapSize: used} = ret;
|
|
113
|
+
if (limit && used) {
|
|
114
|
+
ret.usedPctLimit = round((used / limit) * 100);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return ret;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
getCustom(): PlainObject {
|
|
121
|
+
const ret = {};
|
|
122
|
+
this.sources.forEach((cb, k) => {
|
|
123
|
+
try {
|
|
124
|
+
ret[k] = cb();
|
|
125
|
+
} catch (e) {
|
|
126
|
+
ret[k] = `Error: ${e.message}`;
|
|
127
|
+
this.logWarn(`Error running client health report callback for [${k}]`, e);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return ret;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
//---------------------
|
|
134
|
+
// Implementation
|
|
135
|
+
//---------------------
|
|
136
|
+
private sendReportInternal(opts: Partial<TrackOptions> = {}) {
|
|
137
|
+
const {intervalMins, ...rest} = XH.trackService.conf.clientHealthReport ?? {};
|
|
138
|
+
|
|
139
|
+
XH.track({
|
|
140
|
+
category: 'App',
|
|
141
|
+
message: 'Submitted health report',
|
|
142
|
+
...rest,
|
|
143
|
+
...opts,
|
|
144
|
+
data: {
|
|
145
|
+
loadId: XH.loadId,
|
|
146
|
+
tabId: XH.tabId,
|
|
147
|
+
...this.getReport()
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface GeneralData {
|
|
154
|
+
startTime: number;
|
|
155
|
+
durationMins: number;
|
|
156
|
+
idleMins: number;
|
|
157
|
+
pageState: PageState;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface ConnectionData {
|
|
161
|
+
downlink: number;
|
|
162
|
+
effectiveType: string;
|
|
163
|
+
rtt: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface MemoryData {
|
|
167
|
+
modelCount: number;
|
|
168
|
+
usedPctLimit?: number;
|
|
169
|
+
jsHeapSizeLimit?: number;
|
|
170
|
+
totalJSHeapSize?: number;
|
|
171
|
+
usedJSHeapSize?: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface ClientHealthReport {
|
|
175
|
+
general: GeneralData;
|
|
176
|
+
connection: ConnectionData;
|
|
177
|
+
memory: MemoryData;
|
|
178
|
+
webSockets: WebSocketTelemetry;
|
|
179
|
+
}
|