@xenon-device-management/xenon 2.10.1 → 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 (96) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-defSfi9B.js → AnnotationOverlay-BQPQ5bPF.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-DS7dJ2bU.js → ApiKeyGate-Bd7Pumwv.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-CeD8SUnQ.js → BugReportButton-iC7fxPxl.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-Dgue7t7s.js → DeviceMosaicView-CNdES7nm.js} +1 -1
  6. package/lib/public/assets/{EmptyState-Bo-vvxiy.js → EmptyState-wZeJbGhN.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-DdhTNAXZ.js → FieldGroup-tpRfmhlE.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-J3F_3fDw.js → FilterMenu-BZwOppId.js} +1 -1
  9. package/lib/public/assets/{Menu-Bxluaw2m.js → Menu-CahoAx8L.js} +1 -1
  10. package/lib/public/assets/{Modal-qgMVFrqV.js → Modal-mcM5W_Ji.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-BUT7WgJB.js → RecordingPage-B4dFBndn.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-CY0OIgCo.js → RecordingsPage-BOez3f_9.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-Cq-fybZ2.js → SegmentedControl-qMSiMN1d.js} +1 -1
  14. package/lib/public/assets/{SettingCard-CASWJr7h.js → SettingCard-rcPtJ7HG.js} +1 -1
  15. package/lib/public/assets/{Table-BSO_dZzu.js → Table-J3uognu0.js} +1 -1
  16. package/lib/public/assets/{activity-B_-MNwwR.js → activity-_5pNSfKT.js} +1 -1
  17. package/lib/public/assets/{ai-settings-DFhemyo8.js → ai-settings-DTPRbkei.js} +1 -1
  18. package/lib/public/assets/{api-keys-DV1oB8Tf.js → api-keys-CxkIUo76.js} +1 -1
  19. package/lib/public/assets/apps-BQKfuN3J.css +1 -0
  20. package/lib/public/assets/{apps-B5f-VuLN.js → apps-Bv0pAfxz.js} +2 -2
  21. package/lib/public/assets/{arrow-left-D_xIcVqg.js → arrow-left-uY7F6z1K.js} +1 -1
  22. package/lib/public/assets/{arrow-right-CLGlL_Un.js → arrow-right-BbAm_2Wa.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-CuIgWFj2.js → arrow-up-right-x4H5NuvE.js} +1 -1
  24. package/lib/public/assets/{auth-shell-SgAkrFQg.js → auth-shell-CtMFhbnC.js} +2 -2
  25. package/lib/public/assets/{builds-page-h9ZJ8n74.js → builds-page-B_O5uBSZ.js} +1 -1
  26. package/lib/public/assets/{button-DKl94fzV.js → button-CQoMQEj_.js} +1 -1
  27. package/lib/public/assets/{calendar-BNLTjBsT.js → calendar-BZkpCqlL.js} +1 -1
  28. package/lib/public/assets/{check-1u_3Zsx5.js → check-CAB6RYuc.js} +1 -1
  29. package/lib/public/assets/{chevron-right-Cn2BixQX.js → chevron-right-ec0unu3u.js} +1 -1
  30. package/lib/public/assets/{circle-check-OymVCeGU.js → circle-check-Dfg-gpjg.js} +1 -1
  31. package/lib/public/assets/{circle-x-D3ZyFWOd.js → circle-x-cbPq6Btp.js} +1 -1
  32. package/lib/public/assets/{clock-DwzGN-aa.js → clock-DU9cBLi8.js} +1 -1
  33. package/lib/public/assets/{copy-De43WY6T.js → copy-VM9kWPpE.js} +1 -1
  34. package/lib/public/assets/{device-explorer-CtvEmCC5.js → device-explorer-ai0usQ8m.js} +1 -1
  35. package/lib/public/assets/{download-DcSdKSvR.js → download-WgW4AVid.js} +1 -1
  36. package/lib/public/assets/{forgot-password-DDjM8J7Z.js → forgot-password-BGRpjTtt.js} +1 -1
  37. package/lib/public/assets/{index-Bqt1dmMA.js → index-C33GjwPr.js} +2 -2
  38. package/lib/public/assets/{index-CSNs0EE4.js → index-DNDdHt3h.js} +1 -1
  39. package/lib/public/assets/{input-fRGFwm1K.js → input-DwiVcO3Q.js} +1 -1
  40. package/lib/public/assets/{keycodes-Dj4m3JV7.js → keycodes-CvCtpVJD.js} +1 -1
  41. package/lib/public/assets/{line-chart-DHSZ8Sm4.js → line-chart-PE5e9PeN.js} +1 -1
  42. package/lib/public/assets/{list-checks-Co1gqqDn.js → list-checks-Bgn9GEEO.js} +1 -1
  43. package/lib/public/assets/{lock-BzutP2Yf.js → lock-Cb7My6hc.js} +1 -1
  44. package/lib/public/assets/{login-DSVZLgW2.js → login-CDmyzSpl.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-PYFNp_tc.js → maintenance-settings-Bq6pKoBt.js} +1 -1
  46. package/lib/public/assets/{monitor-DdKVA3ze.js → monitor-BZSwrnPV.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-C8UpXByt.js → mouse-pointer-2-BbFj8y8i.js} +1 -1
  48. package/lib/public/assets/{network-BkzHrr2L.js → network-DpwYGB0-.js} +1 -1
  49. package/lib/public/assets/{overview-0YFo6f97.js → overview-B4_IO-X8.js} +1 -1
  50. package/lib/public/assets/{page-header-DkmHEtLJ.js → page-header-BpLEuo7i.js} +1 -1
  51. package/lib/public/assets/{plus-BH6Y7G7y.js → plus-DHZS0EGU.js} +1 -1
  52. package/lib/public/assets/{profile-page-C1IDBP4l.js → profile-page-DsHDIq-Y.js} +1 -1
  53. package/lib/public/assets/{reset-password-yNMsl5fZ.js → reset-password-DuUjhw_l.js} +1 -1
  54. package/lib/public/assets/{runbook-page--fH5nToN.js → runbook-page-BI_ayvLd.js} +1 -1
  55. package/lib/public/assets/{select-DqjhZ6h2.js → select-xiba9PSx.js} +1 -1
  56. package/lib/public/assets/{selector-detail-redirect-CrYIFrUj.js → selector-detail-redirect-Emz2obK_.js} +1 -1
  57. package/lib/public/assets/{selector-health-page-BnRUmiS2.js → selector-health-page-zpwrgalC.js} +1 -1
  58. package/lib/public/assets/session-detail-page-BhlTJ1Oq.js +4 -0
  59. package/lib/public/assets/{settings-BcxYFxnE.js → settings-B-mabJFm.js} +1 -1
  60. package/lib/public/assets/{stat-tile-DxVLBgA7.js → stat-tile-lT8WcPgu.js} +1 -1
  61. package/lib/public/assets/{tablet-C_uKWfUK.js → tablet-DM8dp75P.js} +1 -1
  62. package/lib/public/assets/{teams-C9G2WisD.js → teams-CRkb5-Fe.js} +1 -1
  63. package/lib/public/assets/{trash-2-C4cJO0CG.js → trash-2-Dxgryn4o.js} +1 -1
  64. package/lib/public/assets/{upload-BN3PMMvP.js → upload-Ci0vz0BP.js} +1 -1
  65. package/lib/public/assets/{use-builds-data-CtelVuo_.js → use-builds-data-DaaqMjjL.js} +1 -1
  66. package/lib/public/assets/{use-password-reset-mode-CClcmiRh.js → use-password-reset-mode-BvIyZtaF.js} +1 -1
  67. package/lib/public/assets/{users-OdnOb63Y.js → users-BT8qx3ea.js} +1 -1
  68. package/lib/public/assets/{users-BK7fwGGA.js → users-CWYHk75q.js} +1 -1
  69. package/lib/public/assets/{video-off-DUAYoXTQ.js → video-off-BvY08g6I.js} +1 -1
  70. package/lib/public/assets/{webhook-settings-BRyvDWHI.js → webhook-settings-aHIlSJFP.js} +1 -1
  71. package/lib/public/assets/{zap-BiceX7DC.js → zap-qG2Li6rI.js} +1 -1
  72. package/lib/public/index.html +1 -1
  73. package/lib/src/app/index.js +2 -0
  74. package/lib/src/dashboard/event-manager.js +4 -6
  75. package/lib/src/gateway/nodeSessionMetrics.js +39 -0
  76. package/lib/src/gateway/nodeSessionStatus.js +32 -23
  77. package/lib/src/plugin.js +3 -0
  78. package/lib/src/services/SessionLifecycleService.js +16 -0
  79. package/lib/src/services/metrics/NodeMetricsCollector.js +114 -0
  80. package/lib/src/services/metrics/NodeMetricsStore.js +76 -0
  81. package/lib/src/services/metrics/SessionMetricsService.js +71 -14
  82. package/lib/src/services/metrics/nodeMetrics.js +92 -0
  83. package/lib/src/sessions/RemoteSession.js +32 -0
  84. package/lib/src/sessions/SessionManager.js +63 -0
  85. package/lib/test/unit/dashboard-events-metrics.spec.js +10 -0
  86. package/lib/test/unit/hub-node-session-metrics.spec.js +330 -0
  87. package/lib/test/unit/node-metrics-collector.spec.js +226 -0
  88. package/lib/test/unit/node-metrics-store.spec.js +70 -0
  89. package/lib/test/unit/node-session-metrics-route.spec.js +97 -0
  90. package/lib/test/unit/session-metrics-lifecycle.spec.js +55 -0
  91. package/lib/test/unit/session-metrics-service.spec.js +97 -0
  92. package/lib/test/unit/session-recovery-metrics.spec.js +94 -0
  93. package/lib/tsconfig.tsbuildinfo +1 -1
  94. package/package.json +1 -1
  95. package/lib/public/assets/apps-DMhpZuKd.css +0 -1
  96. package/lib/public/assets/session-detail-page-BNh_QfHv.js +0 -4
