@xh/hoist 73.0.0-SNAPSHOT.1745976013413 → 73.0.0-SNAPSHOT.1746050068813

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 (57) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/admin/AdminUtils.ts +5 -0
  3. package/admin/App.scss +6 -0
  4. package/admin/AppModel.ts +19 -7
  5. package/admin/{tabs/client/clients/ClientsColumns.ts → columns/Clients.ts} +20 -53
  6. package/admin/columns/Core.ts +34 -35
  7. package/admin/columns/Rest.ts +8 -0
  8. package/admin/columns/Tracking.ts +144 -42
  9. package/admin/columns/index.ts +1 -0
  10. package/admin/tabs/activity/tracking/ActivityTracking.scss +18 -0
  11. package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +309 -210
  12. package/admin/tabs/activity/tracking/ActivityTrackingPanel.ts +81 -51
  13. package/admin/tabs/activity/tracking/chart/AggChartModel.ts +218 -0
  14. package/admin/tabs/activity/tracking/chart/AggChartPanel.ts +61 -0
  15. package/admin/tabs/activity/tracking/datafields/DataFieldsEditor.ts +147 -0
  16. package/admin/tabs/activity/tracking/datafields/DataFieldsEditorModel.ts +133 -0
  17. package/admin/tabs/activity/tracking/detail/ActivityDetailModel.ts +123 -59
  18. package/admin/tabs/activity/tracking/detail/ActivityDetailView.ts +110 -54
  19. package/admin/tabs/client/ClientTab.ts +2 -4
  20. package/admin/tabs/client/clients/ClientsModel.ts +10 -11
  21. package/admin/tabs/cluster/instances/memory/MemoryMonitorModel.ts +1 -2
  22. package/admin/tabs/general/GeneralTab.ts +2 -0
  23. package/build/types/admin/AdminUtils.d.ts +2 -0
  24. package/build/types/admin/AppModel.d.ts +4 -1
  25. package/build/types/admin/{tabs/client/clients/ClientsColumns.d.ts → columns/Clients.d.ts} +3 -7
  26. package/build/types/admin/columns/Core.d.ts +5 -5
  27. package/build/types/admin/columns/Rest.d.ts +1 -0
  28. package/build/types/admin/columns/Tracking.d.ts +13 -4
  29. package/build/types/admin/columns/index.d.ts +1 -0
  30. package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +31 -28
  31. package/build/types/admin/tabs/activity/tracking/chart/AggChartModel.d.ts +33 -0
  32. package/build/types/admin/tabs/activity/tracking/chart/AggChartPanel.d.ts +2 -0
  33. package/build/types/admin/tabs/activity/tracking/datafields/DataFieldsEditor.d.ts +2 -0
  34. package/build/types/admin/tabs/activity/tracking/datafields/DataFieldsEditorModel.d.ts +46 -0
  35. package/build/types/admin/tabs/activity/tracking/detail/ActivityDetailModel.d.ts +14 -1
  36. package/build/types/cmp/form/FormModel.d.ts +19 -30
  37. package/build/types/cmp/form/field/SubformsFieldModel.d.ts +25 -22
  38. package/build/types/core/HoistBase.d.ts +2 -2
  39. package/build/types/data/cube/CubeField.d.ts +4 -5
  40. package/build/types/desktop/cmp/appOption/AutoRefreshAppOption.d.ts +3 -3
  41. package/build/types/desktop/cmp/appOption/ThemeAppOption.d.ts +3 -3
  42. package/cmp/error/ErrorBoundaryModel.ts +1 -1
  43. package/cmp/form/FormModel.ts +20 -28
  44. package/cmp/form/field/SubformsFieldModel.ts +28 -22
  45. package/cmp/grid/columns/DatesTimes.ts +1 -2
  46. package/cmp/grid/impl/GridHScrollbar.ts +1 -2
  47. package/core/HoistBase.ts +12 -12
  48. package/data/cube/CubeField.ts +17 -18
  49. package/package.json +1 -1
  50. package/svc/TrackService.ts +2 -0
  51. package/tsconfig.tsbuildinfo +1 -1
  52. package/admin/tabs/activity/tracking/charts/ChartsModel.ts +0 -218
  53. package/admin/tabs/activity/tracking/charts/ChartsPanel.ts +0 -76
  54. package/build/types/admin/tabs/activity/tracking/charts/ChartsModel.d.ts +0 -34
  55. package/build/types/admin/tabs/activity/tracking/charts/ChartsPanel.d.ts +0 -2
  56. /package/admin/tabs/{client → general}/feedback/FeedbackPanel.ts +0 -0
  57. /package/build/types/admin/tabs/{client → general}/feedback/FeedbackPanel.d.ts +0 -0
