@xenon-device-management/xenon 2.9.2 → 2.10.1

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 (147) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-B87n4pUu.js → AnnotationOverlay-defSfi9B.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-CNZJH6Cs.js → ApiKeyGate-DS7dJ2bU.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-DQ-R87ZV.js → BugReportButton-CeD8SUnQ.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-dX3e7O-w.js → DeviceMosaicView-Dgue7t7s.js} +2 -2
  6. package/lib/public/assets/{EmptyState-D3fG2GHY.js → EmptyState-Bo-vvxiy.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CUmbJZxI.js → FieldGroup-DdhTNAXZ.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-Dm2c-UCk.js → FilterMenu-J3F_3fDw.js} +1 -1
  9. package/lib/public/assets/{Menu-Dl4uXMkH.js → Menu-Bxluaw2m.js} +1 -1
  10. package/lib/public/assets/{Modal-DNmn2RDs.js → Modal-qgMVFrqV.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-Dn3By34o.js → RecordingPage-BUT7WgJB.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-lCUIM4pm.js → RecordingsPage-CY0OIgCo.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-BdA_mHiZ.js → SegmentedControl-Cq-fybZ2.js} +1 -1
  14. package/lib/public/assets/{SettingCard-Dx3WhK7w.js → SettingCard-CASWJr7h.js} +1 -1
  15. package/lib/public/assets/{Table-D_2lhvRM.js → Table-BSO_dZzu.js} +1 -1
  16. package/lib/public/assets/{activity-cNHHOKx5.js → activity-B_-MNwwR.js} +1 -1
  17. package/lib/public/assets/{ai-settings-t6RMrDkg.js → ai-settings-DFhemyo8.js} +1 -1
  18. package/lib/public/assets/{api-keys-CK_qyQvW.js → api-keys-DV1oB8Tf.js} +1 -1
  19. package/lib/public/assets/{apps-DCiI3UOb.js → apps-B5f-VuLN.js} +1 -1
  20. package/lib/public/assets/{arrow-left-CnYs8HVw.js → arrow-left-D_xIcVqg.js} +1 -1
  21. package/lib/public/assets/{arrow-right-Cc9qm6UG.js → arrow-right-CLGlL_Un.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-e7yBOb-c.js → arrow-up-right-CuIgWFj2.js} +1 -1
  23. package/lib/public/assets/{auth-shell-DKrtcIjG.js → auth-shell-SgAkrFQg.js} +2 -2
  24. package/lib/public/assets/{builds-page-CRd5oEz6.js → builds-page-h9ZJ8n74.js} +4 -4
  25. package/lib/public/assets/{button-C0caCjJB.js → button-DKl94fzV.js} +1 -1
  26. package/lib/public/assets/{calendar-BqOOkln_.js → calendar-BNLTjBsT.js} +1 -1
  27. package/lib/public/assets/{check-DYSXRG79.js → check-1u_3Zsx5.js} +1 -1
  28. package/lib/public/assets/{chevron-right-hCNu0CA-.js → chevron-right-Cn2BixQX.js} +1 -1
  29. package/lib/public/assets/{circle-check-DnQ263fa.js → circle-check-OymVCeGU.js} +1 -1
  30. package/lib/public/assets/{circle-x-BaRKZyz9.js → circle-x-D3ZyFWOd.js} +1 -1
  31. package/lib/public/assets/{clock-XP3iBAhg.js → clock-DwzGN-aa.js} +1 -1
  32. package/lib/public/assets/{copy-D8d9UzWE.js → copy-De43WY6T.js} +1 -1
  33. package/lib/public/assets/{device-explorer-pdM3L9TC.js → device-explorer-CtvEmCC5.js} +5 -5
  34. package/lib/public/assets/{download-SaN9-umX.js → download-DcSdKSvR.js} +1 -1
  35. package/lib/public/assets/{forgot-password-DfUhEZW5.js → forgot-password-DDjM8J7Z.js} +1 -1
  36. package/lib/public/assets/{index-CzaIyL9t.js → index-Bqt1dmMA.js} +2 -2
  37. package/lib/public/assets/{index-IefGCFQQ.js → index-CSNs0EE4.js} +1 -1
  38. package/lib/public/assets/{index-C8pvxG1f.css → index-DQa193-A.css} +1 -1
  39. package/lib/public/assets/{input-BqFzAQT3.js → input-fRGFwm1K.js} +1 -1
  40. package/lib/public/assets/{keycodes-DDV2OlpQ.js → keycodes-Dj4m3JV7.js} +10 -5
  41. package/lib/public/assets/line-chart-DHSZ8Sm4.js +1 -0
  42. package/lib/public/assets/list-checks-Co1gqqDn.js +11 -0
  43. package/lib/public/assets/{lock-Eye1CG8p.js → lock-BzutP2Yf.js} +1 -1
  44. package/lib/public/assets/{login-Co1IXuen.js → login-DSVZLgW2.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-D1fw6iqm.js → maintenance-settings-PYFNp_tc.js} +1 -1
  46. package/lib/public/assets/{monitor-U5Y-PxW5.js → monitor-DdKVA3ze.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CDAwphUm.js → mouse-pointer-2-C8UpXByt.js} +1 -1
  48. package/lib/public/assets/{network-BP_U4iDZ.js → network-BkzHrr2L.js} +1 -1
  49. package/lib/public/assets/overview-0YFo6f97.js +6 -0
  50. package/lib/public/assets/{page-header-ZdzNEJp9.js → page-header-DkmHEtLJ.js} +1 -1
  51. package/lib/public/assets/{plus-1k0sxXyU.js → plus-BH6Y7G7y.js} +1 -1
  52. package/lib/public/assets/{profile-page-B_GWKRy3.js → profile-page-C1IDBP4l.js} +1 -1
  53. package/lib/public/assets/{reset-password-DizCTkdw.js → reset-password-yNMsl5fZ.js} +1 -1
  54. package/lib/public/assets/{runbook-page-DWzRiOLZ.js → runbook-page--fH5nToN.js} +1 -1
  55. package/lib/public/assets/{select-CiRW6v17.js → select-DqjhZ6h2.js} +1 -1
  56. package/lib/public/assets/selector-detail-redirect-CrYIFrUj.js +1 -0
  57. package/lib/public/assets/selector-health-page-BnRUmiS2.js +11 -0
  58. package/lib/public/assets/session-detail-page-BNh_QfHv.js +4 -0
  59. package/lib/public/assets/{settings-DhwZoWv7.js → settings-BcxYFxnE.js} +1 -1
  60. package/lib/public/assets/stat-tile-DxVLBgA7.js +1 -0
  61. package/lib/public/assets/{tablet-CPJV3dBq.js → tablet-C_uKWfUK.js} +1 -1
  62. package/lib/public/assets/{teams-D0IBa2Ov.js → teams-C9G2WisD.js} +1 -1
  63. package/lib/public/assets/time-D-gjmE1o.js +1 -0
  64. package/lib/public/assets/{trash-2-u8MlXUBD.js → trash-2-C4cJO0CG.js} +1 -1
  65. package/lib/public/assets/{upload-Cq8L3kpw.js → upload-BN3PMMvP.js} +1 -1
  66. package/lib/public/assets/use-builds-data-CtelVuo_.js +8 -0
  67. package/lib/public/assets/{use-password-reset-mode-Dbr2HEfw.js → use-password-reset-mode-CClcmiRh.js} +1 -1
  68. package/lib/public/assets/{users-DxLHYmFm.js → users-BK7fwGGA.js} +1 -1
  69. package/lib/public/assets/{users-CZqCE511.js → users-OdnOb63Y.js} +1 -1
  70. package/lib/public/assets/{video-off-mh1EEzP9.js → video-off-DUAYoXTQ.js} +1 -1
  71. package/lib/public/assets/view-state-83Z1gFME.js +1 -0
  72. package/lib/public/assets/{webhook-settings-BYuOib-Q.js → webhook-settings-BRyvDWHI.js} +1 -1
  73. package/lib/public/assets/{zap-BOpUur0M.js → zap-BiceX7DC.js} +1 -1
  74. package/lib/public/index.html +2 -2
  75. package/lib/schema.json +5 -0
  76. package/lib/src/app/routers/dashboard.js +86 -40
  77. package/lib/src/app/routers/selector-health.js +75 -0
  78. package/lib/src/app/swagger-docs.js +77 -8
  79. package/lib/src/dashboard/event-manager.js +15 -99
  80. package/lib/src/generated/client/edge.js +27 -3
  81. package/lib/src/generated/client/index-browser.js +24 -0
  82. package/lib/src/generated/client/index.d.ts +8207 -5283
  83. package/lib/src/generated/client/index.js +27 -3
  84. package/lib/src/generated/client/package.json +1 -1
  85. package/lib/src/generated/client/schema.prisma +49 -9
  86. package/lib/src/generated/client/wasm.js +24 -0
  87. package/lib/src/prisma.js +2 -0
  88. package/lib/src/services/CleanupService.js +1 -0
  89. package/lib/src/services/NotificationService.js +3 -4
  90. package/lib/src/services/SelectorStateService.js +151 -113
  91. package/lib/src/services/SelectorVerificationJob.js +64 -14
  92. package/lib/src/services/metrics/AndroidMetricsSampler.js +94 -0
  93. package/lib/src/services/metrics/IOSMetricsSampler.js +120 -0
  94. package/lib/src/services/metrics/SessionMetricsService.js +238 -0
  95. package/lib/src/services/metrics/androidMetrics.js +94 -0
  96. package/lib/src/services/metrics/iosMetrics.js +26 -0
  97. package/lib/src/services/metrics/metricsBody.js +37 -0
  98. package/lib/src/services/metrics/types.js +19 -0
  99. package/lib/src/services/selector-health/access.js +66 -0
  100. package/lib/src/services/selector-health/healingTrend.js +49 -0
  101. package/lib/src/services/selector-health/people.js +27 -0
  102. package/lib/src/services/selector-health/selectorDetail.js +214 -0
  103. package/lib/src/services/selector-health/selectorKeys.js +36 -0
  104. package/lib/src/services/selector-health/selectorList.js +333 -0
  105. package/lib/src/services/selector-health/stateView.js +67 -0
  106. package/lib/test/helpers/scratch-database.js +19 -3
  107. package/lib/test/helpers/selector-health-fixture.js +216 -0
  108. package/lib/test/integration/team-visibility-sessions.spec.js +1 -0
  109. package/lib/test/unit/CleanupService.spec.js +28 -2
  110. package/lib/test/unit/dashboard-events-metrics.spec.js +146 -0
  111. package/lib/test/unit/healing-no-cost.spec.js +134 -0
  112. package/lib/test/unit/healing-state-endpoints.spec.js +70 -0
  113. package/lib/test/unit/healing-trend.spec.js +118 -0
  114. package/lib/test/unit/selector-event-model.spec.js +47 -0
  115. package/lib/test/unit/selector-health-actions.spec.js +106 -0
  116. package/lib/test/unit/selector-health-detail.spec.js +188 -0
  117. package/lib/test/unit/selector-health-list.spec.js +185 -0
  118. package/lib/test/unit/selector-state-service.spec.js +74 -1
  119. package/lib/test/unit/selector-verification-job.spec.js +41 -0
  120. package/lib/test/unit/selector-verification-missed-regression.spec.js +132 -0
  121. package/lib/test/unit/session-log-indexes.spec.js +147 -0
  122. package/lib/test/unit/session-metrics-parse.spec.js +138 -0
  123. package/lib/test/unit/session-metrics-route.spec.js +140 -0
  124. package/lib/test/unit/session-metrics-samplers.spec.js +248 -0
  125. package/lib/test/unit/session-metrics-service.spec.js +215 -0
  126. package/lib/tsconfig.tsbuildinfo +1 -1
  127. package/package.json +1 -1
  128. package/prisma/migrations/20261002120000_session_metric/migration.sql +17 -0
  129. package/prisma/migrations/20261002130000_selector_event/migration.sql +13 -0
  130. package/prisma/migrations/20261002180000_session_log_indexes/migration.sql +5 -0
  131. package/prisma/migrations/20261002190000_log_profiling_session_indexes/migration.sql +5 -0
  132. package/prisma/schema.prisma +49 -9
  133. package/schema.json +5 -0
  134. package/lib/public/assets/chevron-left-BSG6mcpo.js +0 -6
  135. package/lib/public/assets/funnel-OefHE-54.js +0 -6
  136. package/lib/public/assets/layers-a3GNz2DA.js +0 -6
  137. package/lib/public/assets/list-checks-DMrMAFDt.js +0 -6
  138. package/lib/public/assets/overview-CsPyPv10.js +0 -1
  139. package/lib/public/assets/selector-detail-page-pA913STP.js +0 -1
  140. package/lib/public/assets/selector-health-BlICzZlk.css +0 -1
  141. package/lib/public/assets/selector-health-CHLX8KqY.js +0 -16
  142. package/lib/public/assets/selector-health-page-vVkg8Mii.js +0 -16
  143. package/lib/public/assets/session-detail-page-BCD49zDi.js +0 -4
  144. package/lib/public/assets/stat-tile-BaE76cnK.js +0 -8
  145. package/lib/public/assets/time-B8FIgwPE.js +0 -1
  146. package/lib/src/profiling/AndroidAppProfiler.js +0 -213
  147. /package/lib/public/assets/{session-detail-page-Bzzc3bMl.css → line-chart-Bzzc3bMl.css} +0 -0
