@xh/hoist 73.0.0-SNAPSHOT.1746025071597 → 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 (48) hide show
  1. package/CHANGELOG.md +8 -3
  2. package/admin/AdminUtils.ts +5 -0
  3. package/admin/AppModel.ts +19 -7
  4. package/admin/columns/Rest.ts +8 -0
  5. package/admin/columns/Tracking.ts +72 -0
  6. package/admin/tabs/activity/tracking/ActivityTracking.scss +18 -0
  7. package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +309 -216
  8. package/admin/tabs/activity/tracking/ActivityTrackingPanel.ts +81 -51
  9. package/admin/tabs/activity/tracking/chart/AggChartModel.ts +218 -0
  10. package/admin/tabs/activity/tracking/chart/AggChartPanel.ts +61 -0
  11. package/admin/tabs/activity/tracking/datafields/DataFieldsEditor.ts +147 -0
  12. package/admin/tabs/activity/tracking/datafields/DataFieldsEditorModel.ts +133 -0
  13. package/admin/tabs/activity/tracking/detail/ActivityDetailModel.ts +123 -60
  14. package/admin/tabs/activity/tracking/detail/ActivityDetailView.ts +106 -58
  15. package/admin/tabs/client/ClientTab.ts +2 -4
  16. package/admin/tabs/cluster/instances/memory/MemoryMonitorModel.ts +1 -2
  17. package/admin/tabs/general/GeneralTab.ts +2 -0
  18. package/build/types/admin/AdminUtils.d.ts +2 -0
  19. package/build/types/admin/AppModel.d.ts +4 -1
  20. package/build/types/admin/columns/Rest.d.ts +1 -0
  21. package/build/types/admin/columns/Tracking.d.ts +6 -0
  22. package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +31 -28
  23. package/build/types/admin/tabs/activity/tracking/chart/AggChartModel.d.ts +33 -0
  24. package/build/types/admin/tabs/activity/tracking/chart/AggChartPanel.d.ts +2 -0
  25. package/build/types/admin/tabs/activity/tracking/datafields/DataFieldsEditor.d.ts +2 -0
  26. package/build/types/admin/tabs/activity/tracking/datafields/DataFieldsEditorModel.d.ts +46 -0
  27. package/build/types/admin/tabs/activity/tracking/detail/ActivityDetailModel.d.ts +14 -1
  28. package/build/types/cmp/form/FormModel.d.ts +19 -30
  29. package/build/types/cmp/form/field/SubformsFieldModel.d.ts +25 -22
  30. package/build/types/core/HoistBase.d.ts +2 -2
  31. package/build/types/data/cube/CubeField.d.ts +4 -5
  32. package/build/types/desktop/cmp/appOption/AutoRefreshAppOption.d.ts +3 -3
  33. package/build/types/desktop/cmp/appOption/ThemeAppOption.d.ts +3 -3
  34. package/cmp/error/ErrorBoundaryModel.ts +1 -1
  35. package/cmp/form/FormModel.ts +20 -28
  36. package/cmp/form/field/SubformsFieldModel.ts +28 -22
  37. package/cmp/grid/columns/DatesTimes.ts +1 -2
  38. package/cmp/grid/impl/GridHScrollbar.ts +1 -2
  39. package/core/HoistBase.ts +12 -12
  40. package/data/cube/CubeField.ts +17 -18
  41. package/package.json +1 -1
  42. package/tsconfig.tsbuildinfo +1 -1
  43. package/admin/tabs/activity/tracking/charts/ChartsModel.ts +0 -218
  44. package/admin/tabs/activity/tracking/charts/ChartsPanel.ts +0 -76
  45. package/build/types/admin/tabs/activity/tracking/charts/ChartsModel.d.ts +0 -34
  46. package/build/types/admin/tabs/activity/tracking/charts/ChartsPanel.d.ts +0 -2
  47. /package/admin/tabs/{client → general}/feedback/FeedbackPanel.ts +0 -0
  48. /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,49 +47,14 @@ export class ActivityDetailModel extends HoistModel {
30
47
  }
31
48
 
