@xh/hoist 72.2.0 → 72.4.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 +40 -2
- package/admin/tabs/activity/tracking/detail/ActivityDetailModel.ts +2 -2
- package/admin/tabs/cluster/instances/BaseInstanceModel.ts +1 -29
- package/admin/tabs/cluster/instances/InstancesTabModel.ts +4 -3
- package/admin/tabs/cluster/instances/logs/LogDisplay.ts +12 -14
- package/admin/tabs/cluster/instances/logs/LogDisplayModel.ts +0 -2
- package/admin/tabs/cluster/instances/logs/LogViewer.ts +6 -5
- package/admin/tabs/cluster/instances/logs/LogViewerModel.ts +8 -1
- package/admin/tabs/cluster/instances/memory/MemoryMonitorModel.ts +1 -0
- package/admin/tabs/cluster/instances/services/DetailsModel.ts +1 -2
- package/admin/tabs/cluster/instances/services/DetailsPanel.ts +20 -14
- package/admin/tabs/cluster/instances/services/ServiceModel.ts +14 -6
- package/admin/tabs/cluster/instances/services/ServicePanel.ts +9 -10
- package/admin/tabs/cluster/instances/websocket/WebSocketColumns.ts +9 -0
- package/admin/tabs/cluster/instances/websocket/WebSocketModel.ts +2 -1
- package/admin/tabs/cluster/objects/DetailModel.ts +4 -40
- package/admin/tabs/cluster/objects/DetailPanel.ts +2 -1
- package/admin/tabs/userData/roles/RoleModel.ts +1 -1
- package/appcontainer/AppContainerModel.ts +2 -0
- package/appcontainer/AppStateModel.ts +46 -9
- package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +4 -1
- package/build/types/admin/tabs/cluster/instances/BaseInstanceModel.d.ts +1 -3
- package/build/types/admin/tabs/cluster/instances/services/DetailsModel.d.ts +2 -3
- package/build/types/admin/tabs/cluster/instances/websocket/WebSocketColumns.d.ts +1 -0
- package/build/types/admin/tabs/cluster/objects/DetailModel.d.ts +1 -3
- package/build/types/appcontainer/AppStateModel.d.ts +7 -1
- package/build/types/cmp/viewmanager/ViewManagerModel.d.ts +7 -0
- package/build/types/core/XH.d.ts +11 -1
- package/build/types/desktop/cmp/tab/TabSwitcher.d.ts +1 -1
- package/build/types/format/FormatDate.d.ts +22 -1
- package/build/types/format/FormatMisc.d.ts +3 -2
- package/build/types/security/BaseOAuthClient.d.ts +6 -7
- package/build/types/security/Types.d.ts +32 -5
- package/build/types/security/msal/MsalClient.d.ts +14 -1
- package/build/types/svc/ClientHealthService.d.ts +58 -0
- package/build/types/svc/TrackService.d.ts +19 -1
- 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/data/Store.ts +3 -0
- package/desktop/cmp/grid/editors/BooleanEditor.ts +15 -3
- package/desktop/cmp/tab/TabSwitcher.ts +1 -1
- package/desktop/cmp/viewmanager/ViewMenu.ts +11 -9
- package/format/FormatDate.ts +45 -3
- package/format/FormatMisc.ts +6 -4
- package/package.json +2 -2
- package/security/BaseOAuthClient.ts +12 -10
- package/security/Types.ts +35 -6
- package/security/msal/MsalClient.ts +126 -21
- package/svc/ClientHealthService.ts +165 -0
- package/svc/FetchService.ts +3 -2
- package/svc/TrackService.ts +27 -5
- package/svc/WebSocketService.ts +1 -2
- 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 -4
- package/utils/js/BrowserUtils.ts +0 -52
|
@@ -49,11 +49,23 @@ export const [BooleanEditor, booleanEditor] = hoistCmp.withFactory<BooleanEditor
|
|
|
49
49
|
}
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
function useInstantEditor(
|
|
52
|
+
function useInstantEditor(
|
|
53
|
+
{onValueChange, initialValue, stopEditing, eventKey, eGridCell}: CustomCellEditorProps,
|
|
54
|
+
ref
|
|
55
|
+
) {
|
|
56
|
+
// Don't toggle if the user has tabbed into the editor. See https://github.com/xh/hoist-react/issues/3943.
|
|
57
|
+
// Fortunately, `eventKey` is null for tab, so we can use that to accept other keyboard events.
|
|
58
|
+
// Unfortunately, it is also null for mouse events, so we check if the grid cell is currently
|
|
59
|
+
// underneath the mouse position via `:hover` selector.
|
|
53
60
|
useEffect(() => {
|
|
54
|
-
|
|
61
|
+
const els = document.querySelectorAll(':hover'),
|
|
62
|
+
topEl = els[els.length - 1];
|
|
63
|
+
|
|
64
|
+
if (eventKey || topEl === eGridCell) {
|
|
65
|
+
onValueChange(!initialValue);
|
|
66
|
+
}
|
|
55
67
|
stopEditing();
|
|
56
|
-
}, [stopEditing, initialValue, onValueChange]);
|
|
68
|
+
}, [stopEditing, initialValue, onValueChange, eventKey, eGridCell]);
|
|
57
69
|
|
|
58
70
|
return null;
|
|
59
71
|
}
|
|
@@ -41,7 +41,7 @@ import {CSSProperties, ReactElement, KeyboardEvent} from 'react';
|
|
|
41
41
|
*
|
|
42
42
|
* Overflowing tabs can be displayed in a dropdown menu if `enableOverflow` is true.
|
|
43
43
|
* Note that in order for tabs to overflow, the TabSwitcher or it's wrapper must have a
|
|
44
|
-
*
|
|
44
|
+
* maximum width.
|
|
45
45
|
*/
|
|
46
46
|
export const [TabSwitcher, tabSwitcher] = hoistCmp.withFactory<TabSwitcherProps>({
|
|
47
47
|
displayName: 'TabSwitcher',
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xh/hoist",
|
|
3
|
-
"version": "72.
|
|
3
|
+
"version": "72.4.0",
|
|
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",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@auth0/auth0-spa-js": "~2.1.3",
|
|
33
|
-
"@azure/msal-browser": "~
|
|
33
|
+
"@azure/msal-browser": "~4.8.0",
|
|
34
34
|
"@blueprintjs/core": "^5.10.5",
|
|
35
35
|
"@blueprintjs/datetime": "^5.3.7",
|
|
36
36
|
"@blueprintjs/datetime2": "^2.3.7",
|
|
@@ -64,14 +64,13 @@ export interface BaseOAuthClientConfig<S extends AccessTokenSpec> {
|
|
|
64
64
|
idScopes?: string[];
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* Optional
|
|
67
|
+
* Optional spec for access tokens to be loaded and maintained to support access to one or more
|
|
68
|
+
* different back-end resources, distinct from the core Hoist auth flow via ID token.
|
|
68
69
|
*
|
|
69
|
-
* Map of
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* Use this map to gain targeted access tokens for different back-end resources.
|
|
70
|
+
* Map of key to a spec for an access token. The key is an arbitrary, app-determined string
|
|
71
|
+
* used to retrieve the loaded token via {@link getAccessTokenAsync}. The spec is implementation
|
|
72
|
+
* specific, but will typically include scopes to be loaded for the access token and potentially
|
|
73
|
+
* other metadata required by the underlying provider.
|
|
75
74
|
*/
|
|
76
75
|
accessTokens?: Record<string, S>;
|
|
77
76
|
}
|
|
@@ -120,7 +119,7 @@ export abstract class BaseOAuthClient<
|
|
|
120
119
|
throwIf(!config.clientId, 'Missing OAuth clientId. Please review your configuration.');
|
|
121
120
|
|
|
122
121
|
this.idScopes = union(['openid', 'email'], config.idScopes);
|
|
123
|
-
this.accessSpecs = this.config.accessTokens;
|
|
122
|
+
this.accessSpecs = this.config.accessTokens ?? {};
|
|
124
123
|
}
|
|
125
124
|
|
|
126
125
|
/**
|
|
@@ -164,7 +163,10 @@ export abstract class BaseOAuthClient<
|
|
|
164
163
|
* Get an Access token.
|
|
165
164
|
*/
|
|
166
165
|
async getAccessTokenAsync(key: string): Promise<Token> {
|
|
167
|
-
|
|
166
|
+
const spec = this.accessSpecs[key];
|
|
167
|
+
if (!spec) throw XH.exception(`No access token spec configured for key "${key}"`);
|
|
168
|
+
|
|
169
|
+
return this.fetchAccessTokenAsync(spec, true);
|
|
168
170
|
}
|
|
169
171
|
|
|
170
172
|
/**
|
|
@@ -314,7 +316,7 @@ export abstract class BaseOAuthClient<
|
|
|
314
316
|
const eagerOnly = opts?.eagerOnly ?? false,
|
|
315
317
|
useCache = opts?.useCache ?? true,
|
|
316
318
|
accessSpecs = eagerOnly
|
|
317
|
-
? pickBy(this.accessSpecs, spec => spec.fetchMode
|
|
319
|
+
? pickBy(this.accessSpecs, spec => spec.fetchMode !== 'lazy') // specs are eager by default - opt-in to lazy
|
|
318
320
|
: this.accessSpecs,
|
|
319
321
|
ret: TokenMap = {};
|
|
320
322
|
|
package/security/Types.ts
CHANGED
|
@@ -7,16 +7,45 @@
|
|
|
7
7
|
|
|
8
8
|
import {Token} from './Token';
|
|
9
9
|
|
|
10
|
-
export type TokenMap = Record<string, Token>;
|
|
11
|
-
|
|
12
10
|
export interface AccessTokenSpec {
|
|
13
11
|
/**
|
|
14
|
-
* Mode governing when the access token should be requested from provider
|
|
15
|
-
* eager (
|
|
16
|
-
*
|
|
12
|
+
* Mode governing when the access token should be requested from provider:
|
|
13
|
+
* - eager (or undefined) - load on overall initialization, but do not block on failure.
|
|
14
|
+
* Useful for tokens that an app is almost certain to require during a user session.
|
|
15
|
+
* - lazy - defer loading until first requested by client. Useful for tokens that might
|
|
16
|
+
* never be needed by the app during a given user session.
|
|
17
17
|
*/
|
|
18
|
-
fetchMode
|
|
18
|
+
fetchMode?: 'eager' | 'lazy';
|
|
19
19
|
|
|
20
20
|
/** Scopes for the desired access token.*/
|
|
21
21
|
scopes: string[];
|
|
22
22
|
}
|
|
23
|
+
|
|
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: number;
|
|
35
|
+
lastTime: number;
|
|
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: number;
|
|
47
|
+
duration: number;
|
|
48
|
+
code: string;
|
|
49
|
+
name: string;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -7,17 +7,20 @@
|
|
|
7
7
|
import * as msal from '@azure/msal-browser';
|
|
8
8
|
import {
|
|
9
9
|
AccountInfo,
|
|
10
|
+
BrowserPerformanceClient,
|
|
11
|
+
Configuration,
|
|
10
12
|
IPublicClientApplication,
|
|
11
13
|
LogLevel,
|
|
12
14
|
PopupRequest,
|
|
13
15
|
SilentRequest
|
|
14
16
|
} from '@azure/msal-browser';
|
|
15
|
-
import {XH} from '@xh/hoist/core';
|
|
17
|
+
import {AppState, PlainObject, XH} from '@xh/hoist/core';
|
|
16
18
|
import {Token} from '@xh/hoist/security/Token';
|
|
17
|
-
import {AccessTokenSpec, TokenMap} from '../Types';
|
|
18
19
|
import {logDebug, logError, logInfo, logWarn, mergeDeep, throwIf} from '@xh/hoist/utils/js';
|
|
20
|
+
import {withFormattedTimestamps} from '@xh/hoist/format';
|
|
19
21
|
import {flatMap, union, uniq} from 'lodash';
|
|
20
22
|
import {BaseOAuthClient, BaseOAuthClientConfig} from '../BaseOAuthClient';
|
|
23
|
+
import {AccessTokenSpec, TelemetryResults, TokenMap} from '../Types';
|
|
21
24
|
|
|
22
25
|
export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
|
|
23
26
|
/**
|
|
@@ -34,6 +37,13 @@ export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
|
|
|
34
37
|
*/
|
|
35
38
|
domainHint?: string;
|
|
36
39
|
|
|
40
|
+
/**
|
|
41
|
+
* True to enable support for built-in telemetry provided by this class's internal MSAL client.
|
|
42
|
+
* Captured performance events will be summarized via {@link telemetryResults}.
|
|
43
|
+
* See https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/performance.md
|
|
44
|
+
*/
|
|
45
|
+
enableTelemetry?: boolean;
|
|
46
|
+
|
|
37
47
|
/**
|
|
38
48
|
* If specified, the client will use this value when initializing the app to enforce a minimum
|
|
39
49
|
* amount of time during which no further auth flow with the provider should be necessary.
|
|
@@ -104,6 +114,10 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
104
114
|
private account: AccountInfo; // Authenticated account
|
|
105
115
|
private initialTokenLoad: boolean;
|
|
106
116
|
|
|
117
|
+
/** Enable telemetry via `enableTelemetry` ctor config, or via {@link enableTelemetry}. */
|
|
118
|
+
telemetryResults: TelemetryResults = {events: {}};
|
|
119
|
+
private _telemetryCbHandle: string = null;
|
|
120
|
+
|
|
107
121
|
constructor(config: MsalClientConfig) {
|
|
108
122
|
super({
|
|
109
123
|
initRefreshTokenExpirationOffsetSecs: -1,
|
|
@@ -118,6 +132,9 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
118
132
|
//-------------------------------------------
|
|
119
133
|
protected override async doInitAsync(): Promise<TokenMap> {
|
|
120
134
|
const client = (this.client = await this.createClientAsync());
|
|
135
|
+
if (this.config.enableTelemetry) {
|
|
136
|
+
this.enableTelemetry();
|
|
137
|
+
}
|
|
121
138
|
|
|
122
139
|
// 0) Handle redirect return
|
|
123
140
|
const redirectResp = await client.handleRedirectPromise();
|
|
@@ -247,6 +264,85 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
247
264
|
: await client.logoutPopup(opts);
|
|
248
265
|
}
|
|
249
266
|
|
|
267
|
+
//------------------------
|
|
268
|
+
// Telemetry
|
|
269
|
+
//------------------------
|
|
270
|
+
getFormattedTelemetry(): PlainObject {
|
|
271
|
+
return withFormattedTimestamps(this.telemetryResults);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
enableTelemetry(): void {
|
|
275
|
+
if (this._telemetryCbHandle) {
|
|
276
|
+
this.logInfo('Telemetry already enabled', this.getFormattedTelemetry());
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.telemetryResults = {events: {}};
|
|
281
|
+
|
|
282
|
+
this._telemetryCbHandle = this.client.addPerformanceCallback(events => {
|
|
283
|
+
events.forEach(e => {
|
|
284
|
+
try {
|
|
285
|
+
const {events} = this.telemetryResults,
|
|
286
|
+
{name, startTimeMs, durationMs, success, errorName, errorCode} = e,
|
|
287
|
+
eTime = startTimeMs ?? Date.now();
|
|
288
|
+
|
|
289
|
+
const eResult = (events[name] ??= {
|
|
290
|
+
firstTime: eTime,
|
|
291
|
+
lastTime: eTime,
|
|
292
|
+
successCount: 0,
|
|
293
|
+
failureCount: 0,
|
|
294
|
+
duration: {count: 0, total: 0, average: 0, worst: 0},
|
|
295
|
+
lastFailure: null
|
|
296
|
+
});
|
|
297
|
+
eResult.lastTime = eTime;
|
|
298
|
+
|
|
299
|
+
if (success) {
|
|
300
|
+
eResult.successCount++;
|
|
301
|
+
} else {
|
|
302
|
+
eResult.failureCount++;
|
|
303
|
+
eResult.lastFailure = {
|
|
304
|
+
time: eTime,
|
|
305
|
+
duration: e.durationMs,
|
|
306
|
+
code: errorCode,
|
|
307
|
+
name: errorName
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (durationMs) {
|
|
312
|
+
const {duration} = eResult;
|
|
313
|
+
duration.count++;
|
|
314
|
+
duration.total += durationMs;
|
|
315
|
+
duration.average = Math.round(duration.total / duration.count);
|
|
316
|
+
duration.worst = Math.max(duration.worst, durationMs);
|
|
317
|
+
}
|
|
318
|
+
} catch (e) {
|
|
319
|
+
this.logError(`Error processing telemetry event`, e);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Wait for clientHealthService (this client likely initialized during earlier AUTHENTICATING.)
|
|
325
|
+
this.addReaction({
|
|
326
|
+
when: () => XH.appState === AppState.INITIALIZING_APP,
|
|
327
|
+
run: () => XH.clientHealthService.addSource('msalClient', () => this.telemetryResults)
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
this.logInfo('Telemetry enabled');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
disableTelemetry(): void {
|
|
334
|
+
if (!this._telemetryCbHandle) {
|
|
335
|
+
this.logInfo('Telemetry already disabled');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
this.client.removePerformanceCallback(this._telemetryCbHandle);
|
|
340
|
+
this._telemetryCbHandle = null;
|
|
341
|
+
|
|
342
|
+
XH.clientHealthService.removeSource('msalClient');
|
|
343
|
+
this.logInfo('Telemetry disabled', this.getFormattedTelemetry());
|
|
344
|
+
}
|
|
345
|
+
|
|
250
346
|
//------------------------
|
|
251
347
|
// Private implementation
|
|
252
348
|
//------------------------
|
|
@@ -263,29 +359,38 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
263
359
|
}
|
|
264
360
|
|
|
265
361
|
private async createClientAsync(): Promise<IPublicClientApplication> {
|
|
266
|
-
const {clientId, authority, msalLogLevel, msalClientOptions} = this.config;
|
|
362
|
+
const {clientId, authority, msalLogLevel, msalClientOptions, enableTelemetry} = this.config;
|
|
267
363
|
throwIf(!authority, 'Missing MSAL authority. Please review your configuration.');
|
|
268
364
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
{
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
logLevel: msalLogLevel
|
|
281
|
-
}
|
|
282
|
-
},
|
|
283
|
-
cache: {
|
|
284
|
-
cacheLocation: 'localStorage' // allows sharing auth info across tabs.
|
|
365
|
+
const mergedConf: Configuration = mergeDeep(
|
|
366
|
+
{
|
|
367
|
+
auth: {
|
|
368
|
+
clientId,
|
|
369
|
+
authority,
|
|
370
|
+
postLogoutRedirectUri: this.postLogoutRedirectUrl
|
|
371
|
+
},
|
|
372
|
+
system: {
|
|
373
|
+
loggerOptions: {
|
|
374
|
+
loggerCallback: this.logFromMsal,
|
|
375
|
+
logLevel: msalLogLevel
|
|
285
376
|
}
|
|
286
377
|
},
|
|
287
|
-
|
|
288
|
-
|
|
378
|
+
cache: {
|
|
379
|
+
cacheLocation: 'localStorage' // allows sharing auth info across tabs.
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
msalClientOptions
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
return msal.PublicClientApplication.createPublicClientApplication(
|
|
386
|
+
enableTelemetry
|
|
387
|
+
? {
|
|
388
|
+
...mergedConf,
|
|
389
|
+
telemetry: {
|
|
390
|
+
client: new BrowserPerformanceClient(mergedConf)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
: mergedConf
|
|
289
394
|
);
|
|
290
395
|
}
|
|
291
396
|
|
|
@@ -0,0 +1,165 @@
|
|
|
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, XH} from '@xh/hoist/core';
|
|
8
|
+
import {Timer} from '@xh/hoist/utils/async';
|
|
9
|
+
import {MINUTES} from '@xh/hoist/utils/datetime';
|
|
10
|
+
import {withFormattedTimestamps} from '@xh/hoist/format';
|
|
11
|
+
import {pick, round} from 'lodash';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Service for gathering data about client health.
|
|
15
|
+
*
|
|
16
|
+
* Hoist sends this data once on application load, and can be configured to send
|
|
17
|
+
* it at regularly scheduled intervals. Configure via soft-config property
|
|
18
|
+
* 'xhActivityTracking.clientHealthReport'.
|
|
19
|
+
*/
|
|
20
|
+
export class ClientHealthService extends HoistService {
|
|
21
|
+
static instance: ClientHealthService;
|
|
22
|
+
|
|
23
|
+
private sources: Map<string, () => any> = new Map();
|
|
24
|
+
|
|
25
|
+
override async initAsync() {
|
|
26
|
+
const {clientHealthReport} = XH.trackService.conf;
|
|
27
|
+
Timer.create({
|
|
28
|
+
runFn: () => this.sendReport(),
|
|
29
|
+
interval: clientHealthReport.intervalMins * MINUTES,
|
|
30
|
+
delay: true
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Main entry point. Return a default report of client health.
|
|
36
|
+
*/
|
|
37
|
+
getReport(): ClientHealthReport {
|
|
38
|
+
return {
|
|
39
|
+
general: this.getGeneral(),
|
|
40
|
+
memory: this.getMemory(),
|
|
41
|
+
connection: this.getConnection(),
|
|
42
|
+
...this.getCustom()
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Get report, suitable for viewing in console. **/
|
|
47
|
+
getFormattedReport(): PlainObject {
|
|
48
|
+
return withFormattedTimestamps(this.getReport());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register a new source for client health report data. No-op if background health report is
|
|
53
|
+
* not generally enabled via `xhActivityTrackingConfig.clientHealthReport.intervalMins`.
|
|
54
|
+
*
|
|
55
|
+
* @param key - key under which to report the data - can be used to remove this source later.
|
|
56
|
+
* @param callback - function returning serializable to include with each report.
|
|
57
|
+
*/
|
|
58
|
+
addSource(key: string, callback: () => any) {
|
|
59
|
+
this.sources.set(key, callback);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Unregister a previously-enabled source for client health report data. */
|
|
63
|
+
removeSource(key: string) {
|
|
64
|
+
this.sources.delete(key);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// -----------------------------------
|
|
68
|
+
// Generate individual report sections
|
|
69
|
+
//------------------------------------
|
|
70
|
+
getGeneral(): GeneralData {
|
|
71
|
+
const startTime = XH.appContainerModel.appStateModel.loadStarted,
|
|
72
|
+
elapsedMins = (ts: number) => round((Date.now() - ts) / 60_000, 1);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
startTime,
|
|
76
|
+
durationMins: elapsedMins(startTime),
|
|
77
|
+
idleMins: elapsedMins(XH.lastActivityMs),
|
|
78
|
+
pageState: XH.pageState,
|
|
79
|
+
webSocket: XH.webSocketService.channelKey
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getConnection(): ConnectionData {
|
|
84
|
+
const nav = window.navigator as any;
|
|
85
|
+
if (!nav.connection) return null;
|
|
86
|
+
return pick(nav.connection, ['downlink', 'effectiveType', 'rtt']);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getMemory(): MemoryData {
|
|
90
|
+
const perf = window.performance as any;
|
|
91
|
+
if (!perf?.memory) return null;
|
|
92
|
+
|
|
93
|
+
const ret: MemoryData = {modelCount: XH.getModels().length};
|
|
94
|
+
['jsHeapSizeLimit', 'totalJSHeapSize', 'usedJSHeapSize'].forEach(key => {
|
|
95
|
+
const raw = perf.memory[key];
|
|
96
|
+
if (raw) ret[key] = round(raw / 1024 / 1024); // convert to MB
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const {jsHeapSizeLimit: limit, usedJSHeapSize: used} = ret;
|
|
100
|
+
if (limit && used) {
|
|
101
|
+
ret.usedPctLimit = round((used / limit) * 100);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return ret;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
getCustom(): PlainObject {
|
|
108
|
+
const ret = {};
|
|
109
|
+
this.sources.forEach((cb, k) => {
|
|
110
|
+
try {
|
|
111
|
+
ret[k] = cb();
|
|
112
|
+
} catch (e) {
|
|
113
|
+
ret[k] = `Error: ${e.message}`;
|
|
114
|
+
this.logWarn(`Error running client health report callback for [${k}]`, e);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
return ret;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
//------------------
|
|
121
|
+
// Implementation
|
|
122
|
+
//------------------
|
|
123
|
+
private sendReport() {
|
|
124
|
+
const {intervalMins, ...rest} = XH.trackService.conf.clientHealthReport ?? {};
|
|
125
|
+
|
|
126
|
+
XH.track({
|
|
127
|
+
category: 'App',
|
|
128
|
+
message: 'Submitted health report',
|
|
129
|
+
...rest,
|
|
130
|
+
data: {
|
|
131
|
+
clientId: XH.clientId,
|
|
132
|
+
sessionId: XH.sessionId,
|
|
133
|
+
...this.getReport()
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface GeneralData {
|
|
140
|
+
startTime: number;
|
|
141
|
+
durationMins: number;
|
|
142
|
+
idleMins: number;
|
|
143
|
+
pageState: PageState;
|
|
144
|
+
webSocket: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface ConnectionData {
|
|
148
|
+
downlink: number;
|
|
149
|
+
effectiveType: string;
|
|
150
|
+
rtt: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface MemoryData {
|
|
154
|
+
modelCount: number;
|
|
155
|
+
usedPctLimit?: number;
|
|
156
|
+
jsHeapSizeLimit?: number;
|
|
157
|
+
totalJSHeapSize?: number;
|
|
158
|
+
usedJSHeapSize?: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface ClientHealthReport {
|
|
162
|
+
general: GeneralData;
|
|
163
|
+
connection: ConnectionData;
|
|
164
|
+
memory: MemoryData;
|
|
165
|
+
}
|
package/svc/FetchService.ts
CHANGED
|
@@ -217,8 +217,9 @@ export class FetchService extends HoistService {
|
|
|
217
217
|
//-----------------------
|
|
218
218
|
private async fetchInternalAsync(opts: FetchOptions): Promise<any> {
|
|
219
219
|
opts = this.withCorrelationId(opts);
|
|
220
|
-
|
|
221
|
-
|
|
220
|
+
|
|
221
|
+
// Core Promise - chained with custom headers callback to ensure that work is included in overall tracked time.
|
|
222
|
+
let ret = this.withDefaultHeadersAsync(opts).then(opts => this.managedFetchAsync(opts));
|
|
222
223
|
|
|
223
224
|
// Apply tracking
|
|
224
225
|
const {correlationId, loadSpec, track} = opts;
|