@xenon-device-management/xenon 2.10.0 → 2.11.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.
Files changed (114) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-LzfXiiZJ.js → AnnotationOverlay-BQPQ5bPF.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-BzFDp4mK.js → ApiKeyGate-Bd7Pumwv.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-BpgWvmZo.js → BugReportButton-iC7fxPxl.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-CCB-egSH.js → DeviceMosaicView-CNdES7nm.js} +1 -1
  6. package/lib/public/assets/{EmptyState-DkUVmANo.js → EmptyState-wZeJbGhN.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-mbfnfTql.js → FieldGroup-tpRfmhlE.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BH5usz4Y.js → FilterMenu-BZwOppId.js} +1 -1
  9. package/lib/public/assets/{Menu-Cbo9mdTk.js → Menu-CahoAx8L.js} +1 -1
  10. package/lib/public/assets/{Modal-D6XfQHPr.js → Modal-mcM5W_Ji.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CmFPkWqH.js → RecordingPage-B4dFBndn.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-CSlKk2qC.js → RecordingsPage-BOez3f_9.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-BFW9Dlqd.js → SegmentedControl-qMSiMN1d.js} +1 -1
  14. package/lib/public/assets/{SettingCard-C4DioTTi.js → SettingCard-rcPtJ7HG.js} +1 -1
  15. package/lib/public/assets/{Table-DrKjlL00.js → Table-J3uognu0.js} +1 -1
  16. package/lib/public/assets/{activity-C9bE7Jm8.js → activity-_5pNSfKT.js} +1 -1
  17. package/lib/public/assets/{ai-settings-DRYic4oO.js → ai-settings-DTPRbkei.js} +1 -1
  18. package/lib/public/assets/{api-keys-CaQU0g7c.js → api-keys-CxkIUo76.js} +1 -1
  19. package/lib/public/assets/apps-BQKfuN3J.css +1 -0
  20. package/lib/public/assets/{apps-c1gUFmmK.js → apps-Bv0pAfxz.js} +2 -2
  21. package/lib/public/assets/{arrow-left-DMvAWA8r.js → arrow-left-uY7F6z1K.js} +1 -1
  22. package/lib/public/assets/{arrow-right-BEQgxmKT.js → arrow-right-BbAm_2Wa.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-Ba0e2GeU.js → arrow-up-right-x4H5NuvE.js} +1 -1
  24. package/lib/public/assets/{auth-shell-COODQnc7.js → auth-shell-CtMFhbnC.js} +2 -2
  25. package/lib/public/assets/{builds-page-DZQgWBUs.js → builds-page-B_O5uBSZ.js} +1 -1
  26. package/lib/public/assets/{button-WW9j43wR.js → button-CQoMQEj_.js} +1 -1
  27. package/lib/public/assets/{calendar-COUs9VxF.js → calendar-BZkpCqlL.js} +1 -1
  28. package/lib/public/assets/{check-D8ifHIJR.js → check-CAB6RYuc.js} +1 -1
  29. package/lib/public/assets/{chevron-right-BjKVUlB_.js → chevron-right-ec0unu3u.js} +1 -1
  30. package/lib/public/assets/{circle-check-CfP79QE2.js → circle-check-Dfg-gpjg.js} +1 -1
  31. package/lib/public/assets/{circle-x-BZbFCixZ.js → circle-x-cbPq6Btp.js} +1 -1
  32. package/lib/public/assets/{clock-BuTm35Qg.js → clock-DU9cBLi8.js} +1 -1
  33. package/lib/public/assets/{copy-W00zUBwN.js → copy-VM9kWPpE.js} +1 -1
  34. package/lib/public/assets/{device-explorer-BEpS-Y-_.js → device-explorer-ai0usQ8m.js} +1 -1
  35. package/lib/public/assets/{download-CGz-MS3y.js → download-WgW4AVid.js} +1 -1
  36. package/lib/public/assets/{forgot-password-DG7SqVgU.js → forgot-password-BGRpjTtt.js} +1 -1
  37. package/lib/public/assets/{index-EkaQM1DK.js → index-C33GjwPr.js} +2 -2
  38. package/lib/public/assets/{index-DWHTp1YP.js → index-DNDdHt3h.js} +1 -1
  39. package/lib/public/assets/{input-ChaTXeCq.js → input-DwiVcO3Q.js} +1 -1
  40. package/lib/public/assets/{keycodes-BhbgeW_r.js → keycodes-CvCtpVJD.js} +1 -1
  41. package/lib/public/assets/{line-chart-9P3RxScy.js → line-chart-PE5e9PeN.js} +1 -1
  42. package/lib/public/assets/{list-checks-_23IIEEL.js → list-checks-Bgn9GEEO.js} +1 -1
  43. package/lib/public/assets/{lock-B2JaqqRI.js → lock-Cb7My6hc.js} +1 -1
  44. package/lib/public/assets/{login-BEWIfLfC.js → login-CDmyzSpl.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-BGJIJQ-k.js → maintenance-settings-Bq6pKoBt.js} +1 -1
  46. package/lib/public/assets/{monitor-BrwMY4G0.js → monitor-BZSwrnPV.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-DsWDMyrY.js → mouse-pointer-2-BbFj8y8i.js} +1 -1
  48. package/lib/public/assets/{network-DfIhNVJv.js → network-DpwYGB0-.js} +1 -1
  49. package/lib/public/assets/{overview-B1mePY2_.js → overview-B4_IO-X8.js} +1 -1
  50. package/lib/public/assets/{page-header-DoD3TOzZ.js → page-header-BpLEuo7i.js} +1 -1
  51. package/lib/public/assets/{plus-BGvWvpBB.js → plus-DHZS0EGU.js} +1 -1
  52. package/lib/public/assets/{profile-page-CvV5aWvl.js → profile-page-DsHDIq-Y.js} +1 -1
  53. package/lib/public/assets/{reset-password-CGVd31LR.js → reset-password-DuUjhw_l.js} +1 -1
  54. package/lib/public/assets/{runbook-page-Bq2cvwb5.js → runbook-page-BI_ayvLd.js} +1 -1
  55. package/lib/public/assets/{select-CTcD4jWR.js → select-xiba9PSx.js} +1 -1
  56. package/lib/public/assets/selector-detail-redirect-Emz2obK_.js +1 -0
  57. package/lib/public/assets/selector-health-page-zpwrgalC.js +11 -0
  58. package/lib/public/assets/session-detail-page-BhlTJ1Oq.js +4 -0
  59. package/lib/public/assets/{settings-C-EnGIqs.js → settings-B-mabJFm.js} +1 -1
  60. package/lib/public/assets/{stat-tile-BY0RDTRm.js → stat-tile-lT8WcPgu.js} +1 -1
  61. package/lib/public/assets/{tablet-B4cFM-5B.js → tablet-DM8dp75P.js} +1 -1
  62. package/lib/public/assets/{teams-BFe-RpYd.js → teams-CRkb5-Fe.js} +1 -1
  63. package/lib/public/assets/{trash-2-CJCKBOP9.js → trash-2-Dxgryn4o.js} +1 -1
  64. package/lib/public/assets/{upload-DidWkbld.js → upload-Ci0vz0BP.js} +1 -1
  65. package/lib/public/assets/{use-builds-data-fnfxatOd.js → use-builds-data-DaaqMjjL.js} +1 -1
  66. package/lib/public/assets/{use-password-reset-mode-iPen3tmC.js → use-password-reset-mode-BvIyZtaF.js} +1 -1
  67. package/lib/public/assets/{users-CrcjmRdf.js → users-BT8qx3ea.js} +1 -1
  68. package/lib/public/assets/{users-DBUv0CJx.js → users-CWYHk75q.js} +1 -1
  69. package/lib/public/assets/{video-off-eh69-FKB.js → video-off-BvY08g6I.js} +1 -1
  70. package/lib/public/assets/view-state-83Z1gFME.js +1 -0
  71. package/lib/public/assets/{webhook-settings-CQoSshBn.js → webhook-settings-aHIlSJFP.js} +1 -1
  72. package/lib/public/assets/{zap-CICtWiLn.js → zap-qG2Li6rI.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/src/app/index.js +2 -0
  75. package/lib/src/dashboard/event-manager.js +4 -6
  76. package/lib/src/gateway/nodeSessionMetrics.js +39 -0
  77. package/lib/src/gateway/nodeSessionStatus.js +32 -23
  78. package/lib/src/generated/client/edge.js +2 -2
  79. package/lib/src/generated/client/index.js +2 -2
  80. package/lib/src/generated/client/package.json +1 -1
  81. package/lib/src/generated/client/schema.prisma +6 -0
  82. package/lib/src/plugin.js +3 -0
  83. package/lib/src/services/SelectorVerificationJob.js +50 -5
  84. package/lib/src/services/SessionLifecycleService.js +16 -0
  85. package/lib/src/services/metrics/NodeMetricsCollector.js +114 -0
  86. package/lib/src/services/metrics/NodeMetricsStore.js +76 -0
  87. package/lib/src/services/metrics/SessionMetricsService.js +71 -14
  88. package/lib/src/services/metrics/nodeMetrics.js +92 -0
  89. package/lib/src/services/selector-health/selectorDetail.js +36 -20
  90. package/lib/src/sessions/RemoteSession.js +32 -0
  91. package/lib/src/sessions/SessionManager.js +63 -0
  92. package/lib/test/helpers/scratch-database.js +17 -3
  93. package/lib/test/unit/dashboard-events-metrics.spec.js +10 -0
  94. package/lib/test/unit/hub-node-session-metrics.spec.js +330 -0
  95. package/lib/test/unit/node-metrics-collector.spec.js +226 -0
  96. package/lib/test/unit/node-metrics-store.spec.js +70 -0
  97. package/lib/test/unit/node-session-metrics-route.spec.js +97 -0
  98. package/lib/test/unit/selector-health-detail.spec.js +57 -0
  99. package/lib/test/unit/selector-verification-job.spec.js +3 -0
  100. package/lib/test/unit/selector-verification-missed-regression.spec.js +132 -0
  101. package/lib/test/unit/session-log-indexes.spec.js +147 -0
  102. package/lib/test/unit/session-metrics-lifecycle.spec.js +55 -0
  103. package/lib/test/unit/session-metrics-service.spec.js +97 -0
  104. package/lib/test/unit/session-recovery-metrics.spec.js +94 -0
  105. package/lib/tsconfig.tsbuildinfo +1 -1
  106. package/package.json +1 -1
  107. package/prisma/migrations/20261002180000_session_log_indexes/migration.sql +5 -0
  108. package/prisma/migrations/20261002190000_log_profiling_session_indexes/migration.sql +5 -0
  109. package/prisma/schema.prisma +6 -0
  110. package/lib/public/assets/apps-DMhpZuKd.css +0 -1
  111. package/lib/public/assets/selector-detail-redirect-19odlD4B.js +0 -1
  112. package/lib/public/assets/selector-health-page-LqIsLrjW.js +0 -11
  113. package/lib/public/assets/session-detail-page-DQzb8_fq.js +0 -4
  114. package/lib/public/assets/view-state-CP9dE1UI.js +0 -1
@@ -0,0 +1,114 @@
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.NodeMetricsCollector = exports.COLLECT_INTERVAL_MS = void 0;
13
+ /** How often the hub asks a node for a session's new figures. */
14
+ exports.COLLECT_INTERVAL_MS = 10000;
15
+ /**
16
+ * On a hub: a session on a node's phone, sampled by asking the node
17
+ * (GET /node/sessions/:id/metrics) every COLLECT_INTERVAL_MS instead of
18
+ * reading the phone. Its samples go through the same hooks as a phone
19
+ * sampler's, so SessionMetricsService buffers and writes them as its own.
20
+ * Asks never overlap. An unreachable node is asked again; whether the
21
+ * session is alive is the heartbeat's call. When the session ends it asks
22
+ * once more, for the last seconds.
23
+ */
24
+ class NodeMetricsCollector {
25
+ constructor(o) {
26
+ var _a;
27
+ this.o = o;
28
+ this.current = 'sampling';
29
+ this.done = false;
30
+ this.asking = null;
31
+ this.outage = false;
32
+ this.after = (_a = o.after) !== null && _a !== void 0 ? _a : null;
33
+ }
34
+ state() {
35
+ return this.current;
36
+ }
37
+ start() {
38
+ var _a, _b, _c;
39
+ const origin = this.o.source.nodeOrigin();
40
+ if (!origin || !this.o.support.shouldAsk(origin)) {
41
+ this.finish('off');
42
+ return;
43
+ }
44
+ this.timer = setInterval(() => void this.tick(), (_a = this.o.intervalMs) !== null && _a !== void 0 ? _a : exports.COLLECT_INTERVAL_MS);
45
+ (_c = (_b = this.timer).unref) === null || _c === void 0 ? void 0 : _c.call(_b);
46
+ }
47
+ stop() {
48
+ return __awaiter(this, void 0, void 0, function* () {
49
+ if (this.timer)
50
+ clearInterval(this.timer);
51
+ if (this.asking)
52
+ yield this.asking;
53
+ if (!this.done)
54
+ yield this.ask();
55
+ this.done = true;
56
+ });
57
+ }
58
+ tick() {
59
+ if (this.done || this.asking)
60
+ return undefined;
61
+ this.asking = this.ask().finally(() => {
62
+ this.asking = null;
63
+ });
64
+ return this.asking;
65
+ }
66
+ ask() {
67
+ return __awaiter(this, void 0, void 0, function* () {
68
+ var _a;
69
+ const reply = yield this.o.source.nodeMetrics(this.after);
70
+ switch (reply.kind) {
71
+ case 'answer': {
72
+ if (this.outage) {
73
+ this.outage = false;
74
+ this.o.logger.info(`${this.o.source.nodeOrigin()} answers again`);
75
+ }
76
+ for (const s of reply.answer.samples) {
77
+ if (this.after !== null && s.at <= this.after)
78
+ continue;
79
+ this.o.hooks.onSample(s);
80
+ this.after = s.at;
81
+ }
82
+ const state = reply.answer.state;
83
+ if (state === 'sampling')
84
+ this.current = 'sampling';
85
+ else
86
+ this.finish(state === 'off' ? 'off' : 'stopped');
87
+ return;
88
+ }
89
+ case 'unsupported':
90
+ this.o.support.unsupported((_a = this.o.source.nodeOrigin()) !== null && _a !== void 0 ? _a : '', reply.status);
91
+ this.finish('off');
92
+ return;
93
+ case 'refused':
94
+ this.finish('off');
95
+ return;
96
+ case 'unavailable':
97
+ if (!this.outage) {
98
+ this.outage = true;
99
+ this.o.logger.warn(`Can't collect CPU and memory from ${this.o.source.nodeOrigin()}: ${reply.reason}. Asking again.`);
100
+ }
101
+ return;
102
+ }
103
+ });
104
+ }
105
+ finish(state) {
106
+ // Figures already collected stay on the page: "stopped", never "off",
107
+ // which the panel shows as "isn't recorded" in their place.
108
+ this.current = state === 'off' && this.after !== null ? 'stopped' : state;
109
+ this.done = true;
110
+ if (this.timer)
111
+ clearInterval(this.timer);
112
+ }
113
+ }
114
+ exports.NodeMetricsCollector = NodeMetricsCollector;
@@ -0,0 +1,76 @@
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
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.NodeMetricsStore = void 0;
10
+ const typedi_1 = require("typedi");
11
+ const nodeMetrics_1 = require("./nodeMetrics");
12
+ const types_1 = require("./types");
13
+ /**
14
+ * On a node: each sampled session's CPU and memory, held in memory until the
15
+ * hub collects them. A node has no Session row for a session its hub created,
16
+ * so it can't write them to SessionMetric. The newest MAX_BUFFERED_SAMPLES per
17
+ * session; what the hub has collected is dropped; an ended session is kept
18
+ * KEEP_AFTER_END_MS, then forgotten, whether or not a hub asked.
19
+ */
20
+ let NodeMetricsStore = class NodeMetricsStore {
21
+ constructor() {
22
+ this.now = () => Date.now();
23
+ this.entries = new Map();
24
+ }
25
+ begin(sessionId, platform) {
26
+ this.prune();
27
+ this.entries.set(sessionId, {
28
+ platform: platform.toLowerCase(),
29
+ state: 'sampling',
30
+ samples: [],
31
+ endedAt: null,
32
+ });
33
+ }
34
+ add(sessionId, sample) {
35
+ const entry = this.entries.get(sessionId);
36
+ if (!entry)
37
+ return;
38
+ entry.samples.push(sample);
39
+ const over = entry.samples.length - types_1.MAX_BUFFERED_SAMPLES;
40
+ if (over > 0)
41
+ entry.samples.splice(0, over);
42
+ }
43
+ gaveUp(sessionId) {
44
+ const entry = this.entries.get(sessionId);
45
+ if (entry && entry.state === 'sampling')
46
+ entry.state = 'stopped';
47
+ }
48
+ end(sessionId) {
49
+ const entry = this.entries.get(sessionId);
50
+ if (!entry)
51
+ return;
52
+ entry.state = 'ended';
53
+ entry.endedAt = this.now();
54
+ }
55
+ /** The samples newer than `after` (all, without it). Those at or before it the hub has, so they go. */
56
+ read(sessionId, after) {
57
+ this.prune();
58
+ const entry = this.entries.get(sessionId);
59
+ if (!entry)
60
+ return { platform: '', state: 'off', samples: [] };
61
+ if (after !== null)
62
+ entry.samples = entry.samples.filter((s) => s.at > after);
63
+ return { platform: entry.platform, state: entry.state, samples: entry.samples.slice() };
64
+ }
65
+ prune() {
66
+ const cutoff = this.now() - nodeMetrics_1.KEEP_AFTER_END_MS;
67
+ for (const [id, entry] of this.entries) {
68
+ if (entry.endedAt !== null && entry.endedAt <= cutoff)
69
+ this.entries.delete(id);
70
+ }
71
+ }
72
+ };
73
+ exports.NodeMetricsStore = NodeMetricsStore;
74
+ exports.NodeMetricsStore = NodeMetricsStore = __decorate([
75
+ (0, typedi_1.Service)()
76
+ ], NodeMetricsStore);
@@ -34,6 +34,9 @@ const ProcessRegistry_1 = require("../ProcessRegistry");
34
34
  const androidMetrics_1 = require("./androidMetrics");
35
35
  const AndroidMetricsSampler_1 = require("./AndroidMetricsSampler");
36
36
  const IOSMetricsSampler_1 = require("./IOSMetricsSampler");
37
+ const NodeMetricsStore_1 = require("./NodeMetricsStore");
38
+ const NodeMetricsCollector_1 = require("./NodeMetricsCollector");
39
+ const nodeMetrics_1 = require("./nodeMetrics");
37
40
  const types_1 = require("./types");
38
41
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
39
42
  /** One adb call of a sample; one that hangs this long is a failed sample. */
@@ -42,15 +45,18 @@ const ADB_TIMEOUT_MS = 5000;
42
45
  * CPU and memory for the session page's Performance panel. A sampler per
43
46
  * session on this server's own phones, from EventManager's start (once the
44
47
  * session's row exists) to its stop; samples are written every
45
- * FLUSH_INTERVAL_MS. Sampling never fails a session.
48
+ * FLUSH_INTERVAL_MS. On a hub, a session on a node's phone gets a
49
+ * NodeMetricsCollector instead, which asks the node. On a node the samples
50
+ * are held for the hub (NodeMetricsStore), not written. Sampling never fails
51
+ * a session.
46
52
  */
47
53
  let SessionMetricsService = class SessionMetricsService {
48
54
  constructor() {
49
55
  this.log = logger_1.default.scope('SessionMetrics');
50
56
  this.running = new Map();
51
57
  }
52
- /** Whether a session on this phone is sampled. */
53
- appliesTo(device) {
58
+ /** Whether a session on this phone is sampled: this server's phone, or a node's through its session. */
59
+ appliesTo(device, source) {
54
60
  var _a, _b;
55
61
  if (!device)
56
62
  return false;
@@ -62,47 +68,77 @@ let SessionMetricsService = class SessionMetricsService {
62
68
  if (platform !== 'android' && !(platform === 'ios' && device.realDevice === true)) {
63
69
  return false;
64
70
  }
71
+ if (this.ownPhone(device))
72
+ return true;
73
+ // A node's phone, on a hub: collected from the node. Never a cloud provider's.
74
+ return !!source && !device.cloud && !this.holdsForHub();
75
+ }
76
+ ownPhone(device) {
77
+ const ctx = this.context();
65
78
  return (0, localDeviceHosts_1.isOwnDevice)((0, localDeviceHosts_1.localDeviceHosts)(ctx.pluginArgs, ctx.port), ctx.nodeId, device);
66
79
  }
67
- start({ sessionId, device, capabilities }) {
68
- var _a, _b;
69
- if (this.running.has(sessionId) || !this.appliesTo(device))
80
+ start({ sessionId, device, capabilities, source, after }) {
81
+ var _a, _b, _c;
82
+ if (this.running.has(sessionId) || !this.appliesTo(device, source))
70
83
  return;
84
+ // A node has no Session row for the hub's sessions, so it holds the
85
+ // figures for its hub to collect (GET /node/sessions/:id/metrics).
86
+ const held = this.holdsForHub();
87
+ const store = held ? this.nodeStore() : null;
88
+ // The entry's own buffer, not a lookup by session: `stop` removes the
89
+ // entry first, and the hub's collector gives its last samples while it
90
+ // stops (its last ask of the node).
91
+ const buffer = [];
71
92
  const hooks = {
72
- onSample: (s) => { var _a; return (_a = this.running.get(sessionId)) === null || _a === void 0 ? void 0 : _a.buffer.push(s); },
93
+ onSample: (s) => {
94
+ if (store)
95
+ store.add(sessionId, s);
96
+ else
97
+ buffer.push(s);
98
+ },
73
99
  onGiveUp: (reason) => {
74
100
  const entry = this.running.get(sessionId);
75
101
  if (entry)
76
102
  entry.gaveUp = true;
103
+ store === null || store === void 0 ? void 0 : store.gaveUp(sessionId);
77
104
  this.log.warn(`[${sessionId}] Stopped sampling ${device.udid}: ${reason}`);
78
105
  },
79
106
  };
80
107
  let sampler;
81
108
  try {
82
- sampler = this.samplerFor(sessionId, device, capabilities, hooks);
109
+ sampler =
110
+ source && !this.ownPhone(device)
111
+ ? this.collectorFor(source, hooks, after !== null && after !== void 0 ? after : null)
112
+ : this.samplerFor(sessionId, device, capabilities, hooks);
83
113
  }
84
114
  catch (err) {
85
115
  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
116
  return;
87
117
  }
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);
118
+ store === null || store === void 0 ? void 0 : store.begin(sessionId, String((_b = device.platform) !== null && _b !== void 0 ? _b : ''));
119
+ let flushTimer;
120
+ if (!held) {
121
+ flushTimer = setInterval(() => void this.flush(sessionId), types_1.FLUSH_INTERVAL_MS);
122
+ (_c = flushTimer.unref) === null || _c === void 0 ? void 0 : _c.call(flushTimer);
123
+ }
90
124
  this.running.set(sessionId, {
91
125
  sampler,
92
- buffer: [],
126
+ buffer,
93
127
  flushTimer,
94
128
  writing: Promise.resolve(),
95
129
  gaveUp: false,
130
+ held,
96
131
  });
97
132
  sampler.start();
98
133
  this.log.info(`[${sessionId}] Sampling CPU and memory on ${device.udid}`);
99
134
  }
100
135
  /** Whether this server is sampling a running session, stopped after giving up, or never did. */
101
136
  recordingState(sessionId) {
137
+ var _a, _b, _c;
102
138
  const entry = this.running.get(sessionId);
103
139
  if (!entry)
104
140
  return 'off';
105
- return entry.gaveUp ? 'stopped' : 'sampling';
141
+ return (_c = (_b = (_a = entry.sampler).state) === null || _b === void 0 ? void 0 : _b.call(_a)) !== null && _c !== void 0 ? _c : (entry.gaveUp ? 'stopped' : 'sampling');
106
142
  }
107
143
  /** Stops the session's sampler and writes what it buffered. Idempotent. */
108
144
  stop(sessionId) {
@@ -111,9 +147,13 @@ let SessionMetricsService = class SessionMetricsService {
111
147
  if (!entry)
112
148
  return;
113
149
  this.running.delete(sessionId);
114
- clearInterval(entry.flushTimer);
150
+ if (entry.flushTimer)
151
+ clearInterval(entry.flushTimer);
115
152
  yield entry.sampler.stop().catch(() => undefined);
116
- yield this.write(sessionId, entry);
153
+ if (entry.held)
154
+ this.nodeStore().end(sessionId);
155
+ else
156
+ yield this.write(sessionId, entry);
117
157
  });
118
158
  }
119
159
  flush(sessionId) {
@@ -149,6 +189,23 @@ let SessionMetricsService = class SessionMetricsService {
149
189
  context() {
150
190
  return typedi_1.Container.get(PluginContext_1.PluginContext);
151
191
  }
192
+ /** A node (a server with `hub`) holds the figures for its hub instead of writing them. */
193
+ holdsForHub() {
194
+ var _a;
195
+ return ((_a = this.context().pluginArgs) === null || _a === void 0 ? void 0 : _a.hub) !== undefined;
196
+ }
197
+ nodeStore() {
198
+ return typedi_1.Container.get(NodeMetricsStore_1.NodeMetricsStore);
199
+ }
200
+ collectorFor(source, hooks, after) {
201
+ return new NodeMetricsCollector_1.NodeMetricsCollector({
202
+ source,
203
+ hooks,
204
+ after,
205
+ support: typedi_1.Container.get(nodeMetrics_1.NodeMetricsSupport),
206
+ logger: this.log,
207
+ });
208
+ }
152
209
  writeSamples(sessionId, samples) {
153
210
  return __awaiter(this, void 0, void 0, function* () {
154
211
  yield prisma_1.prisma.sessionMetric.createMany({
@@ -0,0 +1,92 @@
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 __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.NodeMetricsSupport = exports.NODE_METRICS_RECHECK_MS = exports.KEEP_AFTER_END_MS = exports.NODE_METRICS_HEADER = exports.NODE_METRICS_ROUTE = void 0;
13
+ exports.readNodeMetricsReply = readNodeMetricsReply;
14
+ exports.nodeMetricsSourceOf = nodeMetricsSourceOf;
15
+ const typedi_1 = require("typedi");
16
+ const logger_1 = __importDefault(require("../../logger"));
17
+ /** Where a node serves a session's figures, under `/xenon/api`. */
18
+ exports.NODE_METRICS_ROUTE = '/node/sessions/:sessionId/metrics';
19
+ /** On every answer of that route, so a hub can tell a node that has it. */
20
+ exports.NODE_METRICS_HEADER = 'x-xenon-node-metrics';
21
+ /** How long a node keeps an ended session's figures for its hub's last collection. */
22
+ exports.KEEP_AFTER_END_MS = 10 * 60000;
23
+ const STATES = ['sampling', 'stopped', 'ended', 'off'];
24
+ function readNodeMetricsReply(status, headers, data) {
25
+ var _a;
26
+ if (!(headers === null || headers === void 0 ? void 0 : headers[exports.NODE_METRICS_HEADER])) {
27
+ // An older node answers its login's 401, an unknown route's 404, or a
28
+ // catch-all's 2xx. Anything else without the header (a proxy's 502 in
29
+ // front of a node) is an outage: ask again, never give the node up.
30
+ if (status === 401 || status === 404 || (status >= 200 && status < 300)) {
31
+ return { kind: 'unsupported', status };
32
+ }
33
+ return { kind: 'unavailable', reason: `answered ${status}` };
34
+ }
35
+ if (status === 404)
36
+ return { kind: 'refused' };
37
+ const value = data === null || data === void 0 ? void 0 : data.value;
38
+ if (status === 200 && value && STATES.includes(value.state) && Array.isArray(value.samples)) {
39
+ return {
40
+ kind: 'answer',
41
+ answer: {
42
+ platform: String((_a = value.platform) !== null && _a !== void 0 ? _a : ''),
43
+ state: value.state,
44
+ samples: value.samples,
45
+ },
46
+ };
47
+ }
48
+ return { kind: 'unavailable', reason: `answered ${status}` };
49
+ }
50
+ /** The session as a NodeMetricsSource, when it is one. */
51
+ function nodeMetricsSourceOf(session) {
52
+ const s = session;
53
+ return s && typeof s.nodeMetrics === 'function' && typeof s.nodeOrigin === 'function'
54
+ ? s
55
+ : undefined;
56
+ }
57
+ /** How long the hub leaves a node without the route alone before asking it again. */
58
+ exports.NODE_METRICS_RECHECK_MS = 10 * 60000;
59
+ /**
60
+ * Hub side: which nodes lack the route (an older Xenon). Their sessions show
61
+ * no figures; the hub says so once per node, and asks again after
62
+ * NODE_METRICS_RECHECK_MS, so a node upgraded in place is picked up.
63
+ */
64
+ let NodeMetricsSupport = class NodeMetricsSupport {
65
+ constructor() {
66
+ this.logger = logger_1.default.scope('NodeMetrics');
67
+ this.now = () => Date.now();
68
+ this.unsupportedUntil = new Map();
69
+ this.warned = new Set();
70
+ }
71
+ shouldAsk(origin) {
72
+ const until = this.unsupportedUntil.get(origin);
73
+ if (until === undefined)
74
+ return true;
75
+ if (until > this.now())
76
+ return false;
77
+ this.unsupportedUntil.delete(origin);
78
+ return true;
79
+ }
80
+ unsupported(origin, status) {
81
+ this.unsupportedUntil.set(origin, this.now() + exports.NODE_METRICS_RECHECK_MS);
82
+ if (this.warned.has(origin))
83
+ return;
84
+ this.warned.add(origin);
85
+ this.logger.warn(`Node ${origin} has no session metrics route (answered ${status}): an older Xenon. ` +
86
+ 'Its sessions show no CPU or memory. Upgrade the node.');
87
+ }
88
+ };
89
+ exports.NodeMetricsSupport = NodeMetricsSupport;
90
+ exports.NodeMetricsSupport = NodeMetricsSupport = __decorate([
91
+ (0, typedi_1.Service)()
92
+ ], NodeMetricsSupport);
@@ -41,6 +41,23 @@ function bump(m, key, make) {
41
41
  const topCounts = (m) => Array.from(m.values())
42
42
  .sort((a, b) => b.count - a.count)
43
43
  .slice(0, TOP);
44
+ /** The sessions behind these ids, with their phone and build, by id. */
45
+ function sessionsById(ids) {
46
+ return __awaiter(this, void 0, void 0, function* () {
47
+ const parts = yield Promise.all((0, selectorKeys_1.chunk)(ids).map((part) => prisma_1.prisma.session.findMany({
48
+ where: { id: { in: part } },
49
+ select: {
50
+ id: true,
51
+ build_id: true,
52
+ device_udid: true,
53
+ device_name: true,
54
+ device_platform: true,
55
+ build: { select: { name: true } },
56
+ },
57
+ })));
58
+ return new Map(parts.flat().map((s) => [s.id, s]));
59
+ });
60
+ }
44
61
  /**
45
62
  * Everything the panel shows for one selector over the period, or null when
46
63
  * the caller may not see it (a member, and no session they can see healed
@@ -61,7 +78,10 @@ function selectorDetail(query_1, scope_1) {
61
78
  (0, selectorKeys_1.sessionScope)(scope),
62
79
  ],
63
80
  },
64
- orderBy: { createdAt: 'desc' },
81
+ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
82
+ // Each heal's own fields only. Attaching its session to every heal took
83
+ // most of a second for a selector healed 50,000 times in a year; each
84
+ // session is read once instead.
65
85
  select: {
66
86
  id: true,
67
87
  session_id: true,
@@ -71,17 +91,9 @@ function selectorDetail(query_1, scope_1) {
71
91
  healing_tier: true,
72
92
  healing_confidence: true,
73
93
  duration: true,
74
- session: {
75
- select: {
76
- build_id: true,
77
- device_udid: true,
78
- device_name: true,
79
- device_platform: true,
80
- build: { select: { name: true } },
81
- },
82
- },
83
94
  },
84
95
  });
96
+ const sessionOf = yield sessionsById(Array.from(new Set(rows.map((r) => r.session_id))));
85
97
  const sessions = new Set();
86
98
  let timeSpentMs = 0;
87
99
  const fixes = new Map();
@@ -110,20 +122,23 @@ function selectorDetail(query_1, scope_1) {
110
122
  }
111
123
  fixes.set(fixKey, f);
112
124
  }
113
- const platform = r.session.device_platform || 'unknown';
125
+ const session = sessionOf.get(r.session_id);
126
+ if (!session)
127
+ continue;
128
+ const platform = session.device_platform || 'unknown';
114
129
  bump(platforms, platform, () => ({ name: platform, count: 0 }));
115
- const buildId = (_e = r.session.build_id) !== null && _e !== void 0 ? _e : null;
130
+ const buildId = (_e = session.build_id) !== null && _e !== void 0 ? _e : null;
116
131
  bump(builds, buildId !== null && buildId !== void 0 ? buildId : '', () => {
117
132
  var _a;
118
133
  return ({
119
134
  id: buildId,
120
- name: ((_a = r.session.build) === null || _a === void 0 ? void 0 : _a.name) || buildId || 'No build',
135
+ name: ((_a = session.build) === null || _a === void 0 ? void 0 : _a.name) || buildId || 'No build',
121
136
  count: 0,
122
137
  });
123
138
  });
124
- const udid = r.session.device_udid;
139
+ const udid = session.device_udid;
125
140
  if (udid)
126
- bump(devices, udid, () => ({ udid, name: r.session.device_name || udid, count: 0 }));
141
+ bump(devices, udid, () => ({ udid, name: session.device_name || udid, count: 0 }));
127
142
  }
128
143
  const [state, events] = yield Promise.all([
129
144
  prisma_1.prisma.selectorState.findUnique({
@@ -168,17 +183,18 @@ function selectorDetail(query_1, scope_1) {
168
183
  devices: topCounts(devices),
169
184
  recent: rows.slice(0, RECENT).map((r) => {
170
185
  var _a, _b, _c, _d;
171
- return ({
186
+ const session = sessionOf.get(r.session_id);
187
+ return {
172
188
  id: r.id,
173
189
  sessionId: r.session_id,
174
- buildId: (_a = r.session.build_id) !== null && _a !== void 0 ? _a : null,
190
+ buildId: (_a = session === null || session === void 0 ? void 0 : session.build_id) !== null && _a !== void 0 ? _a : null,
175
191
  at: r.createdAt.toISOString(),
176
- device: r.session.device_name || r.session.device_udid || null,
177
- platform: r.session.device_platform || null,
192
+ device: (session === null || session === void 0 ? void 0 : session.device_name) || (session === null || session === void 0 ? void 0 : session.device_udid) || null,
193
+ platform: (session === null || session === void 0 ? void 0 : session.device_platform) || null,
178
194
  method: (_b = r.healing_tier) !== null && _b !== void 0 ? _b : null,
179
195
  confidence: (_c = r.healing_confidence) !== null && _c !== void 0 ? _c : null,
180
196
  healedSelector: (_d = r.healed_selector) !== null && _d !== void 0 ? _d : null,
181
- });
197
+ };
182
198
  }),
183
199
  state: (0, stateView_1.stateView)(state, {
184
200
  fixed: fixedEvent ? { user_id: fixedEvent.user_id } : undefined,
@@ -18,6 +18,7 @@ const typedi_1 = require("typedi");
18
18
  const logger_1 = __importDefault(require("../logger"));
19
19
  const SessionType_1 = __importDefault(require("../enums/SessionType"));
20
20
  const hubSessionToken_1 = require("../gateway/hubSessionToken");
21
+ const nodeMetrics_1 = require("../services/metrics/nodeMetrics");
21
22
  const nodeSessionStatus_1 = require("../gateway/nodeSessionStatus");
22
23
  const XenonSession_1 = require("./XenonSession");
23
24
  class RemoteSession extends XenonSession_1.XenonSession {
@@ -221,6 +222,37 @@ class RemoteSession extends XenonSession_1.XenonSession {
221
222
  return null;
222
223
  }
223
224
  }
225
+ /** The node's origin, where its own routes are; null for a base URL that isn't one. */
226
+ nodeOrigin() {
227
+ try {
228
+ return new URL(this.baseUrl).origin;
229
+ }
230
+ catch (_a) {
231
+ return null;
232
+ }
233
+ }
234
+ /** The node's CPU and memory for this session newer than `after` (NodeMetricsCollector). */
235
+ nodeMetrics(after) {
236
+ return __awaiter(this, void 0, void 0, function* () {
237
+ var _a, _b, _c;
238
+ const origin = this.nodeOrigin();
239
+ if (!origin)
240
+ return { kind: 'unavailable', reason: `no node origin in ${this.baseUrl}` };
241
+ try {
242
+ const response = yield this.call({
243
+ method: 'get',
244
+ url: `${origin}/xenon/api/node/sessions/${encodeURIComponent(this.sessionId)}/metrics`,
245
+ params: after === null ? undefined : { after },
246
+ timeout: 5000,
247
+ validateStatus: () => true,
248
+ });
249
+ return (0, nodeMetrics_1.readNodeMetricsReply)(response.status, (_a = response.headers) !== null && _a !== void 0 ? _a : {}, response.data);
250
+ }
251
+ catch (err) {
252
+ return { kind: 'unavailable', reason: String((_c = (_b = err === null || err === void 0 ? void 0 : err.code) !== null && _b !== void 0 ? _b : err === null || err === void 0 ? void 0 : err.message) !== null && _c !== void 0 ? _c : err) };
253
+ }
254
+ });
255
+ }
224
256
  /**
225
257
  * Whether the node still has this session. The node is asked with its
226
258
  * session-status route (gateway/nodeSessionStatus.ts), which reads Appium's