@@ -10,6 +10,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.NodeSessionProbeSupport = exports.UNSUPPORTED_RECHECK_MS = exports.NODE_SESSION_STATUS_HEADER = exports.NODE_SESSION_STATUS_PATH = exports.NODE_SESSION_STATUS_ROUTE = void 0;
13
+ exports.answerForHub = answerForHub;
13
14
  exports.nodeSessionStatusHandler = nodeSessionStatusHandler;
14
15
  exports.registerNodeSessionStatus = registerNodeSessionStatus;
15
16
  const typedi_1 = require("typedi");
@@ -54,34 +55,42 @@ exports.NODE_SESSION_STATUS_PATH = '/xenon/api/node/sessions';
54
55
  exports.NODE_SESSION_STATUS_HEADER = 'x-xenon-node-sessions';
55
56
  /** How long a hub keeps using the old probe for a node without the route before asking again. */
56
57
  exports.UNSUPPORTED_RECHECK_MS = 10 * 60000;
58
+ /**
59
+ * Runs `answer` when the caller may know this session's state on the node:
60
+ * always with per-command auth off, else only with the hub's session token
61
+ * for it. Otherwise the unknown-session answer, or 503 when the token can't
62
+ * be checked. `what` names the route in the log.
63
+ */
64
+ function answerForHub(deps, req, res, sessionId, what, answer) {
65
+ if (!deps.enforced())
66
+ return answer();
67
+ const presented = req.headers[hubSessionToken_1.HUB_TOKEN_HEADER];
68
+ const token = typeof presented === 'string' ? presented : undefined;
69
+ if (token === undefined) {
70
+ res.status(commandAuth_1.UNKNOWN_SESSION_STATUS).json(commandAuth_1.UNKNOWN_SESSION_BODY);
71
+ return;
72
+ }
73
+ deps.hubTokens
74
+ .verify(token, sessionId)
75
+ .then((valid) => {
76
+ if (valid)
77
+ return answer();
78
+ deps.logger.warn(`${what} for ${sessionId} refused: the hub token is not valid for it`);
79
+ res.status(commandAuth_1.UNKNOWN_SESSION_STATUS).json(commandAuth_1.UNKNOWN_SESSION_BODY);
80
+ })
81
+ .catch((error) => {
82
+ deps.logger.error(`${what} for ${sessionId} unavailable: hub token check failed: ${(0, commandAuth_1.summarize)(error)}`);
83
+ if (!res.headersSent) {
84
+ res.status(commandAuth_1.COMMAND_AUTH_UNAVAILABLE_STATUS).json(commandAuth_1.COMMAND_AUTH_UNAVAILABLE_BODY);
85
+ }
86
+ });
87
+ }
57
88
  function nodeSessionStatusHandler(deps) {
58
89
  return (req, res) => {
59
90
  var _a, _b;
60
91
  res.setHeader(exports.NODE_SESSION_STATUS_HEADER, '1');
61
92
  const sessionId = String((_b = (_a = req.params) === null || _a === void 0 ? void 0 : _a.sessionId) !== null && _b !== void 0 ? _b : '');
62
- const answer = () => res.status(200).json({ value: { sessionId, exists: deps.hasSession(sessionId) } });
63
- if (!deps.enforced())
64
- return answer();
65
- const presented = req.headers[hubSessionToken_1.HUB_TOKEN_HEADER];
66
- const token = typeof presented === 'string' ? presented : undefined;
67
- if (token === undefined) {
68
- res.status(commandAuth_1.UNKNOWN_SESSION_STATUS).json(commandAuth_1.UNKNOWN_SESSION_BODY);
69
- return;
70
- }
71
- deps.hubTokens
72
- .verify(token, sessionId)
73
- .then((valid) => {
74
- if (valid)
75
- return answer();
76
- deps.logger.warn(`Session status for ${sessionId} refused: the hub token is not valid for it`);
77
- res.status(commandAuth_1.UNKNOWN_SESSION_STATUS).json(commandAuth_1.UNKNOWN_SESSION_BODY);
78
- })
79
- .catch((error) => {
80
- deps.logger.error(`Session status for ${sessionId} unavailable: hub token check failed: ${(0, commandAuth_1.summarize)(error)}`);
81
- if (!res.headersSent) {
82
- res.status(commandAuth_1.COMMAND_AUTH_UNAVAILABLE_STATUS).json(commandAuth_1.COMMAND_AUTH_UNAVAILABLE_BODY);
83
- }
84
- });
93
+ answerForHub(deps, req, res, sessionId, 'Session status', () => res.status(200).json({ value: { sessionId, exists: deps.hasSession(sessionId) } }));
85
94
  };
86
95
  }