32
49
  override onLinked() {
33
- const hidden = true;
34
- this.gridModel = new GridModel({
35
- sortBy: 'dateCreated|desc',
36
- colChooserModel: true,
37
- enableExport: true,
38
- filterModel: false,
39
- exportOptions: {
40
- columns: 'ALL',
41
- filename: exportFilename('activity-detail')
42
- },
43
- emptyText: 'Select a group on the left to see detailed tracking logs.',
44
- columns: [
45
- {...Col.impersonatingFlag},
46
- {...Col.entryId, hidden},
47
- {...Col.username},
48
- {...Col.impersonating, hidden},
49
- {...Col.category},
50
- {...Col.msg},
51
- {...Col.browser},
52
- {...Col.device},
53
- {...Col.userAgent, hidden},
54
- {...Col.appVersion},
55
- {...Col.loadId},
56
- {...Col.tabId},
57
- {...Col.appEnvironment, hidden},
58
- {...Col.data, hidden},
59
- {...Col.url},
60
- {...Col.instance, hidden},
61
- {...Col.correlationId},
62
- {...Col.severity, hidden},
63
- {...Col.elapsed},
64
- {...Col.dateCreatedWithSec, displayName: 'Timestamp'}
65
- ]
66
- });
67
-
68
- this.formModel = new FormModel({
69
- readonly: true,
70
- fields: this.gridModel
71
- .getLeafColumns()
72
- .map(it => ({name: it.field, displayName: it.headerName as string}))
73
- });
50
+ this.markPersist('formattedDataFilterPath', this.activityTrackingModel.persistWith);
74
51
 
75
52
  this.addReaction(
53
+ {
54
+ track: () => this.activityTrackingModel.dataFields,
55
+ run: () => this.createAndSetCoreModels(),
56
+ fireImmediately: true
57
+ },
76
58
  {
77
59
  track: () => this.activityTrackingModel.gridModel.selectedRecord,
78
60
  run: aggRec => this.showActivityEntriesAsync(aggRec)
@@ -80,11 +62,18 @@ export class ActivityDetailModel extends HoistModel {
80
62
  {
81
63
  track: () => this.gridModel.selectedRecord,
82
64
  run: detailRec => this.showEntryDetail(detailRec)
65
+ },
66
+ {
67
+ track: () => this.formattedDataFilterPath,
68
+ run: () => this.updateFormattedData()
83
69
  }
84
70
  );
85
71
  }
86
72
 
87
- private async showActivityEntriesAsync(aggRec) {
73
+ //------------------
74
+ // Implementation
75
+ //------------------
76
+ private async showActivityEntriesAsync(aggRec: StoreRecord) {
88
77
  const {gridModel} = this,
89
78
  leaves = this.getAllLeafRows(aggRec);
90
79
 
@@ -93,7 +82,7 @@ export class ActivityDetailModel extends HoistModel {
93
82
  }
94
83
 
95
84
  // Extract all leaf, track-entry-level rows from an aggregate record (at any level).
96
- private getAllLeafRows(aggRec, ret = []) {
85
+ private getAllLeafRows(aggRec: StoreRecord, ret = []) {
97
86
  if (!aggRec) return [];
98
87
 
99
88
  if (aggRec.children.length) {
@@ -107,22 +96,96 @@ export class ActivityDetailModel extends HoistModel {
107
96
  return ret;
108
97
  }
109
98
 
110
- // Extract data from a (detail) grid record and flush it into our form for display.
111
- // 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. */
112
100
  @action
113
- private showEntryDetail(detailRec) {
114
- const recData = detailRec?.data ?? {},
115
- trackData = recData.data;
101
+ private showEntryDetail(detailRec: StoreRecord) {
102
+ this.formModel.init(detailRec?.data ?? {});
103
+ this.updateFormattedData();
104
+ }
116
105
 
117
- this.formModel.init(recData);
106
+ @action
107
+ private updateFormattedData() {
108
+ const {gridModel, formattedDataFilterPath} = this,
109
+ trackData = gridModel.selectedRecord?.data.data;
118
110
 
119
- let formattedTrackData = trackData;
120
- if (formattedTrackData) {
121
- try {
122
- formattedTrackData = fmtJson(trackData, {replacer: timestampReplacer()});
123
- } catch (ignored) {}
111
+ if (!trackData) {
112
+ this.formattedData = '';
113
+ return;
124
114
  }
125
115
 
126
- this.formattedData = formattedTrackData;
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
+ }
127
+ }
128
+
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
+ });
127
190
  }
128
191
  }
@@ -7,16 +7,18 @@
7
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';
10
+ import {a, br, div, filler, h3, hframe, placeholder, span} from '@xh/hoist/cmp/layout';
11
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,68 +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
- }
97
- }),
98
- formField({
99
- field: 'loadId',
100
- readonlyRenderer: badgeRenderer
98
+ field: 'elapsed',
99
+ readonlyRenderer: numberRenderer({
100
+ label: 'ms',
101
+ nullDisplay: '-',
102
+ formatConfig: {thousandSeparated: false, mantissa: 0}
103
+ }),
104
+ omit: isNil(formModel.values.elapsed)
101
105
  }),
102
106
  formField({
103
- field: 'tabId',
104
- readonlyRenderer: badgeRenderer
107
+ field: 'dateCreated',
108
+ readonlyRenderer: dateTimeSecRenderer({})
105
109
  }),
106
110
  formField({
107
- field: 'url',
108
- readonlyRenderer: hyperlinkVal
111
+ field: 'correlationId',
112
+ readonlyRenderer: badgeRenderer,
113
+ omit: !formModel.values.correlationId
109
114
  }),
115
+ h3(Icon.idBadge(), 'Session IDs'),
110
116
  formField({
111
- field: 'instance',
112
- readonlyRenderer: badgeRenderer
117
+ field: 'loadId',
118
+ readonlyRenderer: badgeRenderer,
119
+ omit: !formModel.values.loadId
113
120
  }),
114
121
  formField({
115
- field: 'correlationId',
116
- readonlyRenderer: badgeRenderer
122
+ field: 'tabId',
123
+ readonlyRenderer: badgeRenderer,
124
+ omit: !formModel.values.tabId
117
125
  }),
118
126
  formField({
119
- field: 'elapsed',
120
- readonlyRenderer: numberRenderer({
121
- label: 'ms',
122
- nullDisplay: '-',
123
- formatConfig: {thousandSeparated: false, mantissa: 0}
124
- })
127
+ field: 'instance',
128
+ readonlyRenderer: badgeRenderer,
129
+ omit: !formModel.values.instance
125
130
  }),
131
+
132
+ h3(Icon.desktop(), 'Client App / Browser'),
126
133
  formField({
127
- field: 'dateCreated',
128
- readonlyRenderer: dateTimeSecRenderer({})
134
+ field: 'appVersion',
135
+ readonlyRenderer: appVersion => {
136
+ if (!appVersion) return naSpan();
137
+ const {appEnvironment} = formModel.values;
138
+ return `${appVersion} (${appEnvironment})`;
139
+ }
129
140
  }),
130
- h3(Icon.desktop(), 'Device / Browser'),
131
- formField({field: 'device'}),
141
+ formField({field: 'device', label: 'Device'}),
132
142
  formField({field: 'browser'}),
133
- formField({field: 'userAgent'})
143
+ formField({field: 'userAgent'}),
144
+ formField({
145
+ field: 'url',
146
+ readonlyRenderer: hyperlinkVal
147
+ })
134
148
  ]
