@xenon-device-management/xenon 2.3.0 → 2.5.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-AxcBYoKp.js → AnnotationOverlay-BeypNvFL.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-CIU02EIx.js → ApiKeyGate-CQg51mzi.js} +1 -1
- package/lib/public/assets/{BugReportButton-_akEEjXm.js → BugReportButton-JVduDicX.js} +1 -1
- package/lib/public/assets/{DeviceMosaicView-Bhk1udmn.js → DeviceMosaicView-BnEKlOi9.js} +1 -1
- package/lib/public/assets/{EmptyState-DRKRm19_.js → EmptyState-CVk_39sG.js} +1 -1
- package/lib/public/assets/{FieldGroup-zQQHPh7x.js → FieldGroup-DC4jWFOr.js} +1 -1
- package/lib/public/assets/{FilterMenu-C9_StnDi.js → FilterMenu-Df4ApWT_.js} +1 -1
- package/lib/public/assets/{Menu-0J7io6pF.js → Menu-BZcyp6Df.js} +1 -1
- package/lib/public/assets/{Modal-C677ljNX.js → Modal-D27eM9ja.js} +1 -1
- package/lib/public/assets/{RecordingPage-BDJFck-L.js → RecordingPage-ScK_NxUR.js} +1 -1
- package/lib/public/assets/{RecordingsPage-DQkimbKK.js → RecordingsPage-DO6KsDwr.js} +1 -1
- package/lib/public/assets/{SegmentedControl-DvGMZBIE.js → SegmentedControl-P0_wzUSa.js} +1 -1
- package/lib/public/assets/{SettingCard-ogyouYBq.js → SettingCard-CnbZajUY.js} +1 -1
- package/lib/public/assets/{Table-BLFCOLlO.js → Table-DCLEpILV.js} +1 -1
- package/lib/public/assets/{activity-BU55TGRG.js → activity-c-lgQI29.js} +1 -1
- package/lib/public/assets/{ai-settings-GpDAiHav.js → ai-settings-S4Qb0zMX.js} +1 -1
- package/lib/public/assets/{api-keys-dOzP0k8_.js → api-keys-B8OZRdWq.js} +1 -1
- package/lib/public/assets/{apps-mAwAQmkn.js → apps-BUdm1f-6.js} +1 -1
- package/lib/public/assets/{arrow-left-Dg9mxe9T.js → arrow-left-DuWnjija.js} +1 -1
- package/lib/public/assets/{arrow-right-BVcmUyq3.js → arrow-right-9qDEU3uD.js} +1 -1
- package/lib/public/assets/{arrow-up-right-ByR5pZrH.js → arrow-up-right-b6Of0lQ3.js} +1 -1
- package/lib/public/assets/{auth-shell-Jpgk8CgY.js → auth-shell-DM7UlvlL.js} +2 -2
- package/lib/public/assets/builds-page-AOQqU3g3.js +16 -0
- package/lib/public/assets/{button-D-X4wjO8.js → button-D2poE4AT.js} +1 -1
- package/lib/public/assets/{calendar-ur_p5ETA.js → calendar-BwZZxp7_.js} +1 -1
- package/lib/public/assets/{check-K33iMeyq.js → check-bpRVp-8m.js} +1 -1
- package/lib/public/assets/{chevron-left-DMVWShd-.js → chevron-left-o5fCPIVE.js} +1 -1
- package/lib/public/assets/{chevron-right-DphLJ2_X.js → chevron-right-Bp0cGaHf.js} +1 -1
- package/lib/public/assets/{circle-check-CNFhu8oE.js → circle-check-C7h_YWkh.js} +1 -1
- package/lib/public/assets/{circle-x-BH_cUbqZ.js → circle-x-BevILQ_g.js} +1 -1
- package/lib/public/assets/{clock-CVMVdVvp.js → clock-BEKymxJ4.js} +1 -1
- package/lib/public/assets/{copy-CZ2XHafL.js → copy-R5Kb2Sya.js} +1 -1
- package/lib/public/assets/{device-explorer-B94BhaqF.js → device-explorer-DuD_PXL0.js} +51 -56
- package/lib/public/assets/{download-UUiyw7kO.js → download-CUlaFpHe.js} +1 -1
- package/lib/public/assets/{forgot-password-CrS14F79.js → forgot-password-CHO8yPDz.js} +1 -1
- package/lib/public/assets/{funnel-BMOE5qJY.js → funnel-uB2CBRiY.js} +1 -1
- package/lib/public/assets/{index-hymrhlEZ.css → index-DSFSk-0t.css} +1 -1
- package/lib/public/assets/{index-D0R9Yic3.js → index-KgmoUqNo.js} +1 -1
- package/lib/public/assets/{index-2XPPbPwy.js → index-yibbsLFD.js} +2 -2
- package/lib/public/assets/{input-C-1b8J7i.js → input-BR-cDtqC.js} +1 -1
- package/lib/public/assets/{keycodes-Cly7dQc9.js → keycodes-dLBaFcVi.js} +1 -1
- package/lib/public/assets/{layers-DFZfqGdX.js → layers-BESinxSO.js} +1 -1
- package/lib/public/assets/list-checks-DpKchgZl.js +6 -0
- package/lib/public/assets/{lock-ylHMJpfP.js → lock--sC5e3s6.js} +1 -1
- package/lib/public/assets/{login-DWsWKqDx.js → login-Cc-IivWH.js} +1 -1
- package/lib/public/assets/{maintenance-settings-9B6_MTqW.js → maintenance-settings-CO1mxmXj.js} +1 -1
- package/lib/public/assets/{monitor-CzX0hBH1.js → monitor-Cf5xQDkm.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-CZHD7N3z.js → mouse-pointer-2-D0FTHFsu.js} +1 -1
- package/lib/public/assets/{network-Bikc4MyT.js → network-BDR_XEsw.js} +1 -1
- package/lib/public/assets/{overview-DzaA0aMe.js → overview-Bgr23HrL.js} +1 -1
- package/lib/public/assets/{page-header-DqGlDufy.js → page-header-mkEQJJPW.js} +1 -1
- package/lib/public/assets/{plus-Ct6aQTGM.js → plus-BGTOA7GD.js} +1 -1
- package/lib/public/assets/{profile-page-CCnUQ1Qk.js → profile-page-hogPzCrJ.js} +1 -1
- package/lib/public/assets/{reset-password-AmkGGXo5.js → reset-password-BobaiMBQ.js} +1 -1
- package/lib/public/assets/{runbook-page-DQQn9VyE.js → runbook-page-qw6u9fmW.js} +1 -1
- package/lib/public/assets/{select-k5ogtEWW.js → select-skq1Kjsx.js} +1 -1
- package/lib/public/assets/{selector-detail-page-BCcwC5ku.js → selector-detail-page-BJl3YxfV.js} +1 -1
- package/lib/public/assets/{selector-health-BVyX6kIh.js → selector-health-CMmIKYip.js} +1 -1
- package/lib/public/assets/{selector-health-page-CElBB6XT.js → selector-health-page-DR8zYoS9.js} +1 -1
- package/lib/public/assets/session-detail-page-Cpbl4HPh.js +3 -0
- package/lib/public/assets/{settings-DKBj9KyN.js → settings-rZ0cTErR.js} +1 -1
- package/lib/public/assets/{tablet-B6HI5OxV.js → tablet-C08V4-n2.js} +1 -1
- package/lib/public/assets/{teams-owoDHbOU.js → teams-9lNK5Hv4.js} +1 -1
- package/lib/public/assets/{trash-2-D61PoUrZ.js → trash-2-Uko_-EoJ.js} +1 -1
- package/lib/public/assets/{upload-1ZYEVvgD.js → upload-CyTBX5YJ.js} +1 -1
- package/lib/public/assets/use-builds-data-TVBd_s-l.js +1 -0
- package/lib/public/assets/{use-password-reset-mode-Dx0ghiiQ.js → use-password-reset-mode-BQkqB45G.js} +1 -1
- package/lib/public/assets/{users-C5CS5Qez.js → users-CQKCMBqe.js} +1 -1
- package/lib/public/assets/{users-PbGMd5sD.js → users-D7RusV-M.js} +1 -1
- package/lib/public/assets/{video-off-_XusI0HK.js → video-off-CWBbZnqz.js} +1 -1
- package/lib/public/assets/{webhook-settings-Bi_HHQlQ.js → webhook-settings-Q3AJC_pt.js} +1 -1
- package/lib/public/assets/{zap-uzHWFAKI.js → zap-CN2fMC_t.js} +1 -1
- package/lib/public/index.html +2 -2
- package/lib/src/app/routers/dashboard.js +42 -2
- package/lib/src/app/routers/nodePhoneControl.js +29 -6
- package/lib/src/app/routers/sessionInsights.js +221 -0
- package/lib/src/services/recording/RecordingOrchestrator.js +39 -5
- package/lib/src/services/recording/busy-precheck.js +5 -2
- package/lib/src/services/recording/nodeRecordingSource.js +156 -0
- package/lib/test/unit/busy-precheck.spec.js +32 -0
- package/lib/test/unit/node-phone-control.spec.js +26 -0
- package/lib/test/unit/node-recording-relay.spec.js +165 -0
- package/lib/test/unit/recording-orchestrator.spec.js +86 -2
- package/lib/test/unit/session-list-insights.spec.js +329 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/lib/public/assets/builds-page-VnRTrcdl.js +0 -6
- package/lib/public/assets/session-detail-page-6bqo_-ND.js +0 -3
- package/lib/public/assets/status-pill-outline-CZNpvqej.js +0 -1
|
@@ -65,6 +65,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
65
65
|
const config_1 = require("../../config");
|
|
66
66
|
const sessionVisibility_1 = require("../../services/device-access/sessionVisibility");
|
|
67
67
|
const HealEtalonService_1 = require("../../services/healing/HealEtalonService");
|
|
68
|
+
const sessionInsights_1 = require("./sessionInsights");
|
|
68
69
|
const logger_1 = __importDefault(require("../../logger"));
|
|
69
70
|
const MJPEG_PROXY_CACHE = new Map();
|
|
70
71
|
const authOf = (request) => request.auth;
|
|
@@ -100,10 +101,29 @@ function isValidSession(request, response, next) {
|
|
|
100
101
|
}
|
|
101
102
|
});
|
|
102
103
|
}
|
|
104
|
+
// A `since` that isn't a date is refused, rather than ignored into a list
|
|
105
|
+
// of every session.
|
|
106
|
+
function sinceOf(request, response) {
|
|
107
|
+
try {
|
|
108
|
+
return (0, sessionInsights_1.parseSince)(request.query.since);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
if (!(err instanceof sessionInsights_1.InvalidSinceError))
|
|
112
|
+
throw err;
|
|
113
|
+
response.status(400).json({ error: 'invalid_since', message: err.message });
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
103
117
|
function getSessions(request, response) {
|
|
104
118
|
return __awaiter(this, void 0, void 0, function* () {
|
|
105
119
|
const { buildId, query, status, platform } = request.query;
|
|
120
|
+
const since = sinceOf(request, response);
|
|
121
|
+
if (since === undefined)
|
|
122
|
+
return;
|
|
106
123
|
const where = {};
|
|
124
|
+
if (since) {
|
|
125
|
+
where.createdAt = { gte: since };
|
|
126
|
+
}
|
|
107
127
|
if (buildId) {
|
|
108
128
|
where.build_id = buildId;
|
|
109
129
|
}
|
|
@@ -132,7 +152,26 @@ function getSessions(request, response) {
|
|
|
132
152
|
where: visible ? { AND: [where, visible] } : where,
|
|
133
153
|
take: 500,
|
|
134
154
|
});
|
|
135
|
-
return response.status(200).json(sessions);
|
|
155
|
+
return response.status(200).json(yield (0, sessionInsights_1.describeSessions)(sessions));
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// The Sessions page's summary strip: the period since `since` (all time
|
|
159
|
+
// without it) and the one before it, over the sessions the caller may see,
|
|
160
|
+
// of one build when `buildId` is given.
|
|
161
|
+
function getSessionSummary(request, response) {
|
|
162
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
163
|
+
const since = sinceOf(request, response);
|
|
164
|
+
if (since === undefined)
|
|
165
|
+
return;
|
|
166
|
+
const { buildId } = request.query;
|
|
167
|
+
const visible = yield (0, sessionVisibility_1.visibleSessionWhere)(authOf(request));
|
|
168
|
+
const scope = {
|
|
169
|
+
AND: [
|
|
170
|
+
...(visible ? [visible] : []),
|
|
171
|
+
...(typeof buildId === 'string' && buildId ? [{ build_id: buildId }] : []),
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
return response.status(200).json(yield (0, sessionInsights_1.summarizeSessions)(scope, since));
|
|
136
175
|
});
|
|
137
176
|
}
|
|
138
177
|
function getBuilds(request, response) {
|
|
@@ -154,7 +193,7 @@ function getBuilds(request, response) {
|
|
|
154
193
|
});
|
|
155
194
|
const visibleBuilds = visible ? builds.filter((b) => b.sessions.length > 0) : builds;
|
|
156
195
|
// Principal formatting: Add a flat summary object for the frontend
|
|
157
|
-
const formattedBuilds = visibleBuilds.map((b) => (Object.assign(Object.assign({}, b), { _count: { sessions: b.sessions.length }, sessionCount: b.sessions.length, passedCount: b.sessions.filter((s) =>
|
|
196
|
+
const formattedBuilds = visibleBuilds.map((b) => (Object.assign(Object.assign({}, b), { _count: { sessions: b.sessions.length }, sessionCount: b.sessions.length, passedCount: b.sessions.filter((s) => (0, sessionInsights_1.sessionOutcome)(s.status) === 'passed').length, failedCount: b.sessions.filter((s) => (0, sessionInsights_1.sessionOutcome)(s.status) === 'failed').length, runningCount: b.sessions.filter((s) => (0, sessionInsights_1.sessionOutcome)(s.status) === 'running').length, sessions: undefined })));
|
|
158
197
|
return response.status(200).json(formattedBuilds);
|
|
159
198
|
});
|
|
160
199
|
}
|
|
@@ -1074,6 +1113,7 @@ function register(router) {
|
|
|
1074
1113
|
router.use('/session/:sessionId', isValidSession);
|
|
1075
1114
|
router.use((0, roleGuard_1.roleGuard)('MEMBER'));
|
|
1076
1115
|
router.get('/session', getSessions);
|
|
1116
|
+
router.get('/session-summary', getSessionSummary);
|
|
1077
1117
|
router.get('/session/:sessionId', getSessionById);
|
|
1078
1118
|
router.get('/build', getBuilds);
|
|
1079
1119
|
build_export_1.default.register(router);
|
|
@@ -57,6 +57,7 @@ const forwardToNode_1 = require("../../gateway/forwardToNode");
|
|
|
57
57
|
const manualLock_1 = require("../../services/recording/manualLock");
|
|
58
58
|
const SessionOwnerResolver_1 = require("../../services/device-access/SessionOwnerResolver");
|
|
59
59
|
const device_store_1 = require("../../data-service/device-store");
|
|
60
|
+
const recording_store_1 = require("../../services/recording/recording-store");
|
|
60
61
|
const deviceAccessPolicy_1 = require("../../services/device-access/deviceAccessPolicy");
|
|
61
62
|
const controlDevice_1 = require("../../middleware/controlDevice");
|
|
62
63
|
/**
|
|
@@ -217,14 +218,15 @@ function refuse(res, error, message) {
|
|
|
217
218
|
return res.status(501).json({ success: false, error, message });
|
|
218
219
|
}
|
|
219
220
|
function nodePhoneControl(deps = {}) {
|
|
220
|
-
var _a, _b, _c, _d, _e, _f;
|
|
221
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
221
222
|
const findDevice = (_a = deps.findDevice) !== null && _a !== void 0 ? _a : controlDevice_1.findControlDeviceInStore;
|
|
223
|
+
const activeRecordingFor = (_b = deps.activeRecordingFor) !== null && _b !== void 0 ? _b : ((udid) => typedi_1.Container.get(recording_store_1.RecordingStore).activeRecordingFor(udid));
|
|
222
224
|
const forwardDeps = {
|
|
223
|
-
timeoutMs: (
|
|
224
|
-
installTimeoutMs: (
|
|
225
|
-
markNodeBusy: (
|
|
226
|
-
describeHolder: (
|
|
227
|
-
controlToken: (
|
|
225
|
+
timeoutMs: (_c = deps.timeoutMs) !== null && _c !== void 0 ? _c : exports.NODE_CONTROL_TIMEOUT_MS,
|
|
226
|
+
installTimeoutMs: (_d = deps.installTimeoutMs) !== null && _d !== void 0 ? _d : exports.NODE_INSTALL_TIMEOUT_MS,
|
|
227
|
+
markNodeBusy: (_e = deps.markNodeBusy) !== null && _e !== void 0 ? _e : ((udid, host, hold) => device_store_1.DeviceStoreFactory.getStore().markNodeBusy(udid, host, hold)),
|
|
228
|
+
describeHolder: (_f = deps.describeHolder) !== null && _f !== void 0 ? _f : ((id) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).displayName(id)),
|
|
229
|
+
controlToken: (_g = deps.controlToken) !== null && _g !== void 0 ? _g : defaultControlToken,
|
|
228
230
|
};
|
|
229
231
|
return function (req, res, next) {
|
|
230
232
|
return __awaiter(this, void 0, void 0, function* () {
|
|
@@ -263,6 +265,27 @@ function nodePhoneControl(deps = {}) {
|
|
|
263
265
|
if (!exports.NODE_FORWARDED_CONTROL.includes(key)) {
|
|
264
266
|
return refuse(res, 'not_available_through_hub', `This phone is on node ${node}, and the hub doesn't pass ${action} on to nodes yet.`);
|
|
265
267
|
}
|
|
268
|
+
if (key === 'POST stream/stop') {
|
|
269
|
+
// This server may be recording the phone from the node's stream
|
|
270
|
+
// (nodeRecordingSource.ts). The node doesn't know, and stopping its
|
|
271
|
+
// stream would cut the recording short: refused as /control refuses a
|
|
272
|
+
// stop for its own phones being recorded.
|
|
273
|
+
const recording = yield activeRecordingFor(udid).catch((e) => {
|
|
274
|
+
var _a;
|
|
275
|
+
logger_1.default.error(`nodePhoneControl: recording lookup failed for ${udid}: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
|
|
276
|
+
return undefined;
|
|
277
|
+
});
|
|
278
|
+
if (recording === undefined)
|
|
279
|
+
return res.status(503).json((0, deviceAccessPolicy_1.ownershipUnavailableBody)());
|
|
280
|
+
if (recording) {
|
|
281
|
+
return res.status(409).json({
|
|
282
|
+
success: false,
|
|
283
|
+
error: 'device_recording',
|
|
284
|
+
message: 'This device is being recorded. Stop the recording first.',
|
|
285
|
+
groupId: recording.groupId,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
266
289
|
return forward(req, res, { udid, device, node }, forwardDeps);
|
|
267
290
|
});
|
|
268
291
|
};
|
|
@@ -0,0 +1,221 @@
|
|
|
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.InvalidSinceError = void 0;
|
|
13
|
+
exports.sessionOutcome = sessionOutcome;
|
|
14
|
+
exports.parseSince = parseSince;
|
|
15
|
+
exports.describeSessions = describeSessions;
|
|
16
|
+
exports.percentile = percentile;
|
|
17
|
+
exports.summarizeSessions = summarizeSessions;
|
|
18
|
+
const typedi_1 = require("typedi");
|
|
19
|
+
const prisma_1 = require("../../prisma");
|
|
20
|
+
const PluginContext_1 = require("../../PluginContext");
|
|
21
|
+
const localDeviceHosts_1 = require("../../device-managers/localDeviceHosts");
|
|
22
|
+
/**
|
|
23
|
+
* The verdict of a persisted `Session.status`. The dashboard's
|
|
24
|
+
* `sessionStatusBucket` (web/src/components/builds/derive.ts) is the same
|
|
25
|
+
* rule, so a build's counts and its rows agree.
|
|
26
|
+
*/
|
|
27
|
+
function sessionOutcome(status) {
|
|
28
|
+
switch (status) {
|
|
29
|
+
case 'success':
|
|
30
|
+
case 'passed':
|
|
31
|
+
case 'ended':
|
|
32
|
+
return 'passed';
|
|
33
|
+
case 'failed':
|
|
34
|
+
case 'error':
|
|
35
|
+
case 'timeout':
|
|
36
|
+
return 'failed';
|
|
37
|
+
case 'running':
|
|
38
|
+
return 'running';
|
|
39
|
+
default:
|
|
40
|
+
return 'other';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
class InvalidSinceError extends Error {
|
|
44
|
+
constructor() {
|
|
45
|
+
super('since must be an ISO 8601 date');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
exports.InvalidSinceError = InvalidSinceError;
|
|
49
|
+
/** The `since` query parameter: null when absent; a date, or InvalidSinceError. */
|
|
50
|
+
function parseSince(raw) {
|
|
51
|
+
if (raw === undefined || raw === '')
|
|
52
|
+
return null;
|
|
53
|
+
const at = typeof raw === 'string' ? Date.parse(raw) : NaN;
|
|
54
|
+
if (!Number.isFinite(at))
|
|
55
|
+
throw new InvalidSinceError();
|
|
56
|
+
return new Date(at);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Each listed session with who ran it (`owner`) and where (`ranOn`): 'here'
|
|
60
|
+
* for this server, the node's host for another server's phone, null when
|
|
61
|
+
* that isn't known any more. Four queries for the whole list, never one per
|
|
62
|
+
* row. Members can't read the users list, so the dashboard can't name owners
|
|
63
|
+
* itself.
|
|
64
|
+
*/
|
|
65
|
+
function describeSessions(rows) {
|
|
66
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
67
|
+
const [owners, places] = yield Promise.all([ownersOf(rows), placesOf(rows)]);
|
|
68
|
+
return rows.map((row) => {
|
|
69
|
+
var _a, _b;
|
|
70
|
+
return (Object.assign(Object.assign({}, row), { owner: (_a = owners.get(row.id)) !== null && _a !== void 0 ? _a : null, ranOn: (_b = places.get(row.id)) !== null && _b !== void 0 ? _b : null }));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
// SessionOwnerResolver.ownerOf's rule, for a list: the row's user, else, for
|
|
75
|
+
// a row written before user_id existed, its key's owner.
|
|
76
|
+
function ownersOf(rows) {
|
|
77
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
78
|
+
var _a;
|
|
79
|
+
const legacyKeyIds = [
|
|
80
|
+
...new Set(rows.filter((r) => !r.user_id && r.api_key_id).map((r) => r.api_key_id)),
|
|
81
|
+
];
|
|
82
|
+
const keyOwner = new Map();
|
|
83
|
+
if (legacyKeyIds.length > 0) {
|
|
84
|
+
const keys = yield prisma_1.prisma.apiKey.findMany({
|
|
85
|
+
where: { id: { in: legacyKeyIds } },
|
|
86
|
+
select: { id: true, userId: true },
|
|
87
|
+
});
|
|
88
|
+
for (const key of keys)
|
|
89
|
+
keyOwner.set(key.id, key.userId);
|
|
90
|
+
}
|
|
91
|
+
const ownerId = (r) => { var _a; return (_a = r.user_id) !== null && _a !== void 0 ? _a : (r.api_key_id ? keyOwner.get(r.api_key_id) : undefined); };
|
|
92
|
+
const userIds = [...new Set(rows.map(ownerId).filter((id) => !!id))];
|
|
93
|
+
const users = userIds.length > 0
|
|
94
|
+
? yield prisma_1.prisma.user.findMany({
|
|
95
|
+
where: { id: { in: userIds } },
|
|
96
|
+
select: { id: true, name: true, email: true },
|
|
97
|
+
})
|
|
98
|
+
: [];
|
|
99
|
+
const byId = new Map(users.map((u) => [u.id, { name: u.name || u.email, email: u.email }]));
|
|
100
|
+
const out = new Map();
|
|
101
|
+
for (const row of rows) {
|
|
102
|
+
const owner = byId.get((_a = ownerId(row)) !== null && _a !== void 0 ? _a : '');
|
|
103
|
+
if (owner)
|
|
104
|
+
out.set(row.id, owner);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// Node ids are new on every boot, so a session's node_id names its server
|
|
110
|
+
// only until that server restarts. Past that, its phone's row says where it
|
|
111
|
+
// is: the row of that node id, else the phone's only row. A udid on more than
|
|
112
|
+
// one server (an emulator's) with no row of that node id isn't guessed.
|
|
113
|
+
function placesOf(rows) {
|
|
114
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
115
|
+
var _a;
|
|
116
|
+
const context = typedi_1.Container.get(PluginContext_1.PluginContext);
|
|
117
|
+
const nodeIds = [...new Set(rows.map((r) => r.node_id).filter(Boolean))];
|
|
118
|
+
const udids = [...new Set(rows.map((r) => r.device_udid).filter(Boolean))];
|
|
119
|
+
if (udids.length === 0 && nodeIds.length === 0)
|
|
120
|
+
return new Map();
|
|
121
|
+
const devices = yield prisma_1.prisma.device.findMany({
|
|
122
|
+
where: { OR: [{ nodeId: { in: nodeIds } }, { udid: { in: udids } }] },
|
|
123
|
+
select: { udid: true, host: true, nodeId: true },
|
|
124
|
+
});
|
|
125
|
+
const local = (0, localDeviceHosts_1.localDeviceHosts)(context.pluginArgs, context.port);
|
|
126
|
+
const out = new Map();
|
|
127
|
+
for (const row of rows) {
|
|
128
|
+
if (row.node_id && row.node_id === context.nodeId) {
|
|
129
|
+
out.set(row.id, 'here');
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const sameUdid = devices.filter((d) => d.udid === row.device_udid);
|
|
133
|
+
const device = (_a = sameUdid.find((d) => d.nodeId && d.nodeId === row.node_id)) !== null && _a !== void 0 ? _a : (sameUdid.length === 1 ? sameUdid[0] : undefined);
|
|
134
|
+
if (!device)
|
|
135
|
+
continue;
|
|
136
|
+
out.set(row.id, (0, localDeviceHosts_1.isOwnDevice)(local, context.nodeId, device) ? 'here' : hostLabel(device.host));
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** A host as people read it: no scheme, no trailing slash. */
|
|
142
|
+
function hostLabel(host) {
|
|
143
|
+
return host.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/\/+$/, '');
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Durations come from at most this many of the period's newest ended
|
|
147
|
+
* sessions, so a long period costs one bounded read; past it they are an
|
|
148
|
+
* estimate from the newest.
|
|
149
|
+
*/
|
|
150
|
+
const DURATION_SAMPLE = 10000;
|
|
151
|
+
/** Nearest-rank percentile of ascending values; null for none. */
|
|
152
|
+
function percentile(sorted, p) {
|
|
153
|
+
if (sorted.length === 0)
|
|
154
|
+
return null;
|
|
155
|
+
const rank = Math.max(1, Math.ceil((p / 100) * sorted.length));
|
|
156
|
+
return sorted[rank - 1];
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The Sessions page's summary strip: the period from `since` to now, the
|
|
160
|
+
* period of the same length before it, and what runs now, over the sessions
|
|
161
|
+
* `scope` admits (the caller's visibility, and a build when one is chosen).
|
|
162
|
+
* Without `since` the period is all time and there is none before it.
|
|
163
|
+
*/
|
|
164
|
+
function summarizeSessions(scope_1, since_1) {
|
|
165
|
+
return __awaiter(this, arguments, void 0, function* (scope, since, now = Date.now()) {
|
|
166
|
+
const within = (from, to) => {
|
|
167
|
+
const createdAt = {};
|
|
168
|
+
if (from)
|
|
169
|
+
createdAt.gte = from;
|
|
170
|
+
if (to)
|
|
171
|
+
createdAt.lt = to;
|
|
172
|
+
return Object.keys(createdAt).length ? { AND: [scope, { createdAt }] } : scope;
|
|
173
|
+
};
|
|
174
|
+
const counts = (where) => __awaiter(this, void 0, void 0, function* () {
|
|
175
|
+
// groupBy's generated type can't be satisfied with a `where` built at
|
|
176
|
+
// run time; the result's shape is fixed by `by` and `_count`.
|
|
177
|
+
const groups = (yield prisma_1.prisma.session.groupBy({
|
|
178
|
+
by: ['status'],
|
|
179
|
+
where,
|
|
180
|
+
_count: { _all: true },
|
|
181
|
+
}));
|
|
182
|
+
const out = { total: 0, passed: 0, failed: 0, running: 0 };
|
|
183
|
+
for (const g of groups) {
|
|
184
|
+
out.total += g._count._all;
|
|
185
|
+
const outcome = sessionOutcome(g.status);
|
|
186
|
+
if (outcome !== 'other')
|
|
187
|
+
out[outcome] += g._count._all;
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
});
|
|
191
|
+
const current = within(since, null);
|
|
192
|
+
const previousFrom = since ? new Date(since.getTime() - (now - since.getTime())) : null;
|
|
193
|
+
const [currentCounts, previous, ended, running] = yield Promise.all([
|
|
194
|
+
counts(current),
|
|
195
|
+
since ? counts(within(previousFrom, since)) : Promise.resolve(null),
|
|
196
|
+
prisma_1.prisma.session.findMany({
|
|
197
|
+
where: { AND: [current, { endTime: { not: null } }, { status: { not: 'running' } }] },
|
|
198
|
+
select: { startTime: true, endTime: true },
|
|
199
|
+
orderBy: { createdAt: 'desc' },
|
|
200
|
+
take: DURATION_SAMPLE,
|
|
201
|
+
}),
|
|
202
|
+
prisma_1.prisma.session.findMany({
|
|
203
|
+
where: { AND: [scope, { status: 'running' }] },
|
|
204
|
+
select: { device_udid: true },
|
|
205
|
+
}),
|
|
206
|
+
]);
|
|
207
|
+
const durations = ended
|
|
208
|
+
.map((s) => (s.endTime ? s.endTime.getTime() - s.startTime.getTime() : -1))
|
|
209
|
+
.filter((ms) => ms >= 0)
|
|
210
|
+
.sort((a, b) => a - b);
|
|
211
|
+
return {
|
|
212
|
+
since: since ? since.toISOString() : null,
|
|
213
|
+
current: Object.assign(Object.assign({}, currentCounts), { medianMs: percentile(durations, 50), p90Ms: percentile(durations, 90) }),
|
|
214
|
+
previous,
|
|
215
|
+
runningNow: {
|
|
216
|
+
sessions: running.length,
|
|
217
|
+
devices: new Set(running.map((s) => s.device_udid)).size,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
}
|
|
@@ -70,6 +70,7 @@ const concurrency_gate_1 = require("./concurrency-gate");
|
|
|
70
70
|
const device_service_1 = require("../../data-service/device-service");
|
|
71
71
|
const device_store_1 = require("../../data-service/device-store");
|
|
72
72
|
const manualLock_1 = require("./manualLock");
|
|
73
|
+
const nodeRecordingSource_1 = require("./nodeRecordingSource");
|
|
73
74
|
const ensureMjpegForRecording_1 = require("./ensureMjpegForRecording");
|
|
74
75
|
const probeDuration_1 = require("./probeDuration");
|
|
75
76
|
const annotationImage_1 = require("./annotationImage");
|
|
@@ -136,13 +137,15 @@ class RecordingError extends Error {
|
|
|
136
137
|
exports.RecordingError = RecordingError;
|
|
137
138
|
let RecordingOrchestrator = class RecordingOrchestrator {
|
|
138
139
|
constructor(deps = {}) {
|
|
139
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
|
|
140
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
|
|
140
141
|
/**
|
|
141
142
|
* Recording ids currently being closed out. Both the user-initiated stop and
|
|
142
143
|
* an ffmpeg self-exit finalize rows, and they can land at the same instant —
|
|
143
144
|
* this keeps exactly one of them doing it.
|
|
144
145
|
*/
|
|
145
146
|
this.finalizing = new Set();
|
|
147
|
+
/** The relays node phones' recordings read, by recording id, until finalized. */
|
|
148
|
+
this.nodeSources = new Map();
|
|
146
149
|
this._deps = {
|
|
147
150
|
busyPrecheck: (_a = deps.busyPrecheck) !== null && _a !== void 0 ? _a : typedi_1.Container.get(busy_precheck_1.BusyPrecheck),
|
|
148
151
|
store: (_b = deps.store) !== null && _b !== void 0 ? _b : typedi_1.Container.get(recording_store_1.RecordingStore),
|
|
@@ -153,8 +156,9 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
153
156
|
this.blockDeviceFn = (_f = deps.blockDeviceFn) !== null && _f !== void 0 ? _f : device_service_1.blockDevice;
|
|
154
157
|
this.unblockDeviceFn = (_g = deps.unblockDeviceFn) !== null && _g !== void 0 ? _g : device_service_1.unblockDevice;
|
|
155
158
|
this.ensureMjpegPortFn = (_h = deps.ensureMjpegPortFn) !== null && _h !== void 0 ? _h : ensureMjpegForRecording_1.ensureMjpegForRecording;
|
|
156
|
-
this.
|
|
157
|
-
this.
|
|
159
|
+
this.nodeSourceFn = (_j = deps.nodeSourceFn) !== null && _j !== void 0 ? _j : ((udid, actorId) => (0, nodeRecordingSource_1.recordingSourceFor)(udid, actorId));
|
|
160
|
+
this.probeDurationMsFn = (_k = deps.probeDurationMsFn) !== null && _k !== void 0 ? _k : probeDuration_1.probeVideoDurationMs;
|
|
161
|
+
this.now = (_l = deps.now) !== null && _l !== void 0 ? _l : Date.now;
|
|
158
162
|
}
|
|
159
163
|
get busyPrecheck() {
|
|
160
164
|
return this._deps.busyPrecheck;
|
|
@@ -267,7 +271,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
267
271
|
const host = deviceHosts[udid];
|
|
268
272
|
const filePath = typedi_1.Container.get(ArtifactStore_1.ARTIFACT_STORE).resolve(id, 'video', `${id}.mp4`);
|
|
269
273
|
try {
|
|
270
|
-
const mjpegPort = yield this.
|
|
274
|
+
const mjpegPort = yield this.sourcePort(id, udid, actorId);
|
|
271
275
|
mjpegPorts[udid] = mjpegPort;
|
|
272
276
|
yield this.store.create({
|
|
273
277
|
id,
|
|
@@ -294,6 +298,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
294
298
|
}
|
|
295
299
|
catch (err) {
|
|
296
300
|
recLog.error(`Failed to start recording for ${udid}: ${err === null || err === void 0 ? void 0 : err.message}`);
|
|
301
|
+
yield this.closeNodeSource(id);
|
|
297
302
|
// Mark this one failed but continue with the rest of the group.
|
|
298
303
|
try {
|
|
299
304
|
yield this.store.finalize(id, {
|
|
@@ -392,6 +397,8 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
392
397
|
status = 'FAILED';
|
|
393
398
|
failReason = (_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : 'ffmpeg_stop_failed';
|
|
394
399
|
}
|
|
400
|
+
// Its ffmpeg is done reading; a group's composite stops before this.
|
|
401
|
+
yield this.closeNodeSource(r.id);
|
|
395
402
|
// How long the video actually is, not how long the session was open. Those
|
|
396
403
|
// agree only while capture keeps up: a recording whose device died at 26s
|
|
397
404
|
// and was stopped 5 minutes later reported 5m36s for a 35s file (#204).
|
|
@@ -804,7 +811,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
804
811
|
}
|
|
805
812
|
const filePath = typedi_1.Container.get(ArtifactStore_1.ARTIFACT_STORE).resolve(recordingId, 'video', `${recordingId}.mp4`);
|
|
806
813
|
try {
|
|
807
|
-
const mjpegPort = yield this.
|
|
814
|
+
const mjpegPort = yield this.sourcePort(recordingId, udid, actorId);
|
|
808
815
|
yield this.store.create({
|
|
809
816
|
id: recordingId,
|
|
810
817
|
groupId,
|
|
@@ -829,6 +836,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
829
836
|
}
|
|
830
837
|
catch (err) {
|
|
831
838
|
recLog.error(`Failed to add device ${udid} to group ${groupId}: ${err === null || err === void 0 ? void 0 : err.message}`);
|
|
839
|
+
yield this.closeNodeSource(recordingId);
|
|
832
840
|
this.gate.release(recordingId);
|
|
833
841
|
yield this.tryUnblock(udid, host);
|
|
834
842
|
try {
|
|
@@ -940,6 +948,32 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
940
948
|
}
|
|
941
949
|
});
|
|
942
950
|
}
|
|
951
|
+
/**
|
|
952
|
+
* The loopback port a recording reads: this server's own phone's MJPEG
|
|
953
|
+
* stream, or, for another server's phone, a relay to its node's, kept until
|
|
954
|
+
* the recording is finalized (nodeRecordingSource.ts).
|
|
955
|
+
*/
|
|
956
|
+
sourcePort(recordingId, udid, actorId) {
|
|
957
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
958
|
+
const relay = yield this.nodeSourceFn(udid, actorId);
|
|
959
|
+
if (!relay)
|
|
960
|
+
return this.ensureMjpegPortFn(udid);
|
|
961
|
+
this.nodeSources.set(recordingId, relay);
|
|
962
|
+
return relay.port;
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
closeNodeSource(recordingId) {
|
|
966
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
967
|
+
const relay = this.nodeSources.get(recordingId);
|
|
968
|
+
if (!relay)
|
|
969
|
+
return;
|
|
970
|
+
this.nodeSources.delete(recordingId);
|
|
971
|
+
yield relay.close().catch((err) => {
|
|
972
|
+
var _a;
|
|
973
|
+
recLog.warn(`Closing the node relay of ${recordingId} failed: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
|
|
974
|
+
});
|
|
975
|
+
});
|
|
976
|
+
}
|
|
943
977
|
tryUnblock(udid, host) {
|
|
944
978
|
return __awaiter(this, void 0, void 0, function* () {
|
|
945
979
|
try {
|
|
@@ -64,7 +64,7 @@ let BusyPrecheck = class BusyPrecheck {
|
|
|
64
64
|
*/
|
|
65
65
|
findBusy(udids, actorId, actorApiKeyId) {
|
|
66
66
|
return __awaiter(this, void 0, void 0, function* () {
|
|
67
|
-
var _a;
|
|
67
|
+
var _a, _b;
|
|
68
68
|
const store = this.storeProvider();
|
|
69
69
|
const out = [];
|
|
70
70
|
for (const udid of udids) {
|
|
@@ -81,7 +81,10 @@ let BusyPrecheck = class BusyPrecheck {
|
|
|
81
81
|
}
|
|
82
82
|
if (!device.busy)
|
|
83
83
|
continue;
|
|
84
|
-
|
|
84
|
+
// This server's own hold, else, on a hub, a node phone's preview hold
|
|
85
|
+
// its node reports (nodeHold): session_id is the hub's and stays empty
|
|
86
|
+
// for it, which read as unknown and refused the holder their own phone.
|
|
87
|
+
const blockId = (_b = (_a = device.session_id) !== null && _a !== void 0 ? _a : device.nodeHold) !== null && _b !== void 0 ? _b : undefined;
|
|
85
88
|
const lock = (0, manualLock_1.inspectManualLock)(blockId, actorId, udid);
|
|
86
89
|
// Single source of truth for "is this lock mine" — the same helper
|
|
87
90
|
// /control's guard, stream/start and stream/stop use. Checking only
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.NODE_STREAM_READY_TIMEOUT_MS = void 0;
|
|
16
|
+
exports.openNodeMjpegRelay = openNodeMjpegRelay;
|
|
17
|
+
exports.recordingSourceFor = recordingSourceFor;
|
|
18
|
+
const http_1 = __importDefault(require("http"));
|
|
19
|
+
const typedi_1 = require("typedi");
|
|
20
|
+
const logger_1 = __importDefault(require("../../logger"));
|
|
21
|
+
const hubSessionToken_1 = require("../../gateway/hubSessionToken");
|
|
22
|
+
const forwardToNode_1 = require("../../gateway/forwardToNode");
|
|
23
|
+
const nodePhoneControl_1 = require("../../app/routers/nodePhoneControl");
|
|
24
|
+
const controlDevice_1 = require("../../middleware/controlDevice");
|
|
25
|
+
const relayLog = logger_1.default.scope('NodeRecordingSource');
|
|
26
|
+
/**
|
|
27
|
+
* How long a node's phone has to start streaming: an iPhone's WebDriverAgent
|
|
28
|
+
* and tunnel take tens of seconds on a cold start.
|
|
29
|
+
*/
|
|
30
|
+
exports.NODE_STREAM_READY_TIMEOUT_MS = 90000;
|
|
31
|
+
const defaultControlToken = (grant) => typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).controlTokenFor(grant);
|
|
32
|
+
/**
|
|
33
|
+
* A node's phone, recorded on this server: a loopback server whose every
|
|
34
|
+
* connection gets the node's MJPEG stream (`GET /control/<udid>/stream`),
|
|
35
|
+
* signed afresh for the recording's user. ffmpeg reads it like one of this
|
|
36
|
+
* server's own phones, and reconnects on any hiccup; the hub's control token
|
|
37
|
+
* lasts a minute and a recording much longer, so each connection signs again.
|
|
38
|
+
* The node counts the connection as a viewer, so its idle release leaves the
|
|
39
|
+
* stream running for as long as the recording reads it. Loopback only, like
|
|
40
|
+
* this server's own phones' MJPEG servers.
|
|
41
|
+
*/
|
|
42
|
+
function openNodeMjpegRelay(target_1) {
|
|
43
|
+
return __awaiter(this, arguments, void 0, function* (target, deps = {}) {
|
|
44
|
+
var _a, _b;
|
|
45
|
+
const { udid, host, actorId } = target;
|
|
46
|
+
const origin = (0, nodePhoneControl_1.safeNodeOrigin)(host);
|
|
47
|
+
if (!origin)
|
|
48
|
+
throw new Error(`Unsafe device host for ${udid}`);
|
|
49
|
+
const controlToken = (_a = deps.controlToken) !== null && _a !== void 0 ? _a : defaultControlToken;
|
|
50
|
+
const url = `${origin}/xenon/api/control/${encodeURIComponent(udid)}/stream`;
|
|
51
|
+
const headers = () => __awaiter(this, void 0, void 0, function* () {
|
|
52
|
+
const token = yield controlToken({ userId: actorId, isAdmin: false, udid, host });
|
|
53
|
+
return Object.assign({ accept: '*/*', 'accept-encoding': 'identity' }, (token ? { [hubSessionToken_1.HUB_TOKEN_HEADER]: token } : {}));
|
|
54
|
+
});
|
|
55
|
+
// As local recording starts the phone's stream before ffmpeg, the node's
|
|
56
|
+
// must be sending frames before the port is handed out. Its GET stream
|
|
57
|
+
// starts the stream; an iPhone's takes seconds. Otherwise the video began
|
|
58
|
+
// late, and every mark on it, timed from ffmpeg's start, was off by that.
|
|
59
|
+
yield untilStreaming(url, yield headers(), (_b = deps.readyTimeoutMs) !== null && _b !== void 0 ? _b : exports.NODE_STREAM_READY_TIMEOUT_MS);
|
|
60
|
+
const server = http_1.default.createServer((_req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
61
|
+
var _a, _b, _c, _d;
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
res.on('close', () => controller.abort());
|
|
64
|
+
try {
|
|
65
|
+
const upstream = yield (0, forwardToNode_1.sendToNode)({
|
|
66
|
+
url,
|
|
67
|
+
method: 'GET',
|
|
68
|
+
headers: yield headers(),
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
});
|
|
71
|
+
const type = upstream.headers['content-type'];
|
|
72
|
+
res.writeHead((_a = upstream.statusCode) !== null && _a !== void 0 ? _a : 502, Object.assign(Object.assign({}, (type ? { 'content-type': type } : {})), { 'cache-control': 'no-cache' }));
|
|
73
|
+
upstream.on('error', () => res.destroy());
|
|
74
|
+
upstream.pipe(res);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (res.headersSent) {
|
|
78
|
+
res.destroy();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
relayLog.warn(`[${udid}] node ${origin} stream unavailable: ${(_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : err}`);
|
|
82
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
83
|
+
res.end(`node unreachable: ${(_d = (_c = err === null || err === void 0 ? void 0 : err.code) !== null && _c !== void 0 ? _c : err === null || err === void 0 ? void 0 : err.message) !== null && _d !== void 0 ? _d : err}`);
|
|
84
|
+
}
|
|
85
|
+
}));
|
|
86
|
+
yield new Promise((resolve, reject) => {
|
|
87
|
+
server.once('error', reject);
|
|
88
|
+
server.listen(0, '127.0.0.1', () => resolve());
|
|
89
|
+
});
|
|
90
|
+
const { port } = server.address();
|
|
91
|
+
relayLog.info(`[${udid}] recording relay for node ${origin} on 127.0.0.1:${port}`);
|
|
92
|
+
return {
|
|
93
|
+
port,
|
|
94
|
+
close: () => new Promise((resolve) => {
|
|
95
|
+
var _a;
|
|
96
|
+
(_a = server.closeAllConnections) === null || _a === void 0 ? void 0 : _a.call(server);
|
|
97
|
+
server.close(() => resolve());
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolves once the node's stream sends its first bytes; throws when the node
|
|
104
|
+
* refuses it, ends it first, or sends nothing within `timeoutMs`.
|
|
105
|
+
*/
|
|
106
|
+
function untilStreaming(url, headers, timeoutMs) {
|
|
107
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
108
|
+
var _a;
|
|
109
|
+
const controller = new AbortController();
|
|
110
|
+
let timedOut = false;
|
|
111
|
+
const timer = setTimeout(() => {
|
|
112
|
+
timedOut = true;
|
|
113
|
+
controller.abort();
|
|
114
|
+
}, timeoutMs);
|
|
115
|
+
try {
|
|
116
|
+
const upstream = yield (0, forwardToNode_1.sendToNode)({ url, method: 'GET', headers, signal: controller.signal });
|
|
117
|
+
if (((_a = upstream.statusCode) !== null && _a !== void 0 ? _a : 502) !== 200) {
|
|
118
|
+
const answer = yield (0, forwardToNode_1.readAnswer)(upstream);
|
|
119
|
+
throw new Error(`the node answered ${answer.status} for the stream: ${answer.body.toString('utf8').slice(0, 200)}`);
|
|
120
|
+
}
|
|
121
|
+
yield new Promise((resolve, reject) => {
|
|
122
|
+
upstream.once('data', () => resolve());
|
|
123
|
+
upstream.once('end', () => reject(new Error("the node's stream ended before its first frame")));
|
|
124
|
+
upstream.once('error', reject);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
if (timedOut) {
|
|
129
|
+
throw new Error(`the node's stream sent no first frame within ${Math.round(timeoutMs / 1000)} s`);
|
|
130
|
+
}
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
controller.abort();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The recording source for a phone: a relay to its node for another server's
|
|
141
|
+
* phone, null for this server's own (or an unknown) phone, which the caller
|
|
142
|
+
* reads locally as before. A cloud provider's phone can't be recorded here.
|
|
143
|
+
*/
|
|
144
|
+
function recordingSourceFor(udid_1, actorId_1) {
|
|
145
|
+
return __awaiter(this, arguments, void 0, function* (udid, actorId, deps = {}) {
|
|
146
|
+
var _a;
|
|
147
|
+
const findDevice = (_a = deps.findDevice) !== null && _a !== void 0 ? _a : controlDevice_1.findControlDeviceInStore;
|
|
148
|
+
const device = yield findDevice(udid);
|
|
149
|
+
if (!device || !(0, nodePhoneControl_1.isOtherServersPhone)(device))
|
|
150
|
+
return null;
|
|
151
|
+
if (device.cloud) {
|
|
152
|
+
throw new Error(`${udid} is a cloud provider's phone and can't be recorded here.`);
|
|
153
|
+
}
|
|
154
|
+
return openNodeMjpegRelay({ udid, host: device.host, actorId }, deps);
|
|
155
|
+
});
|
|
156
|
+
}
|