@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.
- package/lib/package.json +1 -1
- package/lib/public/assets/{AnnotationOverlay-LzfXiiZJ.js → AnnotationOverlay-BQPQ5bPF.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-BzFDp4mK.js → ApiKeyGate-Bd7Pumwv.js} +1 -1
- package/lib/public/assets/{BugReportButton-BpgWvmZo.js → BugReportButton-iC7fxPxl.js} +1 -1
- package/lib/public/assets/{DeviceMosaicView-CCB-egSH.js → DeviceMosaicView-CNdES7nm.js} +1 -1
- package/lib/public/assets/{EmptyState-DkUVmANo.js → EmptyState-wZeJbGhN.js} +1 -1
- package/lib/public/assets/{FieldGroup-mbfnfTql.js → FieldGroup-tpRfmhlE.js} +1 -1
- package/lib/public/assets/{FilterMenu-BH5usz4Y.js → FilterMenu-BZwOppId.js} +1 -1
- package/lib/public/assets/{Menu-Cbo9mdTk.js → Menu-CahoAx8L.js} +1 -1
- package/lib/public/assets/{Modal-D6XfQHPr.js → Modal-mcM5W_Ji.js} +1 -1
- package/lib/public/assets/{RecordingPage-CmFPkWqH.js → RecordingPage-B4dFBndn.js} +1 -1
- package/lib/public/assets/{RecordingsPage-CSlKk2qC.js → RecordingsPage-BOez3f_9.js} +1 -1
- package/lib/public/assets/{SegmentedControl-BFW9Dlqd.js → SegmentedControl-qMSiMN1d.js} +1 -1
- package/lib/public/assets/{SettingCard-C4DioTTi.js → SettingCard-rcPtJ7HG.js} +1 -1
- package/lib/public/assets/{Table-DrKjlL00.js → Table-J3uognu0.js} +1 -1
- package/lib/public/assets/{activity-C9bE7Jm8.js → activity-_5pNSfKT.js} +1 -1
- package/lib/public/assets/{ai-settings-DRYic4oO.js → ai-settings-DTPRbkei.js} +1 -1
- package/lib/public/assets/{api-keys-CaQU0g7c.js → api-keys-CxkIUo76.js} +1 -1
- package/lib/public/assets/apps-BQKfuN3J.css +1 -0
- package/lib/public/assets/{apps-c1gUFmmK.js → apps-Bv0pAfxz.js} +2 -2
- package/lib/public/assets/{arrow-left-DMvAWA8r.js → arrow-left-uY7F6z1K.js} +1 -1
- package/lib/public/assets/{arrow-right-BEQgxmKT.js → arrow-right-BbAm_2Wa.js} +1 -1
- package/lib/public/assets/{arrow-up-right-Ba0e2GeU.js → arrow-up-right-x4H5NuvE.js} +1 -1
- package/lib/public/assets/{auth-shell-COODQnc7.js → auth-shell-CtMFhbnC.js} +2 -2
- package/lib/public/assets/{builds-page-DZQgWBUs.js → builds-page-B_O5uBSZ.js} +1 -1
- package/lib/public/assets/{button-WW9j43wR.js → button-CQoMQEj_.js} +1 -1
- package/lib/public/assets/{calendar-COUs9VxF.js → calendar-BZkpCqlL.js} +1 -1
- package/lib/public/assets/{check-D8ifHIJR.js → check-CAB6RYuc.js} +1 -1
- package/lib/public/assets/{chevron-right-BjKVUlB_.js → chevron-right-ec0unu3u.js} +1 -1
- package/lib/public/assets/{circle-check-CfP79QE2.js → circle-check-Dfg-gpjg.js} +1 -1
- package/lib/public/assets/{circle-x-BZbFCixZ.js → circle-x-cbPq6Btp.js} +1 -1
- package/lib/public/assets/{clock-BuTm35Qg.js → clock-DU9cBLi8.js} +1 -1
- package/lib/public/assets/{copy-W00zUBwN.js → copy-VM9kWPpE.js} +1 -1
- package/lib/public/assets/{device-explorer-BEpS-Y-_.js → device-explorer-ai0usQ8m.js} +1 -1
- package/lib/public/assets/{download-CGz-MS3y.js → download-WgW4AVid.js} +1 -1
- package/lib/public/assets/{forgot-password-DG7SqVgU.js → forgot-password-BGRpjTtt.js} +1 -1
- package/lib/public/assets/{index-EkaQM1DK.js → index-C33GjwPr.js} +2 -2
- package/lib/public/assets/{index-DWHTp1YP.js → index-DNDdHt3h.js} +1 -1
- package/lib/public/assets/{input-ChaTXeCq.js → input-DwiVcO3Q.js} +1 -1
- package/lib/public/assets/{keycodes-BhbgeW_r.js → keycodes-CvCtpVJD.js} +1 -1
- package/lib/public/assets/{line-chart-9P3RxScy.js → line-chart-PE5e9PeN.js} +1 -1
- package/lib/public/assets/{list-checks-_23IIEEL.js → list-checks-Bgn9GEEO.js} +1 -1
- package/lib/public/assets/{lock-B2JaqqRI.js → lock-Cb7My6hc.js} +1 -1
- package/lib/public/assets/{login-BEWIfLfC.js → login-CDmyzSpl.js} +1 -1
- package/lib/public/assets/{maintenance-settings-BGJIJQ-k.js → maintenance-settings-Bq6pKoBt.js} +1 -1
- package/lib/public/assets/{monitor-BrwMY4G0.js → monitor-BZSwrnPV.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-DsWDMyrY.js → mouse-pointer-2-BbFj8y8i.js} +1 -1
- package/lib/public/assets/{network-DfIhNVJv.js → network-DpwYGB0-.js} +1 -1
- package/lib/public/assets/{overview-B1mePY2_.js → overview-B4_IO-X8.js} +1 -1
- package/lib/public/assets/{page-header-DoD3TOzZ.js → page-header-BpLEuo7i.js} +1 -1
- package/lib/public/assets/{plus-BGvWvpBB.js → plus-DHZS0EGU.js} +1 -1
- package/lib/public/assets/{profile-page-CvV5aWvl.js → profile-page-DsHDIq-Y.js} +1 -1
- package/lib/public/assets/{reset-password-CGVd31LR.js → reset-password-DuUjhw_l.js} +1 -1
- package/lib/public/assets/{runbook-page-Bq2cvwb5.js → runbook-page-BI_ayvLd.js} +1 -1
- package/lib/public/assets/{select-CTcD4jWR.js → select-xiba9PSx.js} +1 -1
- package/lib/public/assets/selector-detail-redirect-Emz2obK_.js +1 -0
- package/lib/public/assets/selector-health-page-zpwrgalC.js +11 -0
- package/lib/public/assets/session-detail-page-BhlTJ1Oq.js +4 -0
- package/lib/public/assets/{settings-C-EnGIqs.js → settings-B-mabJFm.js} +1 -1
- package/lib/public/assets/{stat-tile-BY0RDTRm.js → stat-tile-lT8WcPgu.js} +1 -1
- package/lib/public/assets/{tablet-B4cFM-5B.js → tablet-DM8dp75P.js} +1 -1
- package/lib/public/assets/{teams-BFe-RpYd.js → teams-CRkb5-Fe.js} +1 -1
- package/lib/public/assets/{trash-2-CJCKBOP9.js → trash-2-Dxgryn4o.js} +1 -1
- package/lib/public/assets/{upload-DidWkbld.js → upload-Ci0vz0BP.js} +1 -1
- package/lib/public/assets/{use-builds-data-fnfxatOd.js → use-builds-data-DaaqMjjL.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-iPen3tmC.js → use-password-reset-mode-BvIyZtaF.js} +1 -1
- package/lib/public/assets/{users-CrcjmRdf.js → users-BT8qx3ea.js} +1 -1
- package/lib/public/assets/{users-DBUv0CJx.js → users-CWYHk75q.js} +1 -1
- package/lib/public/assets/{video-off-eh69-FKB.js → video-off-BvY08g6I.js} +1 -1
- package/lib/public/assets/view-state-83Z1gFME.js +1 -0
- package/lib/public/assets/{webhook-settings-CQoSshBn.js → webhook-settings-aHIlSJFP.js} +1 -1
- package/lib/public/assets/{zap-CICtWiLn.js → zap-qG2Li6rI.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/src/app/index.js +2 -0
- package/lib/src/dashboard/event-manager.js +4 -6
- package/lib/src/gateway/nodeSessionMetrics.js +39 -0
- package/lib/src/gateway/nodeSessionStatus.js +32 -23
- package/lib/src/generated/client/edge.js +2 -2
- package/lib/src/generated/client/index.js +2 -2
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +6 -0
- package/lib/src/plugin.js +3 -0
- package/lib/src/services/SelectorVerificationJob.js +50 -5
- package/lib/src/services/SessionLifecycleService.js +16 -0
- package/lib/src/services/metrics/NodeMetricsCollector.js +114 -0
- package/lib/src/services/metrics/NodeMetricsStore.js +76 -0
- package/lib/src/services/metrics/SessionMetricsService.js +71 -14
- package/lib/src/services/metrics/nodeMetrics.js +92 -0
- package/lib/src/services/selector-health/selectorDetail.js +36 -20
- package/lib/src/sessions/RemoteSession.js +32 -0
- package/lib/src/sessions/SessionManager.js +63 -0
- package/lib/test/helpers/scratch-database.js +17 -3
- package/lib/test/unit/dashboard-events-metrics.spec.js +10 -0
- package/lib/test/unit/hub-node-session-metrics.spec.js +330 -0
- package/lib/test/unit/node-metrics-collector.spec.js +226 -0
- package/lib/test/unit/node-metrics-store.spec.js +70 -0
- package/lib/test/unit/node-session-metrics-route.spec.js +97 -0
- package/lib/test/unit/selector-health-detail.spec.js +57 -0
- package/lib/test/unit/selector-verification-job.spec.js +3 -0
- package/lib/test/unit/selector-verification-missed-regression.spec.js +132 -0
- package/lib/test/unit/session-log-indexes.spec.js +147 -0
- package/lib/test/unit/session-metrics-lifecycle.spec.js +55 -0
- package/lib/test/unit/session-metrics-service.spec.js +97 -0
- package/lib/test/unit/session-recovery-metrics.spec.js +94 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20261002180000_session_log_indexes/migration.sql +5 -0
- package/prisma/migrations/20261002190000_log_profiling_session_indexes/migration.sql +5 -0
- package/prisma/schema.prisma +6 -0
- package/lib/public/assets/apps-DMhpZuKd.css +0 -1
- package/lib/public/assets/selector-detail-redirect-19odlD4B.js +0 -1
- package/lib/public/assets/selector-health-page-LqIsLrjW.js +0 -11
- package/lib/public/assets/session-detail-page-DQzb8_fq.js +0 -4
- 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.
|
|
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) => {
|
|
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 =
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
150
|
+
if (entry.flushTimer)
|
|
151
|
+
clearInterval(entry.flushTimer);
|
|
115
152
|
yield entry.sampler.stop().catch(() => undefined);
|
|
116
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
139
|
+
const udid = session.device_udid;
|
|
125
140
|
if (udid)
|
|
126
|
-
bump(devices, udid, () => ({ udid, name:
|
|
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
|
-
|
|
186
|
+
const session = sessionOf.get(r.session_id);
|
|
187
|
+
return {
|
|
172
188
|
id: r.id,
|
|
173
189
|
sessionId: r.session_id,
|
|
174
|
-
buildId: (_a =
|
|
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:
|
|
177
|
-
platform:
|
|
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
|