87
96
  /**
package/lib/src/plugin.js CHANGED
@@ -66,6 +66,7 @@ const IPluginArgs_1 = require("./interfaces/IPluginArgs");
66
66
  const NodeDevices_1 = __importDefault(require("./device-managers/NodeDevices"));
67
67
  const appiumUmbrella_1 = require("./sessions/appiumUmbrella");
68
68
  const LiveSessionOwners_1 = require("./services/device-access/LiveSessionOwners");
69
+ const SessionMetricsService_1 = require("./services/metrics/SessionMetricsService");
69
70
  const config_1 = require("./config");
70
71
  const SessionManager_1 = require("./sessions/SessionManager");
71
72
  const event_manager_1 = require("./dashboard/event-manager");
@@ -98,6 +99,8 @@ class XenonPlugin extends base_plugin_1.default {
98
99
  var _a;
99
100
  const sessionId = driver.sessionId;
100
101
  typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners).forget(sessionId);
102
+ if (sessionId)
103
+ yield typedi_1.Container.get(SessionMetricsService_1.SessionMetricsService).stop(sessionId);
101
104
  const deviceFilter = {
102
105
  session_id: sessionId ? sessionId : undefined,
103
106
  udid: driver.caps && driver.caps.udid ? driver.caps.udid : undefined,
@@ -80,6 +80,7 @@ const callerTeamIds_1 = require("./device-access/callerTeamIds");
80
80
  const queueVisibility_1 = require("./device-access/queueVisibility");
81
81
  const appVisibility_1 = require("./device-access/appVisibility");
82
82
  const LiveSessionOwners_1 = require("./device-access/LiveSessionOwners");
83
+ const SessionMetricsService_1 = require("./metrics/SessionMetricsService");
83
84
  const appCapability_1 = require("./session/appCapability");
84
85
  const CircuitBreaker_1 = require("../data-service/CircuitBreaker");
85
86
  const nodeWebDriverUrl_1 = require("../gateway/nodeWebDriverUrl");
@@ -816,6 +817,16 @@ let SessionLifecycleService = class SessionLifecycleService {
816
817
  if (sessionInstance instanceof LocalSession_1.LocalSession) {
817
818
  typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners).record(sessionId, userId);
818
819
  }
820
+ // A node samples its own phones for its hub, whatever its dashboard
821
+ // setting (the hub collects the figures). A hub and a standalone server
822
+ // start sampling in EventManager.onSessionStarted, once the row exists.
823
+ if (!this.isHub(context.pluginArgs) && sessionInstance instanceof LocalSession_1.LocalSession) {
824
+ typedi_1.Container.get(SessionMetricsService_1.SessionMetricsService).start({
825
+ sessionId,
826
+ device: freshDevice,
827
+ capabilities: sessionResponse,
828
+ });
829
+ }
819
830
  yield this.applyPostSessionLogic(sessionInstance, xenonCapabilities, freshDevice);
820
831
  });
821
832
  }
@@ -1104,6 +1115,10 @@ let SessionLifecycleService = class SessionLifecycleService {
1104
1115
  finally {
1105
1116
  if (sessionId) {
1106
1117
  typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners).forget(sessionId);
1118
+ // Before the lock's "still in memory?" check, which a dashboard-off
1119
+ // node fails. On a node this ends the figures it holds for its hub;
1120
+ // on a hub it collects a node session's last ones. Idempotent.
1121
+ yield typedi_1.Container.get(SessionMetricsService_1.SessionMetricsService).stop(sessionId);
1107
1122
  yield sessionCleanupLock.acquire(sessionId, () => __awaiter(this, void 0, void 0, function* () {
1108
1123
  const session = SessionManager_1.SESSION_MANAGER.getSession(sessionId);
1109
1124
  if (!session) {
@@ -1238,6 +1253,7 @@ let SessionLifecycleService = class SessionLifecycleService {
1238
1253
  }
1239
1254
  SessionManager_1.SESSION_MANAGER.removeSession(sessionId);
1240
1255
  typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners).forget(sessionId);
1256
+ yield typedi_1.Container.get(SessionMetricsService_1.SessionMetricsService).stop(sessionId);
1241
1257
  }));
1242
1258
  });
1243
1259
  }
@@ -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);
@@ -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