135
149
  }),
136
- panel({
137
- flex: 1,
138
- className: 'xh-border-left',
139
- items: [h3(Icon.json(), 'Additional Data'), additionalDataJsonInput()]
140
- })
150
+ additionalDataPanel()
141
151
  )
142
152
  })
143
- : placeholder('Select an activity tracking record to view details.');
153
+ : placeholder(Icon.detail(), 'Select an activity tracking record to view details.');
144
154
  });
145
155
 
146
- const additionalDataJsonInput = hoistCmp.factory<ActivityDetailModel>(({model}) => {
147
- return jsonInput({
148
- readonly: true,
149
- width: '100%',
150
- height: '100%',
151
- showCopyButton: true,
152
- 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
+ ]
153
201
  });
154
202
  });
155
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
  })
@@ -13,9 +13,8 @@ import {LoadSpec, managed, XH} from '@xh/hoist/core';
13
13
  import {lengthIs, required} from '@xh/hoist/data';
14
14
  import {fmtTime, numberRenderer} from '@xh/hoist/format';
15
15
  import {Icon} from '@xh/hoist/icon';
16
- import {bindable, makeObservable} from '@xh/hoist/mobx';
16
+ import {bindable, makeObservable, observable, runInAction} from '@xh/hoist/mobx';
17
17
  import {forOwn, orderBy, sortBy} from 'lodash';
18
- import {observable, runInAction} from 'mobx';
19
18
 
20
19
  export interface PastInstance {
21
20
  name: string;
@@ -5,6 +5,7 @@
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {configPanel} from '@xh/hoist/admin/tabs/general/config/ConfigPanel';
8
+ import {feedbackPanel} from '@xh/hoist/admin/tabs/general/feedback/FeedbackPanel';
8
9
  import {tabContainer} from '@xh/hoist/cmp/tab';
9
10
  import {hoistCmp} from '@xh/hoist/core';
10
11
  import {Icon} from '@xh/hoist/icon';
@@ -19,6 +20,7 @@ export const generalTab = hoistCmp.factory(() =>
19
20
  tabs: [
20
21
  {id: 'about', icon: Icon.info(), content: aboutPanel},
21
22
  {id: 'config', icon: Icon.settings(), content: configPanel},
23
+ {id: 'feedback', icon: Icon.comment(), content: feedbackPanel},
22
24
  {id: 'alertBanner', icon: Icon.bullhorn(), content: alertBannerPanel}
23
25
  ]
24
26
  }
@@ -1,3 +1,4 @@
1
+ import { AppModel } from '@xh/hoist/admin/AppModel';
1
2
  /**
2
3
  * Generate a standardized filename for an Admin module grid export, without datestamp.
3
4
  */
@@ -7,3 +8,4 @@ export declare function exportFilename(moduleName: string): string;
7
8
  * Returned as a closure to ensure current date is evaluated at export time.
8
9
  */
9
10
  export declare function exportFilenameWithDate(moduleName: string): () => string;
11
+ export declare function getAppModel<T extends AppModel>(): T;