@@ -0,0 +1,133 @@
1
+ import {ActivityTrackingModel} from '@xh/hoist/admin/tabs/activity/tracking/ActivityTrackingModel';
2
+ import {FormModel, SubformsFieldModel} from '@xh/hoist/cmp/form';
3
+ import {HoistModel, managed} from '@xh/hoist/core';
4
+ import {AggregatorToken, FieldType, genDisplayName, required} from '@xh/hoist/data';
5
+ import {action, observable, makeObservable} from '@xh/hoist/mobx';
6
+ import {computed} from '@xh/hoist/mobx';
7
+ import {last, uniqBy} from 'lodash';
8
+
9
+ /**
10
+ * Slimmed down {@link CubeFieldSpec} for persisted specs of fields to be extracted from the `data`
11
+ * block of loaded track statements and promoted to top-level columns in the grids. These are the
12
+ * entities (stored on parent `ActivityTrackingModel`) that are edited by the this component.
13
+ */
14
+ export interface ActivityTrackingDataFieldSpec {
15
+ /**
16
+ * Path to field data within the `data` block of each track log entry. Can be dot-delimited for
17
+ * nested data (e.g. `timings.preAuth`). See {@link ActivityTrackingModel.processRawTrackLog}.
18
+ */
19
+ path: string;
20
+ /**
21
+ * Normalized name for the field for use in Cube/Grid - adds `df_` prefix to avoid conflicts
22
+ * and strips out dot-delimiters. See {@link ActivityTrackingModel.setDataFields}.
23
+ */
24
+ name: string;
25
+ displayName?: string;
26
+ type?: FieldType;
27
+ isDimension?: boolean;
28
+ aggregator?: AggregatorToken;
29
+ }
30
+
31
+ export class DataFieldsEditorModel extends HoistModel {
32
+ @observable showEditor = false;
33
+
34
+ @managed formModel: FormModel;
35
+ private parentModel: ActivityTrackingModel;
36
+
37
+ aggTokens: AggregatorToken[] = ['AVG', 'MAX', 'MIN', 'SINGLE', 'SUM', 'UNIQUE'];
38
+
39
+ get dataFields(): SubformsFieldModel {
40
+ return this.formModel.fields.dataFields as SubformsFieldModel;
41
+ }
42
+
43
+ @computed
44
+ get appliedDataFieldCount(): number {
45
+ return this.parentModel.dataFields.length;
46
+ }
47
+
48
+ get hasAppliedDataFields(): boolean {
49
+ return this.appliedDataFieldCount > 0;
50
+ }
51
+
52
+ constructor(parentModel: ActivityTrackingModel) {
53
+ super();
54
+ makeObservable(this);
55
+
56
+ this.parentModel = parentModel;
57
+
58
+ this.formModel = new FormModel({
59
+ fields: [
60
+ {
61
+ name: 'dataFields',
62
+ subforms: {
63
+ fields: [
64
+ {name: 'path', rules: [required]},
65
+ {name: 'displayName'},
66
+ {name: 'type', initialValue: 'auto'},
67
+ {name: 'isDimension'},
68
+ {name: 'aggregator'}
69
+ ]
70
+ }
71
+ }
72
+ ]
73
+ });
74
+
75
+ this.addReaction({
76
+ track: () => this.parentModel.dataFields,
77
+ run: () => this.syncFromParent(),
78
+ fireImmediately: true
79
+ });
80
+ }
81
+
82
+ @action
83
+ show() {
84
+ this.syncFromParent();
85
+ this.showEditor = true;
86
+ }
87
+
88
+ @action
89
+ applyAndClose() {
90
+ this.syncToParent();
91
+ this.showEditor = false;
92
+ }
93
+
94
+ @action
95
+ close() {
96
+ this.showEditor = false;
97
+ }
98
+
99
+ addField() {
100
+ this.dataFields.add();
101
+ }
102
+
103
+ cloneField(formModel: FormModel) {
104
+ const {dataFields} = this,
105
+ srcIdx = dataFields.value.indexOf(formModel);
106
+
107
+ dataFields.add({initialValues: formModel.getData(), index: srcIdx + 1});
108
+ }
109
+
110
+ private syncFromParent() {
111
+ this.formModel.init({
112
+ dataFields: this.parentModel.dataFields
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Normalize field specs and set onto parent.
118
+ * Note, will de-dupe fields by name w/o any alert to user.
119
+ */
120
+ private syncToParent() {
121
+ const raw = this.formModel.getData().dataFields,
122
+ specs: ActivityTrackingDataFieldSpec[] = raw.map(it => {
123
+ const {displayName, path, aggregator: agg} = it;
124
+ return {
125
+ ...it,
126
+ name: 'df_' + path.replaceAll('.', '') + (agg ? `_${agg}` : ''),
127
+ displayName: displayName || genDisplayName(last(path.split('.')))
128
+ };
129
+ });
130
+
131
+ this.parentModel.setDataFields(uniqBy(specs, 'name'));
132
+ }
133
+ }
@@ -9,15 +9,32 @@ 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 {action, computed, makeObservable, observable} from '@xh/hoist/mobx';
12
+ import {StoreRecord} from '@xh/hoist/data';
13
+ import {timestampReplacer} from '@xh/hoist/format';
14
+ import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx';
15
+ import {get} from 'lodash';
13
16
  import {ActivityTrackingModel} from '../ActivityTrackingModel';
14
- import {fmtJson, timestampReplacer} from '@xh/hoist/format';
15
17
 
16
18
  export class ActivityDetailModel extends HoistModel {
17
19
  @lookup(ActivityTrackingModel) activityTrackingModel: ActivityTrackingModel;
18
- @managed gridModel: GridModel;
19
- @managed formModel: FormModel;
20
- @observable formattedData;
20
+
21
+ @managed @observable.ref gridModel: GridModel;
22
+ @managed @observable.ref formModel: FormModel;
23
+
24
+ /**
25
+ * Optional dot-delimited path(s) to filter the displayed `data` payload down to a particular
26
+ * node or nodes, for easier browsing of records with a large data payload. Multiple paths
27
+ * can be separated with `|`.
28
+ */
29
+ @bindable formattedDataFilterPath: string;
30
+
31
+ /** Stringified, pretty-printed, optionally path-filtered `data` payload. */
32
+ @observable formattedData: string;
33
+
34
+ @computed
35
+ get hasExtraTrackData(): boolean {
36
+ return this.gridModel.selectedRecord?.data.data != null;
37
+ }
21
38
 
22
39
  @computed
23
40
  get hasSelection() {
@@ -30,48 +47,14 @@ export class ActivityDetailModel extends HoistModel {
30
47
  }
31
48
 
32
49
  override onLinked() {
33
- const hidden = true;
34
-
35
- this.gridModel = new GridModel({
36
- sortBy: 'dateCreated|desc',
37
- colChooserModel: true,
38
- enableExport: true,
39
- filterModel: false,
40
- exportOptions: {
41
- columns: 'ALL',
42
- filename: exportFilename('activity-detail')
43
- },
44
- emptyText: 'Select a group on the left to see detailed tracking logs.',
45
- columns: [
46
- {...Col.impersonatingFlag},
47
- {...Col.entryId, hidden},
48
- {...Col.username},
49
- {...Col.impersonating, hidden},
50
- {...Col.category},
51
- {...Col.msg},
52
- {...Col.browser},
53
- {...Col.device},
54
- {...Col.userAgent, hidden},
55
- {...Col.appVersion},
56
- {...Col.appEnvironment, hidden},
57
- {...Col.data, hidden},
58
- {...Col.url},
59
- {...Col.correlationId},
60
- {...Col.instance, hidden},
61
- {...Col.severity, hidden},
62
- {...Col.elapsed},
63
- {...Col.dateCreatedWithSec, displayName: 'Timestamp'}
64
- ]
65
- });
66
-
67
- this.formModel = new FormModel({
68
- readonly: true,
69
- fields: this.gridModel
70
- .getLeafColumns()
71
- .map(it => ({name: it.field, displayName: it.headerName as string}))
72
- });
50
+ this.markPersist('formattedDataFilterPath', this.activityTrackingModel.persistWith);
73
51
 
74
52
  this.addReaction(
53
+ {
54
+ track: () => this.activityTrackingModel.dataFields,
55
+ run: () => this.createAndSetCoreModels(),
56
+ fireImmediately: true
57
+ },
75
58
  {
76
59
  track: () => this.activityTrackingModel.gridModel.selectedRecord,
77
60
  run: aggRec => this.showActivityEntriesAsync(aggRec)
@@ -79,11 +62,18 @@ export class ActivityDetailModel extends HoistModel {
79
62
  {
80
63
  track: () => this.gridModel.selectedRecord,
81
64
  run: detailRec => this.showEntryDetail(detailRec)
65
+ },
66
+ {
67
+ track: () => this.formattedDataFilterPath,
68
+ run: () => this.updateFormattedData()
82
69
  }
83
70
  );
84
71
  }
85
72
 
86
- private async showActivityEntriesAsync(aggRec) {
73
+ //------------------
74
+ // Implementation
75
+ //------------------
76
+ private async showActivityEntriesAsync(aggRec: StoreRecord) {
87
77
  const {gridModel} = this,
88
78
  leaves = this.getAllLeafRows(aggRec);
89
79
 
@@ -92,7 +82,7 @@ export class ActivityDetailModel extends HoistModel {
92
82
  }
93
83
 
94
84
  // Extract all leaf, track-entry-level rows from an aggregate record (at any level).
95
- private getAllLeafRows(aggRec, ret = []) {
85
+ private getAllLeafRows(aggRec: StoreRecord, ret = []) {
96
86
  if (!aggRec) return [];
97
87
 
98
88
  if (aggRec.children.length) {
@@ -106,22 +96,96 @@ export class ActivityDetailModel extends HoistModel {
106
96
  return ret;
107
97
  }
108
98
 
109
- // Extract data from a (detail) grid record and flush it into our form for display.
110
- // Also parse/format any additional data (as JSON) if provided.
99
+ /** Extract data from a (detail) grid record and flush it into our form for display. */
100
+ @action
101
+ private showEntryDetail(detailRec: StoreRecord) {
102
+ this.formModel.init(detailRec?.data ?? {});
103
+ this.updateFormattedData();
104
+ }
105
+
111
106
  @action
112
- private showEntryDetail(detailRec) {
113
- const recData = detailRec?.data ?? {},
114
- trackData = recData.data;
107
+ private updateFormattedData() {
108
+ const {gridModel, formattedDataFilterPath} = this,
109
+ trackData = gridModel.selectedRecord?.data.data;
115
110
 
116
- this.formModel.init(recData);
111
+ if (!trackData) {
112
+ this.formattedData = '';
113
+ return;
114
+ }
117
115
 
118
- let formattedTrackData = trackData;
119
- if (formattedTrackData) {
120
- try {
121
- formattedTrackData = fmtJson(trackData, {replacer: timestampReplacer()});
122
- } catch (ignored) {}
116
+ let parsed = JSON.parse(trackData),
117
+ toFormat = parsed;
118
+
119
+ if (formattedDataFilterPath) {
120
+ const paths = formattedDataFilterPath.split('|');
121
+ if (paths.length > 1) {
122
+ toFormat = {};
123
+ paths.forEach(path => (toFormat[path.trim()] = get(parsed, path.trim())));
124
+ } else {
125
+ toFormat = get(parsed, formattedDataFilterPath.trim());
126
+ }
123
127
  }
124
128
 
125
- this.formattedData = formattedTrackData;
129
+ this.formattedData = JSON.stringify(toFormat, timestampReplacer(), 2);
130
+ }
131
+
132
+ //------------------------
133
+ // Core data-handling models
134
+ //------------------------
135
+ @action
136
+ private createAndSetCoreModels() {
137
+ this.gridModel = this.createGridModel();
138
+ this.formModel = this.createSingleEntryFormModel();
139
+ }
140
+
141
+ private createGridModel(): GridModel {
142
+ const hidden = true,
143
+ pinned = true;
144
+
145
+ return new GridModel({
146
+ persistWith: {...this.activityTrackingModel.persistWith, path: 'detailGrid'},
147
+ sortBy: 'dateCreated|desc',
148
+ colChooserModel: true,
149
+ enableExport: true,
150
+ filterModel: false,
151
+ exportOptions: {
152
+ columns: 'ALL',
153
+ filename: exportFilename('activity-detail')
154
+ },
155
+ emptyText: 'Select a group on the left to see detailed tracking logs.',
156
+ columns: [
157
+ {...Col.entryId, hidden, pinned},
158
+ {...Col.severityIcon, pinned},
159
+ {...Col.impersonatingFlag, pinned},
160
+ {...Col.username, pinned},
161
+ {...Col.impersonating, hidden},
162
+ {...Col.category},
163
+ {...Col.msg},
164
+ {...Col.elapsed},
165
+ {...Col.deviceIcon},
166
+ {...Col.browser, hidden},
167
+ {...Col.userAgent, hidden},
168
+ {...Col.appVersion, hidden},
169
+ {...Col.loadId, hidden},
170
+ {...Col.tabId},
171
+ {...Col.correlationId, hidden},
172
+ {...Col.appEnvironment, hidden},
173
+ {...Col.instance, hidden},
174
+ {...Col.urlPathOnly},
175
+ {...Col.data, hidden},
176
+ {...Col.dateCreatedNoYear, displayName: 'Timestamp'},
177
+ ...this.activityTrackingModel.dataFieldCols
178
+ ]
179
+ });
180
+ }
181
+
182
+ // TODO - don't base on grid cols
183
+ private createSingleEntryFormModel(): FormModel {
184
+ return new FormModel({
185
+ readonly: true,
186
+ fields: this.gridModel
187
+ .getLeafColumns()
188
+ .map(it => ({name: it.field, displayName: it.headerName as string}))
189
+ });
126
190
  }
127
191
  }
@@ -4,19 +4,21 @@
4
4
  *
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
- import {correlationId, instance} from '@xh/hoist/admin/columns';
7
+ import {badgeRenderer} from '@xh/hoist/admin/columns';
8
8
  import {form} from '@xh/hoist/cmp/form';
9
9
  import {grid, gridCountLabel} from '@xh/hoist/cmp/grid';
10
- import {a, div, filler, h3, hframe, placeholder, span} from '@xh/hoist/cmp/layout';
11
- import {hoistCmp, creates} from '@xh/hoist/core';
10
+ import {a, br, div, filler, h3, hframe, placeholder, span} from '@xh/hoist/cmp/layout';
11
+ import {creates, hoistCmp} from '@xh/hoist/core';
12
12
  import {colChooserButton, exportButton} from '@xh/hoist/desktop/cmp/button';
13
13
  import {formField} from '@xh/hoist/desktop/cmp/form';
14
14
  import {gridFindField} from '@xh/hoist/desktop/cmp/grid';
15
- import {jsonInput} from '@xh/hoist/desktop/cmp/input';
15
+ import {jsonInput, textInput} from '@xh/hoist/desktop/cmp/input';
16
16
  import {panel} from '@xh/hoist/desktop/cmp/panel';
17
17
  import {toolbar} from '@xh/hoist/desktop/cmp/toolbar';
18
18
  import {dateTimeSecRenderer, numberRenderer} from '@xh/hoist/format';
19
19
  import {Icon} from '@xh/hoist/icon/Icon';
20
+ import {tooltip} from '@xh/hoist/kit/blueprint';
21
+ import {isNil} from 'lodash';
20
22
  import {ActivityDetailModel} from './ActivityDetailModel';
21
23
 
22
24
  export const activityDetailView = hoistCmp.factory({
@@ -24,26 +26,26 @@ export const activityDetailView = hoistCmp.factory({
24
26
 
25
27
  render({model, ...props}) {
26
28
  return panel({
27
- title: 'Track Log Entries',
28
- icon: Icon.list(),
29
29
  className: 'xh-admin-activity-detail',
30
- compactHeader: true,
31
- items: [grid({flex: 1}), detailRecPanel()],
32
30
  tbar: tbar(),
31
+ items: [grid({flex: 1}), detailRecPanel()],
33
32
  ...props
34
33
  });
35
34
  }
36
35
  });
37
36
 
38
- const tbar = hoistCmp.factory(({model}) => {
39
- return toolbar(
40
- filler(),
41
- gridCountLabel({unit: 'entry'}),
42
- '-',
43
- gridFindField(),
44
- colChooserButton(),
45
- exportButton()
46
- );
37
+ const tbar = hoistCmp.factory<ActivityDetailModel>(({model}) => {
38
+ const {gridModel} = model;
39
+ return toolbar({
40
+ items: [
41
+ filler(),
42
+ gridCountLabel({unit: 'entry'}),
43
+ '-',
44
+ gridFindField({gridModel, key: gridModel.xhId}),
45
+ colChooserButton({gridModel}),
46
+ exportButton()
47
+ ]
48
+ });
47
49
  });
48
50
 
49
51
  // Discrete outer panel to retain sizing across master/detail selection changes.
@@ -54,7 +56,11 @@ const detailRecPanel = hoistCmp.factory<ActivityDetailModel>(({model}) => {
54
56
  compactHeader: true,
55
57
  modelConfig: {
56
58
  side: 'bottom',
57
- defaultSize: 400
59
+ defaultSize: 400,
60
+ persistWith: {
61
+ ...model.activityTrackingModel.persistWith,
62
+ path: 'singleActivityDetailPanel'
63
+ }
58
64
  },
59
65
  item: detailRecForm()
60
66
  });
@@ -71,6 +77,7 @@ const detailRecForm = hoistCmp.factory<ActivityDetailModel>(({model}) => {
71
77
  style: {flex: 1},
72
78
  items: [
73
79
  h3(Icon.info(), 'Activity'),
80
+ formField({field: 'severity', label: 'Severity'}),
74
81
  formField({
75
82
  field: 'username',
76
83
  readonlyRenderer: username => {
@@ -88,60 +95,109 @@ const detailRecForm = hoistCmp.factory<ActivityDetailModel>(({model}) => {
88
95
  formField({field: 'category'}),
89
96
  formField({field: 'msg'}),
90
97
  formField({
91
- field: 'appVersion',
92
- readonlyRenderer: appVersion => {
93
- if (!appVersion) return naSpan();
94
- const {appEnvironment} = formModel.values;
95
- return `${appVersion} (${appEnvironment})`;
96
- }
98
+ field: 'elapsed',
99
+ readonlyRenderer: numberRenderer({
100
+ label: 'ms',
101
+ nullDisplay: '-',
102
+ formatConfig: {thousandSeparated: false, mantissa: 0}
103
+ }),
104
+ omit: isNil(formModel.values.elapsed)
97
105
  }),
98
106
  formField({
99
- field: 'url',
100
- readonlyRenderer: hyperlinkVal
107
+ field: 'dateCreated',
108
+ readonlyRenderer: dateTimeSecRenderer({})
101
109
  }),
102
110
  formField({
103
- field: 'instance',
104
- readonlyRenderer: v => instance.renderer(v, null)
111
+ field: 'correlationId',
112
+ readonlyRenderer: badgeRenderer,
113
+ omit: !formModel.values.correlationId
105
114
  }),
115
+ h3(Icon.idBadge(), 'Session IDs'),
106
116
  formField({
107
- field: 'correlationId',
108
- readonlyRenderer: v => correlationId.renderer(v, null)
117
+ field: 'loadId',
118
+ readonlyRenderer: badgeRenderer,
119
+ omit: !formModel.values.loadId
109
120
  }),
110
121
  formField({
111
- field: 'elapsed',
112
- readonlyRenderer: numberRenderer({
113
- label: 'ms',
114
- nullDisplay: '-',
115
- formatConfig: {thousandSeparated: false, mantissa: 0}
116
- })
122
+ field: 'tabId',
123
+ readonlyRenderer: badgeRenderer,
124
+ omit: !formModel.values.tabId
117
125
  }),
118
126
  formField({
119
- field: 'dateCreated',
120
- readonlyRenderer: dateTimeSecRenderer({})
127
+ field: 'instance',
128
+ readonlyRenderer: badgeRenderer,
129
+ omit: !formModel.values.instance
130
+ }),
131
+
132
+ h3(Icon.desktop(), 'Client App / Browser'),
133
+ formField({
134
+ field: 'appVersion',
135
+ readonlyRenderer: appVersion => {
136
+ if (!appVersion) return naSpan();
137
+ const {appEnvironment} = formModel.values;
138
+ return `${appVersion} (${appEnvironment})`;
139
+ }
121
140
  }),
122
- h3(Icon.desktop(), 'Device / Browser'),
123
- formField({field: 'device'}),
141
+ formField({field: 'device', label: 'Device'}),
124
142
  formField({field: 'browser'}),
125
- formField({field: 'userAgent'})
143
+ formField({field: 'userAgent'}),
144
+ formField({
145
+ field: 'url',
146
+ readonlyRenderer: hyperlinkVal
147
+ })
126
148
  ]
127
149
  }),
128
- panel({
129
- flex: 1,
130
- className: 'xh-border-left',
131
- items: [h3(Icon.json(), 'Additional Data'), additionalDataJsonInput()]
132
- })
150
+ additionalDataPanel()
133
151
  )
134
152
  })
135
- : placeholder('Select an activity tracking record to view details.');
153
+ : placeholder(Icon.detail(), 'Select an activity tracking record to view details.');
136
154
  });
137
155
 
138
- const additionalDataJsonInput = hoistCmp.factory<ActivityDetailModel>(({model}) => {
139
- return jsonInput({
140
- readonly: true,
141
- width: '100%',
142
- height: '100%',
143
- showCopyButton: true,
144
- value: model.formattedData
156
+ const additionalDataPanel = hoistCmp.factory<ActivityDetailModel>(({model}) => {
157
+ const item = model.formattedData
158
+ ? jsonInput({
159
+ readonly: true,
160
+ width: '100%',
161
+ height: '100%',
162
+ showCopyButton: true,
163
+ value: model.formattedData
164
+ })
165
+ : placeholder({
166
+ items: [
167
+ model.hasExtraTrackData ? Icon.filter() : null,
168
+ model.hasExtraTrackData
169
+ ? 'Additional data available, but hidden by path filter below.'
170
+ : 'No additional data available.'
171
+ ]
172
+ });
173
+
174
+ return panel({
175
+ flex: 1,
176
+ className: 'xh-border-left',
177
+ items: [
178
+ h3(Icon.json(), 'Additional Data'),
179
+ item,
180
+ toolbar(
181
+ textInput({
182
+ placeholder: 'Path filter(s)...',
183
+ leftIcon: Icon.filter({
184
+ intent: model.formattedDataFilterPath ? 'warning' : null
185
+ }),
186
+ commitOnChange: true,
187
+ enableClear: true,
188
+ flex: 1,
189
+ bind: 'formattedDataFilterPath'
190
+ }),
191
+ tooltip({
192
+ item: Icon.questionCircle({className: 'xh-margin-right'}),
193
+ content: span(
194
+ 'Specify one or more dot-delimited paths to filter the JSON data displayed above.',
195
+ br(),
196
+ 'Separate multiple paths that you wish to include with a | character.'
197
+ )
198
+ })
199
+ )
200
+ ]
145
201
  });
146
202
  });
147
203
 
@@ -4,11 +4,10 @@
4
4
  *
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
+ import {clientErrorsPanel} from '@xh/hoist/admin/tabs/client/errors/ClientErrorsPanel';
7
8
  import {tabContainer} from '@xh/hoist/cmp/tab';
8
9
  import {hoistCmp} from '@xh/hoist/core';
9
10
  import {Icon} from '@xh/hoist/icon';
10
- import {clientErrorsPanel} from '@xh/hoist/admin/tabs/client/errors/ClientErrorsPanel';
11
- import {feedbackPanel} from './feedback/FeedbackPanel';
12
11
  import {clientsPanel} from './clients/ClientsPanel';
13
12
 
14
13
  export const clientTab = hoistCmp.factory(() =>
@@ -18,8 +17,7 @@ export const clientTab = hoistCmp.factory(() =>
18
17
  switcher: {orientation: 'left', testId: 'client-tab-switcher'},
19
18
  tabs: [
20
19
  {id: 'connections', icon: Icon.diff(), content: clientsPanel},
21
- {id: 'errors', icon: Icon.warning(), content: clientErrorsPanel},
22
- {id: 'feedback', icon: Icon.comment(), content: feedbackPanel}
20
+ {id: 'errors', icon: Icon.warning(), content: clientErrorsPanel}
23
21
  ]
24
22
  }
25
23
  })
@@ -18,7 +18,6 @@ import {Timer} from '@xh/hoist/utils/async';
18
18
  import {SECONDS} from '@xh/hoist/utils/datetime';
19
19
  import {pluralize} from '@xh/hoist/utils/js';
20
20
  import {isEmpty} from 'lodash';
21
- import * as WSCol from './ClientsColumns';
22
21
  import {BaseAdminTabModel} from '@xh/hoist/admin/tabs/BaseAdminTabModel';
23
22
 
24
23
  export class ClientsModel extends BaseAdminTabModel {
@@ -177,32 +176,32 @@ export class ClientsModel extends BaseAdminTabModel {
177
176
  },
178
177
  sortBy: ['user'],
179
178
  columns: [
180
- WSCol.isOpen,
179
+ Col.isOpen,
181
180
  Col.user,
182
181
  {
183
182
  headerName: 'Session',
184
183
  headerAlign: 'center',
185
184
  children: [
186
- WSCol.createdTime,
187
- {...WSCol.key, hidden},
185
+ Col.createdTime,
186
+ {...Col.key, hidden},
188
187
  Col.instance,
189
- WSCol.loadId,
190
- WSCol.tabId
188
+ Col.loadId,
189
+ Col.tabId
191
190
  ]
192
191
  },
193
192
  {
194
193
  headerName: 'Client App',
195
194
  headerAlign: 'center',
196
- children: [WSCol.appVersion, WSCol.appBuild]
195
+ children: [Col.appVersion, Col.appBuild]
197
196
  },
198
197
  {
199
198
  headerName: 'Send/Receive',
200
199
  headerAlign: 'center',
201
200
  children: [
202
- WSCol.sentMessageCount,
203
- WSCol.lastSentTime,
204
- WSCol.receivedMessageCount,
205
- WSCol.lastReceivedTime
201
+ Col.sentMessageCount,
202
+ Col.lastSentTime,
203
+ Col.receivedMessageCount,
204
+ Col.lastReceivedTime
206
205
  ]
207
206
  }
208
207
  ]