@@ -27,6 +27,8 @@ const prisma_1 = require("../prisma");
27
27
  const SocketServer_1 = require("./SocketServer");
28
28
  const SocketEvents_1 = require("../enums/SocketEvents");
29
29
  const logger_1 = __importDefault(require("../logger"));
30
+ const SelectorStateService_1 = require("./SelectorStateService");
31
+ const selectorKeys_1 = require("./selector-health/selectorKeys");
30
32
  const log = logger_1.default.scope('SelectorVerification');
31
33
  /**
32
34
  * Threshold of distinct clean build_ids required to promote a Pending
@@ -50,24 +52,68 @@ let SelectorVerificationJob = class SelectorVerificationJob {
50
52
  this.socket = socket !== null && socket !== void 0 ? socket : typedi_1.Container.get(SocketServer_1.SocketServer);
51
53
  }
52
54
  /**
53
- * Process every row currently in `'pending'`. Each row is wrapped in its
54
- * own try/catch so a query failure on one selector can't take down the
55
- * remainder of the run.
55
+ * Process every row currently in `'pending'`, then look for a heal of every
56
+ * `'resolved'` one. Each row is wrapped in its own try/catch so a query
57
+ * failure on one selector can't take down the remainder of the run.
56
58
  */
57
59
  run() {
58
60
  return __awaiter(this, void 0, void 0, function* () {
59
- var _a;
61
+ var _a, _b;
60
62
  const pending = yield this.prisma.selectorState.findMany({
61
63
  where: { status: 'pending' },
62
64
  });
63
65
  for (const row of pending) {
64
66
  try {
65
- yield this.processOne(row);
67
+ if (!(yield this.brokeAgain(row)))
68
+ yield this.processOne(row);
66
69
  }
67
70
  catch (err) {
68
71
  log.error(`[${row.id}] processing failed (${row.original_strategy}=${row.original_selector}): ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
69
72
  }
70
73
  }
74
+ const resolved = yield this.prisma.selectorState.findMany({
75
+ where: { status: 'resolved' },
76
+ });
77
+ for (const row of resolved) {
78
+ try {
79
+ yield this.brokeAgain(row);
80
+ }
81
+ catch (err) {
82
+ log.error(`[${row.id}] heal check failed (${row.original_strategy}=${row.original_selector}): ${(_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : err}`);
83
+ }
84
+ }
85
+ });
86
+ }
87
+ /**
88
+ * A heal since the selector was marked fixed means it broke again. The heal
89
+ * write path says so at once (`onHealRecorded`), but that call can fail and
90
+ * isn't tried again, and the clean-build count below doesn't look at healed
91
+ * builds: a selector with three clean builds was promoted however often it
92
+ * healed in others. So the job checks too, and makes the same change.
93
+ */
94
+ brokeAgain(row) {
95
+ return __awaiter(this, void 0, void 0, function* () {
96
+ if (!row.fixed_at)
97
+ return false;
98
+ const heal = yield this.prisma.sessionLog.findFirst({
99
+ where: {
100
+ AND: [
101
+ { is_healed: true, createdAt: { gte: row.fixed_at } },
102
+ (0, selectorKeys_1.tupleWhere)(row.original_strategy, row.original_selector),
103
+ ],
104
+ },
105
+ orderBy: { createdAt: 'desc' },
106
+ select: { session_id: true },
107
+ });
108
+ if (!heal)
109
+ return false;
110
+ log.warn(`[${row.id}] healed again after being marked fixed, unnoticed when it healed (${row.original_strategy}=${row.original_selector}, session=${heal.session_id})`);
111
+ yield new SelectorStateService_1.SelectorStateService(this.prisma, this.socket).onHealRecorded({
112
+ strategy: row.original_strategy,
113
+ selector: row.original_selector,
114
+ sessionId: heal.session_id,
115
+ });
116
+ return true;
71
117
  });
72
118
  }
73
119
  processOne(row) {
@@ -109,15 +155,19 @@ let SelectorVerificationJob = class SelectorVerificationJob {
109
155
  promoteToResolved(row) {
110
156
  return __awaiter(this, void 0, void 0, function* () {
111
157
  const now = new Date();
112
- const updated = yield this.prisma.selectorState.update({
113
- where: { id: row.id },
114
- data: {
115
- status: 'resolved',
116
- resolved_at: now,
117
- clean_builds_count: CLEAN_BUILDS_TO_RESOLVE,
118
- last_event_at: now,
119
- },
120
- });
158
+ const updated = yield this.prisma.$transaction((tx) => __awaiter(this, void 0, void 0, function* () {
159
+ const promoted = yield tx.selectorState.update({
160
+ where: { id: row.id },
161
+ data: {
162
+ status: 'resolved',
163
+ resolved_at: now,
164
+ clean_builds_count: CLEAN_BUILDS_TO_RESOLVE,
165
+ last_event_at: now,
166
+ },
167
+ });
168
+ yield tx.selectorEvent.create((0, SelectorStateService_1.selectorEventData)({ strategy: row.original_strategy, selector: row.original_selector }, 'verified'));
169
+ return promoted;
170
+ }));
121
171
  log.info(`[${row.id}] promoted to resolved (${row.original_strategy}=${row.original_selector})`);
122
172
  this.socket.emitToDashboard(SocketEvents_1.SocketEvents.SELECTOR_RESOLVED, this.serialize(updated));
123
173
  });
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.AndroidMetricsSampler = void 0;
13
+ const androidMetrics_1 = require("./androidMetrics");
14
+ const types_1 = require("./types");
15
+ /**
16
+ * Samples an Android phone every SAMPLE_INTERVAL_MS with one `adb shell`
17
+ * call. The app is the session's package, else the app in the foreground.
18
+ * Samples never overlap: the next is scheduled when one finishes.
19
+ */
20
+ class AndroidMetricsSampler {
21
+ constructor(opts) {
22
+ this.opts = opts;
23
+ this.stopped = false;
24
+ this.prev = null;
25
+ this.failures = 0;
26
+ this.foreground = null;
27
+ this.pkg = (0, androidMetrics_1.isSafePackage)(opts.appPackage) ? opts.appPackage : null;
28
+ }
29
+ start() {
30
+ this.schedule(0);
31
+ }
32
+ stop() {
33
+ return __awaiter(this, void 0, void 0, function* () {
34
+ this.stopped = true;
35
+ if (this.timer)
36
+ clearTimeout(this.timer);
37
+ });
38
+ }
39
+ schedule(delay) {
40
+ var _a, _b;
41
+ if (this.stopped)
42
+ return;
43
+ this.timer = setTimeout(() => {
44
+ void this.sample().finally(() => this.schedule(types_1.SAMPLE_INTERVAL_MS));
45
+ }, delay);
46
+ (_b = (_a = this.timer).unref) === null || _b === void 0 ? void 0 : _b.call(_a);
47
+ }
48
+ now() {
49
+ return this.opts.now ? this.opts.now() : Date.now();
50
+ }
51
+ sample() {
52
+ return __awaiter(this, void 0, void 0, function* () {
53
+ var _a;
54
+ try {
55
+ const appId = yield this.appId();
56
+ const reading = (0, androidMetrics_1.parseAndroidReading)(yield this.opts.shell((0, androidMetrics_1.androidSampleCommand)(appId)));
57
+ if (!reading)
58
+ throw new Error('unreadable /proc output');
59
+ const sample = (0, androidMetrics_1.androidSample)(this.prev, reading, appId, this.now());
60
+ this.prev = reading;
61
+ this.failures = 0;
62
+ if (!this.stopped)
63
+ this.opts.hooks.onSample(sample);
64
+ }
65
+ catch (err) {
66
+ this.failures += 1;
67
+ if (this.failures >= types_1.MAX_CONSECUTIVE_FAILURES && !this.stopped) {
68
+ this.stopped = true;
69
+ this.opts.hooks.onGiveUp((_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : String(err));
70
+ }
71
+ }
72
+ });
73
+ }
74
+ appId() {
75
+ return __awaiter(this, void 0, void 0, function* () {
76
+ if (this.pkg)
77
+ return this.pkg;
78
+ const now = this.now();
79
+ if (this.foreground && now - this.foreground.at < types_1.FOREGROUND_REFRESH_MS) {
80
+ return this.foreground.pkg;
81
+ }
82
+ let pkg = null;
83
+ try {
84
+ pkg = (0, androidMetrics_1.parseForegroundPackage)(yield this.opts.shell('dumpsys activity activities'));
85
+ }
86
+ catch (_a) {
87
+ // Device figures still count; the app waits for the next lookup.
88
+ }
89
+ this.foreground = { pkg, at: now };
90
+ return pkg;
91
+ });
92
+ }
93
+ }
94
+ exports.AndroidMetricsSampler = AndroidMetricsSampler;
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.IOSMetricsSampler = void 0;
13
+ const iosMetrics_1 = require("./iosMetrics");
14
+ const types_1 = require("./types");
15
+ /** A reading older than this is not reported again. */
16
+ const STALE_MS = 2 * types_1.SAMPLE_INTERVAL_MS;
17
+ /**
18
+ * Device CPU of an iPhone, from a long-running `ios sysmontap` through the
19
+ * phone's tunnel (borrowed, and asked for again so it outlives the 2-minute
20
+ * idle stop). Reports the latest reading every SAMPLE_INTERVAL_MS.
21
+ */
22
+ class IOSMetricsSampler {
23
+ constructor(opts) {
24
+ this.opts = opts;
25
+ this.stopped = false;
26
+ this.failures = 0;
27
+ this.timers = [];
28
+ }
29
+ start() {
30
+ void this.launch();
31
+ this.every(types_1.SAMPLE_INTERVAL_MS, () => this.report());
32
+ this.every(types_1.TUNNEL_RENEW_MS, () => {
33
+ void this.opts.tunnels.borrow(this.opts.udid).catch(() => null);
34
+ });
35
+ }
36
+ stop() {
37
+ return __awaiter(this, void 0, void 0, function* () {
38
+ var _a;
39
+ this.stopped = true;
40
+ this.timers.forEach((t) => clearInterval(t));
41
+ if (this.relaunch)
42
+ clearTimeout(this.relaunch);
43
+ (_a = this.proc) === null || _a === void 0 ? void 0 : _a.kill('SIGTERM');
44
+ this.proc = undefined;
45
+ });
46
+ }
47
+ every(ms, fn) {
48
+ var _a;
49
+ const t = setInterval(fn, ms);
50
+ (_a = t.unref) === null || _a === void 0 ? void 0 : _a.call(t);
51
+ this.timers.push(t);
52
+ }
53
+ launch() {
54
+ return __awaiter(this, void 0, void 0, function* () {
55
+ var _a, _b;
56
+ if (this.stopped)
57
+ return;
58
+ // Below iOS 17 there is no tunnel (null), and sysmontap needs none.
59
+ yield this.opts.tunnels.borrow(this.opts.udid).catch(() => null);
60
+ if (this.stopped)
61
+ return;
62
+ const proc = this.opts.spawnSysmontap(this.opts.tunnels.envFor(this.opts.udid));
63
+ this.proc = proc;
64
+ let rest = '';
65
+ const read = (chunk) => {
66
+ var _a;
67
+ const lines = (rest + chunk.toString()).split('\n');
68
+ rest = (_a = lines.pop()) !== null && _a !== void 0 ? _a : '';
69
+ for (const line of lines) {
70
+ const cpu = (0, iosMetrics_1.parseSysmontapCpu)(line);
71
+ if (cpu !== null) {
72
+ this.latest = { cpu, at: Date.now() };
73
+ this.failures = 0;
74
+ }
75
+ }
76
+ };
77
+ (_a = proc.stdout) === null || _a === void 0 ? void 0 : _a.on('data', read);
78
+ (_b = proc.stderr) === null || _b === void 0 ? void 0 : _b.on('data', read);
79
+ let ended = false;
80
+ const end = () => {
81
+ if (ended)
82
+ return;
83
+ ended = true;
84
+ this.exited(proc);
85
+ };
86
+ proc.once('exit', end);
87
+ proc.once('error', end);
88
+ });
89
+ }
90
+ exited(proc) {
91
+ var _a, _b;
92
+ if (this.proc === proc)
93
+ this.proc = undefined;
94
+ if (this.stopped)
95
+ return;
96
+ this.failures += 1;
97
+ if (this.failures >= types_1.MAX_CONSECUTIVE_FAILURES) {
98
+ void this.stop();
99
+ this.opts.hooks.onGiveUp('ios sysmontap kept exiting');
100
+ return;
101
+ }
102
+ const delay = Math.min(30000, types_1.SAMPLE_INTERVAL_MS * 2 ** (this.failures - 1));
103
+ this.relaunch = setTimeout(() => void this.launch(), delay);
104
+ (_b = (_a = this.relaunch).unref) === null || _b === void 0 ? void 0 : _b.call(_a);
105
+ }
106
+ report() {
107
+ if (this.stopped || !this.latest || Date.now() - this.latest.at > STALE_MS)
108
+ return;
109
+ this.opts.hooks.onSample({
110
+ at: Date.now(),
111
+ deviceCpuPct: this.latest.cpu,
112
+ deviceMemMb: null,
113
+ deviceMemTotalMb: null,
114
+ appCpuPct: null,
115
+ appMemMb: null,
116
+ appId: null,
117
+ });
118
+ }
119
+ }
120
+ exports.IOSMetricsSampler = IOSMetricsSampler;
@@ -0,0 +1,238 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
9
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
10
+ return new (P || (P = Promise))(function (resolve, reject) {
11
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
12
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
13
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
14
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
15
+ });
16
+ };
17
+ var __importDefault = (this && this.__importDefault) || function (mod) {
18
+ return (mod && mod.__esModule) ? mod : { "default": mod };
19
+ };
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.SessionMetricsService = void 0;
22
+ const child_process_1 = require("child_process");
23
+ const util_1 = require("util");
24
+ const typedi_1 = require("typedi");
25
+ const logger_1 = __importDefault(require("../../logger"));
26
+ const prisma_1 = require("../../prisma");
27
+ const PluginContext_1 = require("../../PluginContext");
28
+ const localDeviceHosts_1 = require("../../device-managers/localDeviceHosts");
29
+ const device_managers_1 = require("../../device-managers");
30
+ const AndroidDeviceManager_1 = __importDefault(require("../../device-managers/AndroidDeviceManager"));
31
+ const IOSTunnels_1 = require("../../device-managers/ios/IOSTunnels");
32
+ const goIosBinary_1 = require("../../device-managers/ios/goIosBinary");
33
+ const ProcessRegistry_1 = require("../ProcessRegistry");
34
+ const androidMetrics_1 = require("./androidMetrics");
35
+ const AndroidMetricsSampler_1 = require("./AndroidMetricsSampler");
36
+ const IOSMetricsSampler_1 = require("./IOSMetricsSampler");
37
+ const types_1 = require("./types");
38
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
39
+ /** One adb call of a sample; one that hangs this long is a failed sample. */
40
+ const ADB_TIMEOUT_MS = 5000;
41
+ /**
42
+ * CPU and memory for the session page's Performance panel. A sampler per
43
+ * session on this server's own phones, from EventManager's start (once the
44
+ * session's row exists) to its stop; samples are written every
45
+ * FLUSH_INTERVAL_MS. Sampling never fails a session.
46
+ */
47
+ let SessionMetricsService = class SessionMetricsService {
48
+ constructor() {
49
+ this.log = logger_1.default.scope('SessionMetrics');
50
+ this.running = new Map();
51
+ }
52
+ /** Whether a session on this phone is sampled. */
53
+ appliesTo(device) {
54
+ var _a, _b;
55
+ if (!device)
56
+ return false;
57
+ const ctx = this.context();
58
+ if (((_a = ctx.pluginArgs) === null || _a === void 0 ? void 0 : _a.sessionMetrics) === false)
59
+ return false;
60
+ const platform = String((_b = device.platform) !== null && _b !== void 0 ? _b : '').toLowerCase();
61
+ // An emulator's /proc reads like a phone's; a simulator has no sysmontap.
62
+ if (platform !== 'android' && !(platform === 'ios' && device.realDevice === true)) {
63
+ return false;
64
+ }
65
+ return (0, localDeviceHosts_1.isOwnDevice)((0, localDeviceHosts_1.localDeviceHosts)(ctx.pluginArgs, ctx.port), ctx.nodeId, device);
66
+ }
67
+ start({ sessionId, device, capabilities }) {
68
+ var _a, _b;
69
+ if (this.running.has(sessionId) || !this.appliesTo(device))
70
+ return;
71
+ const hooks = {
72
+ onSample: (s) => { var _a; return (_a = this.running.get(sessionId)) === null || _a === void 0 ? void 0 : _a.buffer.push(s); },
73
+ onGiveUp: (reason) => {
74
+ const entry = this.running.get(sessionId);
75
+ if (entry)
76
+ entry.gaveUp = true;
77
+ this.log.warn(`[${sessionId}] Stopped sampling ${device.udid}: ${reason}`);
78
+ },
79
+ };
80
+ let sampler;
81
+ try {
82
+ sampler = this.samplerFor(sessionId, device, capabilities, hooks);
83
+ }
84
+ catch (err) {
85
+ this.log.warn(`[${sessionId}] Can't sample ${device.udid}: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
86
+ return;
87
+ }
88
+ const flushTimer = setInterval(() => void this.flush(sessionId), types_1.FLUSH_INTERVAL_MS);
89
+ (_b = flushTimer.unref) === null || _b === void 0 ? void 0 : _b.call(flushTimer);
90
+ this.running.set(sessionId, {
91
+ sampler,
92
+ buffer: [],
93
+ flushTimer,
94
+ writing: Promise.resolve(),
95
+ gaveUp: false,
96
+ });
97
+ sampler.start();
98
+ this.log.info(`[${sessionId}] Sampling CPU and memory on ${device.udid}`);
99
+ }
100
+ /** Whether this server is sampling a running session, stopped after giving up, or never did. */
101
+ recordingState(sessionId) {
102
+ const entry = this.running.get(sessionId);
103
+ if (!entry)
104
+ return 'off';
105
+ return entry.gaveUp ? 'stopped' : 'sampling';
106
+ }
107
+ /** Stops the session's sampler and writes what it buffered. Idempotent. */
108
+ stop(sessionId) {
109
+ return __awaiter(this, void 0, void 0, function* () {
110
+ const entry = this.running.get(sessionId);
111
+ if (!entry)
112
+ return;
113
+ this.running.delete(sessionId);
114
+ clearInterval(entry.flushTimer);
115
+ yield entry.sampler.stop().catch(() => undefined);
116
+ yield this.write(sessionId, entry);
117
+ });
118
+ }
119
+ flush(sessionId) {
120
+ return __awaiter(this, void 0, void 0, function* () {
121
+ const entry = this.running.get(sessionId);
122
+ if (entry)
123
+ yield this.write(sessionId, entry);
124
+ });
125
+ }
126
+ /** On failure the samples wait for the next write; the newest MAX_BUFFERED_SAMPLES are kept. */
127
+ write(sessionId, entry) {
128
+ entry.writing = entry.writing.then(() => __awaiter(this, void 0, void 0, function* () {
129
+ var _a;
130
+ const batch = entry.buffer.splice(0);
131
+ if (batch.length === 0)
132
+ return;
133
+ try {
134
+ yield this.writeSamples(sessionId, batch);
135
+ }
136
+ catch (err) {
137
+ entry.buffer.unshift(...batch);
138
+ const over = entry.buffer.length - types_1.MAX_BUFFERED_SAMPLES;
139
+ if (over > 0)
140
+ entry.buffer.splice(0, over);
141
+ this.log.debug(`[${sessionId}] Writing ${batch.length} samples failed: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
142
+ }
143
+ }));
144
+ return entry.writing;
145
+ }
146
+ // ---------------------------------------------------------------------
147
+ // Seams. Overridden by tests; the defaults are the real thing.
148
+ // ---------------------------------------------------------------------
149
+ context() {
150
+ return typedi_1.Container.get(PluginContext_1.PluginContext);
151
+ }
152
+ writeSamples(sessionId, samples) {
153
+ return __awaiter(this, void 0, void 0, function* () {
154
+ yield prisma_1.prisma.sessionMetric.createMany({
155
+ data: samples.map((s) => ({
156
+ session_id: sessionId,
157
+ at: s.at,
158
+ device_cpu_pct: s.deviceCpuPct,
159
+ device_mem_mb: s.deviceMemMb,
160
+ device_mem_total: s.deviceMemTotalMb,
161
+ app_cpu_pct: s.appCpuPct,
162
+ app_mem_mb: s.appMemMb,
163
+ app_id: s.appId,
164
+ })),
165
+ });
166
+ });
167
+ }
168
+ samplerFor(sessionId, device, capabilities, hooks) {
169
+ if (String(device.platform).toLowerCase() === 'android') {
170
+ let adb;
171
+ return new AndroidMetricsSampler_1.AndroidMetricsSampler({
172
+ appPackage: (0, androidMetrics_1.appPackageOf)(capabilities),
173
+ hooks,
174
+ shell: (command) => __awaiter(this, void 0, void 0, function* () {
175
+ if (!adb)
176
+ adb = this.adbCommand(device.udid);
177
+ try {
178
+ const { path, base } = yield adb;
179
+ return yield this.execAdb(path, [...base, 'shell', command]);
180
+ }
181
+ catch (err) {
182
+ adb = undefined; // looked up again next time
183
+ throw err;
184
+ }
185
+ }),
186
+ });
187
+ }
188
+ return new IOSMetricsSampler_1.IOSMetricsSampler({
189
+ udid: device.udid,
190
+ tunnels: typedi_1.Container.get(IOSTunnels_1.IOSTunnels),
191
+ hooks,
192
+ spawnSysmontap: (env) => {
193
+ const proc = (0, child_process_1.spawn)((0, goIosBinary_1.goIosBinaryPath)(), ['sysmontap', '--udid', device.udid], {
194
+ env,
195
+ stdio: ['ignore', 'pipe', 'pipe'],
196
+ });
197
+ typedi_1.Container.get(ProcessRegistry_1.ProcessRegistry).track({
198
+ sessionId,
199
+ udid: device.udid,
200
+ kind: 'other',
201
+ process: proc,
202
+ });
203
+ return proc;
204
+ },
205
+ });
206
+ }
207
+ /**
208
+ * The resolved adb, and the arguments that reach this phone: the host's,
209
+ * then `-s <udid>`. The shared appium-adb instance names no device (its
210
+ * `shell()` adds `-s` only after `setDeviceId`), so with several phones on
211
+ * one adb server a plain `adb shell` fails.
212
+ */
213
+ adbCommand(udid) {
214
+ return __awaiter(this, void 0, void 0, function* () {
215
+ var _a;
216
+ const managers = yield typedi_1.Container.get(device_managers_1.XenonManager).deviceInstances();
217
+ const android = managers.find((m) => m instanceof AndroidDeviceManager_1.default);
218
+ if (!android)
219
+ throw new Error('no Android device manager');
220
+ const adb = (yield android.getAdbForDevice(udid));
221
+ const path = (_a = adb === null || adb === void 0 ? void 0 : adb.executable) === null || _a === void 0 ? void 0 : _a.path;
222
+ if (!path)
223
+ throw new Error(`adb executable path not resolved for ${udid}`);
224
+ const hostArgs = adb.adbHost && adb.adbPort ? ['-H', adb.adbHost, '-P', String(adb.adbPort)] : [];
225
+ return { path, base: [...hostArgs, '-s', udid] };
226
+ });
227
+ }
228
+ execAdb(path, args) {
229
+ return __awaiter(this, void 0, void 0, function* () {
230
+ const { stdout } = yield execFileAsync(path, args, { timeout: ADB_TIMEOUT_MS });
231
+ return stdout;
232
+ });
233
+ }
234
+ };
235
+ exports.SessionMetricsService = SessionMetricsService;
236
+ exports.SessionMetricsService = SessionMetricsService = __decorate([
237
+ (0, typedi_1.Service)()
238
+ ], SessionMetricsService);
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isSafePackage = isSafePackage;
4
+ exports.appPackageOf = appPackageOf;
5
+ exports.androidSampleCommand = androidSampleCommand;
6
+ exports.parseAndroidReading = parseAndroidReading;
7
+ exports.androidSample = androidSample;
8
+ exports.parseForegroundPackage = parseForegroundPackage;
9
+ const types_1 = require("./types");
10
+ /**
11
+ * An Android package name: two or more dot-separated identifiers. A session's
12
+ * appPackage is client input and goes into a shell command, so nothing else
13
+ * is ever used.
14
+ */
15
+ function isSafePackage(pkg) {
16
+ return typeof pkg === 'string' && /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/.test(pkg);
17
+ }
18
+ /** A session's appPackage capability, in either spelling. Not yet validated. */
19
+ function appPackageOf(caps) {
20
+ var _a;
21
+ return (_a = caps === null || caps === void 0 ? void 0 : caps['appium:appPackage']) !== null && _a !== void 0 ? _a : caps === null || caps === void 0 ? void 0 : caps.appPackage;
22
+ }
23
+ /** The one shell command a sample runs: device CPU and memory, then the app's. */
24
+ function androidSampleCommand(pkg) {
25
+ const device = 'head -1 /proc/stat; grep -e ^MemTotal: -e ^MemAvailable: /proc/meminfo';
26
+ if (pkg === null)
27
+ return device;
28
+ if (!isSafePackage(pkg))
29
+ throw new Error(`not a package name: ${pkg}`);
30
+ return (`${device}; p=$(pidof ${pkg} | cut -d' ' -f1); echo "pid=$p"; ` +
31
+ 'if [ -n "$p" ]; then echo "stat=$(cut -d" " -f14,15 /proc/$p/stat)"; grep VmRSS /proc/$p/status; fi');
32
+ }
33
+ const num = (m) => (m ? Number(m[1]) : null);
34
+ function parseAndroidReading(out) {
35
+ var _a;
36
+ const cpu = out.match(/^cpu\s+([\d\s]+)$/m);
37
+ if (!cpu)
38
+ return null;
39
+ const f = cpu[1].trim().split(/\s+/).map(Number);
40
+ if (f.length < 4 || f.some((n) => !Number.isFinite(n)))
41
+ return null;
42
+ // user nice system idle iowait irq softirq steal; guest time is already in user.
43
+ const cpuTotal = f.slice(0, 8).reduce((a, b) => a + b, 0);
44
+ const cpuIdle = f[3] + ((_a = f[4]) !== null && _a !== void 0 ? _a : 0);
45
+ const pid = num(out.match(/^pid=(\d+)\s*$/m));
46
+ const stat = out.match(/^stat=(\d+) (\d+)\s*$/m);
47
+ return {
48
+ cpuTotal,
49
+ cpuIdle,
50
+ memTotalKb: num(out.match(/^MemTotal:\s+(\d+) kB/m)),
51
+ memAvailableKb: num(out.match(/^MemAvailable:\s+(\d+) kB/m)),
52
+ pid,
53
+ appJiffies: pid !== null && stat ? Number(stat[1]) + Number(stat[2]) : null,
54
+ appRssKb: pid !== null ? num(out.match(/^VmRSS:\s+(\d+) kB/m)) : null,
55
+ };
56
+ }
57
+ const pct = (share) => (0, types_1.round1)((0, types_1.clamp)(share * 100, 0, 100));
58
+ const mb = (kb) => (0, types_1.round1)(kb / 1024);
59
+ /**
60
+ * A sample from this reading and the one before it. CPU needs both: the
61
+ * first sample has none, and the app's needs the same process in both, so a
62
+ * restarted app shows no CPU for one sample rather than a spike.
63
+ */
64
+ function androidSample(prev, cur, appId, at) {
65
+ let deviceCpuPct = null;
66
+ let appCpuPct = null;
67
+ const dTotal = prev ? cur.cpuTotal - prev.cpuTotal : 0;
68
+ if (prev && dTotal > 0) {
69
+ deviceCpuPct = pct(1 - (cur.cpuIdle - prev.cpuIdle) / dTotal);
70
+ if (cur.pid !== null &&
71
+ cur.pid === prev.pid &&
72
+ cur.appJiffies !== null &&
73
+ prev.appJiffies !== null) {
74
+ appCpuPct = pct((cur.appJiffies - prev.appJiffies) / dTotal);
75
+ }
76
+ }
77
+ const hasApp = cur.pid !== null;
78
+ return {
79
+ at,
80
+ deviceCpuPct,
81
+ deviceMemMb: cur.memTotalKb !== null && cur.memAvailableKb !== null
82
+ ? mb(cur.memTotalKb - cur.memAvailableKb)
83
+ : null,
84
+ deviceMemTotalMb: cur.memTotalKb !== null ? mb(cur.memTotalKb) : null,
85
+ appCpuPct,
86
+ appMemMb: hasApp && cur.appRssKb !== null ? mb(cur.appRssKb) : null,
87
+ appId: hasApp ? appId : null,
88
+ };
89
+ }
90
+ /** The foreground app from `dumpsys activity activities`, before and after Android 10. */
91
+ function parseForegroundPackage(dumpsys) {
92
+ const m = dumpsys.match(/(?:topResumedActivity|mResumedActivity)[:=]\s*ActivityRecord\{\S+ u\d+ ([^\s/]+)\//);
93
+ return m && isSafePackage(m[1]) ? m[1] : null;
94
+ }