@xenon-device-management/xenon 2.4.0 → 2.6.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 (83) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-CpoWcXKP.js → AnnotationOverlay-PMj0TMCJ.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-DAYuoJDl.js → ApiKeyGate-DXo-ecxe.js} +1 -1
  4. package/lib/public/assets/BugReportButton-DY9ltq27.js +15 -0
  5. package/lib/public/assets/{DeviceMosaicView-C-sJRz63.js → DeviceMosaicView-CT7nA_re.js} +1 -1
  6. package/lib/public/assets/{EmptyState-B_JRYedM.js → EmptyState-DX4jwZ9l.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CorXlSLU.js → FieldGroup-Czk8gPhv.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BKv95DEc.js → FilterMenu-DTblXm4v.js} +1 -1
  9. package/lib/public/assets/{Menu-Cpy5yMwg.js → Menu-CdGgXICe.js} +1 -1
  10. package/lib/public/assets/{Modal-Djp4NvyZ.js → Modal-CtzTciQY.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CP57OKw9.js → RecordingPage-BjJH7AyA.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-BjrTit38.js → RecordingsPage-BkgX2xkA.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-MGTzJhBf.js → SegmentedControl-DEXgl8Qw.js} +1 -1
  14. package/lib/public/assets/{SettingCard-CYBPhKPe.js → SettingCard-BnwAoJhk.js} +1 -1
  15. package/lib/public/assets/{Table-CdKQ9hnU.js → Table-hmNY-ICS.js} +1 -1
  16. package/lib/public/assets/{activity-QsRK21QF.js → activity-Bmuu2fcZ.js} +1 -1
  17. package/lib/public/assets/{ai-settings-Dn6DtRn7.js → ai-settings-uAzHwRK_.js} +1 -1
  18. package/lib/public/assets/{api-keys-DYyf17eU.js → api-keys-CFZeXLs7.js} +1 -1
  19. package/lib/public/assets/{apps-HKFKTe1J.js → apps-DgwQRGg6.js} +1 -1
  20. package/lib/public/assets/{arrow-left-BkA7oMRe.js → arrow-left-D5x8OLs-.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BsSr58EU.js → arrow-right-DRYGEjl5.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-B7TEV_Gr.js → arrow-up-right-DLtHJsd6.js} +1 -1
  23. package/lib/public/assets/{auth-shell-BlmNHGcG.js → auth-shell-D_TLCi0Y.js} +2 -2
  24. package/lib/public/assets/builds-page-CwKR7w5o.js +11 -0
  25. package/lib/public/assets/{button-BIwCF2bR.js → button-CbVMa9PL.js} +1 -1
  26. package/lib/public/assets/{calendar-CMLnXC7N.js → calendar-DgmwU-Oq.js} +1 -1
  27. package/lib/public/assets/{check-zugO3p5U.js → check-Du5vvLoJ.js} +1 -1
  28. package/lib/public/assets/{chevron-left-sCqxpTY6.js → chevron-left-CiRlzB7w.js} +1 -1
  29. package/lib/public/assets/{chevron-right-Bb59xwZc.js → chevron-right-i93nTPHX.js} +1 -1
  30. package/lib/public/assets/{circle-check-DsvxbnTg.js → circle-check-DxYO7yp5.js} +1 -1
  31. package/lib/public/assets/{circle-x-w69AbmbW.js → circle-x-Dw1DFg0I.js} +1 -1
  32. package/lib/public/assets/{clock-C6XOs4bc.js → clock-0q-GqRU0.js} +1 -1
  33. package/lib/public/assets/{copy-pHk-SWBM.js → copy-Ds-F075f.js} +1 -1
  34. package/lib/public/assets/{device-explorer-BDq38IcO.js → device-explorer-Dsldlx2g.js} +54 -64
  35. package/lib/public/assets/{download-CAHtPZmY.js → download-BdJEWL8b.js} +1 -1
  36. package/lib/public/assets/{forgot-password-cmvhGOUu.js → forgot-password-D7eNuw-V.js} +1 -1
  37. package/lib/public/assets/{funnel-BrDuhOtM.js → funnel-CY3Ovebz.js} +1 -1
  38. package/lib/public/assets/{index-C-Q4QkXb.js → index-BpJwb7cQ.js} +1 -1
  39. package/lib/public/assets/{index-hymrhlEZ.css → index-C8pvxG1f.css} +1 -1
  40. package/lib/public/assets/{index-BQCXPOXt.js → index-tW_VASh2.js} +2 -2
  41. package/lib/public/assets/{input-DnUVHYax.js → input-DvCYrL_q.js} +1 -1
  42. package/lib/public/assets/{keycodes-CmOptFVa.js → keycodes-CyOBCkU6.js} +1 -1
  43. package/lib/public/assets/{layers-DRtz_bzF.js → layers-ChEcsAc2.js} +1 -1
  44. package/lib/public/assets/list-checks-EWcywAEC.js +6 -0
  45. package/lib/public/assets/{lock-DF2VnPFm.js → lock-DmkRlmQB.js} +1 -1
  46. package/lib/public/assets/{login-DHYffYa4.js → login-BXeYWAn-.js} +1 -1
  47. package/lib/public/assets/{maintenance-settings-r0uPsJ6n.js → maintenance-settings-Cd37WZ5e.js} +1 -1
  48. package/lib/public/assets/{monitor-7nt9oGNx.js → monitor-BTqiDmvk.js} +1 -1
  49. package/lib/public/assets/{mouse-pointer-2-ys1MKVr2.js → mouse-pointer-2-BrwX4M5d.js} +1 -1
  50. package/lib/public/assets/{network-B8m-F79k.js → network-8uySyPSM.js} +1 -1
  51. package/lib/public/assets/{overview-BHEntlXz.js → overview-CdBGYC95.js} +1 -1
  52. package/lib/public/assets/{page-header-CuFXVUop.js → page-header-BYzd5kfp.js} +1 -1
  53. package/lib/public/assets/{plus-DUSlMac2.js → plus-BIekJsya.js} +1 -1
  54. package/lib/public/assets/{profile-page-CBamPy_h.js → profile-page-a-nQ7xFX.js} +1 -1
  55. package/lib/public/assets/{reset-password-DG0vZl-o.js → reset-password-7Gbj29w1.js} +1 -1
  56. package/lib/public/assets/{runbook-page-5S3T-I4g.js → runbook-page-Bgd9u7W6.js} +1 -1
  57. package/lib/public/assets/{select-f0ci1QC7.js → select-DEpRzm_3.js} +1 -1
  58. package/lib/public/assets/{selector-detail-page-BXA9LUf9.js → selector-detail-page-CvqjXfvp.js} +1 -1
  59. package/lib/public/assets/{selector-health-TyMPgAWo.js → selector-health-BEzkwE_K.js} +1 -1
  60. package/lib/public/assets/{selector-health-page-uBlXy33G.js → selector-health-page-Dl_IE1Je.js} +1 -1
  61. package/lib/public/assets/session-detail-page-CuF_EwRE.js +4 -0
  62. package/lib/public/assets/{settings-IfOFbqtv.js → settings-CxLw5tlF.js} +1 -1
  63. package/lib/public/assets/stat-tile-DFuoxV5Y.js +6 -0
  64. package/lib/public/assets/{tablet-lmFk4qxW.js → tablet-DJBOerD6.js} +1 -1
  65. package/lib/public/assets/{teams-CwMSjVVW.js → teams-CDQQdVRl.js} +1 -1
  66. package/lib/public/assets/{trash-2-BKsgmBS6.js → trash-2-B_zQvjI3.js} +1 -1
  67. package/lib/public/assets/{upload-DXH47sQS.js → upload-9FV8q3NB.js} +1 -1
  68. package/lib/public/assets/{use-password-reset-mode-DU98dxpc.js → use-password-reset-mode-Cx5GHMnk.js} +1 -1
  69. package/lib/public/assets/{users-V5O64rVx.js → users-DC0IoOuZ.js} +1 -1
  70. package/lib/public/assets/{users-eXWwQZjX.js → users-ep-SOGaV.js} +1 -1
  71. package/lib/public/assets/{video-off-BuoIHEYy.js → video-off-BmUg-gKW.js} +1 -1
  72. package/lib/public/assets/{webhook-settings-ClsxsKnC.js → webhook-settings-CwUBzLGC.js} +1 -1
  73. package/lib/public/assets/{zap-I10vdQXd.js → zap-CBld_5xf.js} +1 -1
  74. package/lib/public/index.html +2 -2
  75. package/lib/src/app/routers/dashboard.js +45 -3
  76. package/lib/src/app/routers/sessionInsights.js +221 -0
  77. package/lib/test/unit/session-list-insights.spec.js +354 -0
  78. package/lib/tsconfig.tsbuildinfo +1 -1
  79. package/package.json +1 -1
  80. package/lib/public/assets/BugReportButton-DEXyENUv.js +0 -10
  81. package/lib/public/assets/builds-page-E4WIKtl8.js +0 -6
  82. package/lib/public/assets/session-detail-page-DECim5lh.js +0 -3
  83. package/lib/public/assets/status-pill-outline-grXLf8I8.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) => ['success', 'passed'].includes(s.status)).length, failedCount: b.sessions.filter((s) => s.status === 'failed').length, runningCount: b.sessions.filter((s) => s.status === 'running').length, sessions: undefined })));
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
  }
@@ -168,7 +207,9 @@ function getSessionById(request, response) {
168
207
  if (!session) {
169
208
  return response.status(404).json({ error: true, message: 'Session not found' });
170
209
  }
171
- return response.status(200).json(session);
210
+ // Who ran it and where, as the list says (describeSessions).
211
+ const [described] = yield (0, sessionInsights_1.describeSessions)([session]);
212
+ return response.status(200).json(described);
172
213
  });
173
214
  }
174
215
  function getSessionLogs(request, response) {
@@ -1074,6 +1115,7 @@ function register(router) {
1074
1115
  router.use('/session/:sessionId', isValidSession);
1075
1116
  router.use((0, roleGuard_1.roleGuard)('MEMBER'));
1076
1117
  router.get('/session', getSessions);
1118
+ router.get('/session-summary', getSessionSummary);
1077
1119
  router.get('/session/:sessionId', getSessionById);
1078
1120
  router.get('/build', getBuilds);
1079
1121
  build_export_1.default.register(router);
@@ -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
+ }
@@ -0,0 +1,354 @@
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
+ require("reflect-metadata");
16
+ const chai_1 = require("chai");
17
+ const express_1 = __importDefault(require("express"));
18
+ const typedi_1 = require("typedi");
19
+ const loopbackRequest_1 = __importDefault(require("../helpers/loopbackRequest"));
20
+ const scratch_database_1 = require("../helpers/scratch-database");
21
+ const PluginContext_1 = require("../../src/PluginContext");
22
+ const IPluginArgs_1 = require("../../src/interfaces/IPluginArgs");
23
+ const dashboard_1 = __importDefault(require("../../src/app/routers/dashboard"));
24
+ /**
25
+ * What the Sessions page reads besides the rows themselves: the period filter
26
+ * on the list, who ran each session and where, the period summary, and the
27
+ * per-build counts. Real queries against a scratch database, because each of
28
+ * these is a Prisma filter a stub would only echo.
29
+ */
30
+ describe('Sessions page data', () => {
31
+ const scratch = (0, scratch_database_1.useScratchDatabase)();
32
+ const HOUR = 60 * 60 * 1000;
33
+ const MIN = 60 * 1000;
34
+ let context;
35
+ let saved;
36
+ let now;
37
+ let caller;
38
+ // An admin: teamIds undefined, so every session is visible.
39
+ const ADMIN = { kind: 'user-session', userId: 'admin-1', role: 'SUPER_ADMIN', scopes: 'admin' };
40
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
41
+ context = typedi_1.Container.get(PluginContext_1.PluginContext);
42
+ saved = Object.assign({}, context);
43
+ context.setContext(Object.assign(Object.assign({}, IPluginArgs_1.DefaultPluginArgs), { bindHostOrIp: '127.0.0.1' }), 4723, 'hub-1', '');
44
+ now = Date.now();
45
+ caller = ADMIN;
46
+ for (const model of ['session', 'build', 'apiKey', 'user', 'device']) {
47
+ yield scratch.db[model].deleteMany({});
48
+ }
49
+ }));
50
+ afterEach(() => {
51
+ Object.assign(context, saved);
52
+ });
53
+ const app = () => {
54
+ const a = (0, express_1.default)();
55
+ a.use(express_1.default.json());
56
+ a.use((req, _res, next) => {
57
+ req.auth = caller;
58
+ next();
59
+ });
60
+ dashboard_1.default.register(a);
61
+ return a;
62
+ };
63
+ let seq = 0;
64
+ const session = (fields) => {
65
+ seq += 1;
66
+ return scratch.db.session.create({
67
+ data: Object.assign({ id: `s-${seq}`, status: 'success', desired_capabilities: '{}', session_capabilities: '{}', node_id: 'hub-1', has_live_video: false, device_udid: 'phone-1', device_platform: 'android', device_version: '14' }, fields),
68
+ });
69
+ };
70
+ const ago = (ms) => new Date(now - ms);
71
+ const user = (id, name, email) => scratch.db.user.create({
72
+ data: { id, name, email, passwordHash: 'x', accessKey: `ak-${id}` },
73
+ });
74
+ describe('GET /session', () => {
75
+ it('keeps the sessions created since the given time, newest first', () => __awaiter(void 0, void 0, void 0, function* () {
76
+ yield session({ id: 'old', createdAt: ago(3 * HOUR) });
77
+ yield session({ id: 'recent', createdAt: ago(30 * MIN) });
78
+ yield session({ id: 'newest', createdAt: ago(5 * MIN) });
79
+ const res = yield (0, loopbackRequest_1.default)(app()).get(`/session?since=${ago(HOUR).toISOString()}`);
80
+ (0, chai_1.expect)(res.status).to.equal(200);
81
+ (0, chai_1.expect)(res.body.map((s) => s.id)).to.deep.equal(['newest', 'recent']);
82
+ const all = yield (0, loopbackRequest_1.default)(app()).get('/session');
83
+ (0, chai_1.expect)(all.body.map((s) => s.id)).to.deep.equal(['newest', 'recent', 'old']);
84
+ }));
85
+ it('refuses a since that is not a date', () => __awaiter(void 0, void 0, void 0, function* () {
86
+ const res = yield (0, loopbackRequest_1.default)(app()).get('/session?since=yesterday-ish');
87
+ (0, chai_1.expect)(res.status).to.equal(400);
88
+ (0, chai_1.expect)(res.body.error).to.equal('invalid_since');
89
+ }));
90
+ it('names who ran each session: its user, or an older row’s key owner', () => __awaiter(void 0, void 0, void 0, function* () {
91
+ yield user('u-priya', 'Priya Shah', 'priya@example.com');
92
+ yield user('u-alex', '', 'alex@example.com');
93
+ yield scratch.db.apiKey.create({
94
+ data: { id: 'key-1', name: 'ci', keyHash: 'h1', scopes: 'sessions', userId: 'u-alex' },
95
+ });
96
+ yield session({ id: 'by-user', user_id: 'u-priya', createdAt: ago(4 * MIN) });
97
+ yield session({ id: 'by-key', api_key_id: 'key-1', createdAt: ago(3 * MIN) });
98
+ yield session({ id: 'gone-user', user_id: 'u-deleted', createdAt: ago(2 * MIN) });
99
+ yield session({ id: 'nobody', createdAt: ago(1 * MIN) });
100
+ const res = yield (0, loopbackRequest_1.default)(app()).get('/session');
101
+ const owner = Object.fromEntries(res.body.map((s) => [s.id, s.owner]));
102
+ (0, chai_1.expect)(owner['by-user']).to.deep.equal({ name: 'Priya Shah', email: 'priya@example.com' });
103
+ // No name on record: the email stands in for it.
104
+ (0, chai_1.expect)(owner['by-key']).to.deep.equal({
105
+ name: 'alex@example.com',
106
+ email: 'alex@example.com',
107
+ });
108
+ (0, chai_1.expect)(owner['gone-user']).to.equal(null);
109
+ (0, chai_1.expect)(owner['nobody']).to.equal(null);
110
+ }));
111
+ it('says where each session ran: here, a node by its host, or not known', () => __awaiter(void 0, void 0, void 0, function* () {
112
+ yield scratch.db.device.create({
113
+ data: {
114
+ udid: 'own-phone',
115
+ host: 'http://127.0.0.1:4723',
116
+ nodeId: 'hub-1',
117
+ platform: 'android',
118
+ },
119
+ });
120
+ yield scratch.db.device.create({
121
+ data: {
122
+ udid: 'node-phone',
123
+ host: 'http://10.0.0.9:4725',
124
+ nodeId: 'node-2',
125
+ platform: 'ios',
126
+ },
127
+ });
128
+ yield session({
129
+ id: 'this-boot',
130
+ node_id: 'hub-1',
131
+ device_udid: 'own-phone',
132
+ createdAt: ago(5 * MIN),
133
+ });
134
+ // Node ids are new on every boot, so an older session of this server's
135
+ // own phone carries an id nothing matches any more; its phone's row says.
136
+ yield session({
137
+ id: 'last-boot',
138
+ node_id: 'old-boot',
139
+ device_udid: 'own-phone',
140
+ createdAt: ago(4 * MIN),
141
+ });
142
+ yield session({
143
+ id: 'on-node',
144
+ node_id: 'node-2',
145
+ device_udid: 'node-phone',
146
+ createdAt: ago(3 * MIN),
147
+ });
148
+ yield session({
149
+ id: 'node-restarted',
150
+ node_id: 'node-2-old',
151
+ device_udid: 'node-phone',
152
+ createdAt: ago(2 * MIN),
153
+ });
154
+ yield session({
155
+ id: 'phone-gone',
156
+ node_id: 'node-9',
157
+ device_udid: 'unplugged',
158
+ createdAt: ago(1 * MIN),
159
+ });
160
+ const res = yield (0, loopbackRequest_1.default)(app()).get('/session');
161
+ const ranOn = Object.fromEntries(res.body.map((s) => [s.id, s.ranOn]));
162
+ (0, chai_1.expect)(ranOn).to.deep.equal({
163
+ 'this-boot': 'here',
164
+ 'last-boot': 'here',
165
+ 'on-node': '10.0.0.9:4725',
166
+ 'node-restarted': '10.0.0.9:4725',
167
+ 'phone-gone': null,
168
+ });
169
+ }));
170
+ it('does not guess when the same udid is on more than one server', () => __awaiter(void 0, void 0, void 0, function* () {
171
+ yield scratch.db.device.create({
172
+ data: {
173
+ udid: 'emulator-5554',
174
+ host: 'http://127.0.0.1:4723',
175
+ nodeId: 'hub-1',
176
+ platform: 'android',
177
+ },
178
+ });
179
+ yield scratch.db.device.create({
180
+ data: {
181
+ udid: 'emulator-5554',
182
+ host: 'http://10.0.0.9:4725',
183
+ nodeId: 'node-2',
184
+ platform: 'android',
185
+ },
186
+ });
187
+ yield session({ id: 'which', node_id: 'old-boot', device_udid: 'emulator-5554' });
188
+ const res = yield (0, loopbackRequest_1.default)(app()).get('/session');
189
+ (0, chai_1.expect)(res.body[0].ranOn).to.equal(null);
190
+ }));
191
+ });
192
+ describe('GET /session/:id', () => {
193
+ // The detail page's header names who ran the session and where, as its row does.
194
+ it('says who ran the session and where, as the list does', () => __awaiter(void 0, void 0, void 0, function* () {
195
+ yield user('u-priya', 'Priya Shah', 'priya@example.com');
196
+ yield scratch.db.device.create({
197
+ data: {
198
+ udid: 'node-phone',
199
+ host: 'http://10.0.0.9:4725',
200
+ nodeId: 'node-2',
201
+ platform: 'ios',
202
+ },
203
+ });
204
+ yield session({
205
+ id: 'one',
206
+ user_id: 'u-priya',
207
+ node_id: 'node-2',
208
+ device_udid: 'node-phone',
209
+ });
210
+ const res = yield (0, loopbackRequest_1.default)(app()).get('/session/one');
211
+ (0, chai_1.expect)(res.status).to.equal(200);
212
+ (0, chai_1.expect)(res.body.id).to.equal('one');
213
+ (0, chai_1.expect)(res.body.owner).to.deep.equal({ name: 'Priya Shah', email: 'priya@example.com' });
214
+ (0, chai_1.expect)(res.body.ranOn).to.equal('10.0.0.9:4725');
215
+ }));
216
+ });
217
+ describe('GET /session-summary', () => {
218
+ it('summarizes the period and the one before it, of the same length', () => __awaiter(void 0, void 0, void 0, function* () {
219
+ const build = yield scratch.db.build.create({ data: { name: 'nightly' } });
220
+ const inPeriod = (status, minutesAgo, durationS, udid = 'phone-1') => session({
221
+ status,
222
+ build_id: build.id,
223
+ device_udid: udid,
224
+ createdAt: ago(minutesAgo * MIN),
225
+ startTime: ago(minutesAgo * MIN),
226
+ endTime: durationS === undefined ? null : new Date(now - minutesAgo * MIN + durationS * 1000),
227
+ });
228
+ // The period: the last hour.
229
+ yield inPeriod('success', 50, 10);
230
+ yield inPeriod('passed', 45, 20);
231
+ yield inPeriod('ended', 40, 30);
232
+ yield inPeriod('failed', 35, 40);
233
+ yield inPeriod('error', 30, 50);
234
+ yield inPeriod('timeout', 25, 600);
235
+ yield inPeriod('unmarked', 20, 5);
236
+ yield inPeriod('running', 10, undefined, 'phone-1');
237
+ yield inPeriod('running', 5, undefined, 'phone-2');
238
+ // The hour before it.
239
+ yield inPeriod('success', 90, 10);
240
+ yield inPeriod('failed', 80, 10);
241
+ // Older than both, still running: running now, but in neither period.
242
+ yield inPeriod('running', 300, undefined, 'phone-1');
243
+ const res = yield (0, loopbackRequest_1.default)(app()).get(`/session-summary?since=${ago(HOUR).toISOString()}`);
244
+ (0, chai_1.expect)(res.status).to.equal(200);
245
+ (0, chai_1.expect)(res.body.since).to.equal(ago(HOUR).toISOString());
246
+ (0, chai_1.expect)(res.body.current).to.deep.equal({
247
+ total: 9,
248
+ passed: 3,
249
+ failed: 3,
250
+ running: 2,
251
+ // Of every session in it that has ended, verdict or not: 5, 10, 20,
252
+ // 30, 40, 50 and 600 s. Nearest rank: the 4th and the 7th of 7.
253
+ medianMs: 30000,
254
+ p90Ms: 600000,
255
+ });
256
+ (0, chai_1.expect)(res.body.previous).to.deep.equal({ total: 2, passed: 1, failed: 1, running: 0 });
257
+ (0, chai_1.expect)(res.body.runningNow).to.deep.equal({ sessions: 3, devices: 2 });
258
+ }));
259
+ it('covers one build, with no period before it, when asked for a build', () => __awaiter(void 0, void 0, void 0, function* () {
260
+ const mine = yield scratch.db.build.create({ data: { name: 'mine' } });
261
+ const other = yield scratch.db.build.create({ data: { name: 'other' } });
262
+ yield session({
263
+ build_id: mine.id,
264
+ status: 'success',
265
+ startTime: ago(2 * MIN),
266
+ endTime: ago(MIN),
267
+ });
268
+ yield session({ build_id: mine.id, status: 'running' });
269
+ yield session({ build_id: other.id, status: 'failed' });
270
+ yield session({ build_id: other.id, status: 'running', device_udid: 'phone-9' });
271
+ const res = yield (0, loopbackRequest_1.default)(app()).get(`/session-summary?buildId=${mine.id}`);
272
+ (0, chai_1.expect)(res.body.since).to.equal(null);
273
+ (0, chai_1.expect)(res.body.current).to.deep.equal({
274
+ total: 2,
275
+ passed: 1,
276
+ failed: 0,
277
+ running: 1,
278
+ medianMs: 60000,
279
+ p90Ms: 60000,
280
+ });
281
+ (0, chai_1.expect)(res.body.previous).to.equal(null);
282
+ (0, chai_1.expect)(res.body.runningNow).to.deep.equal({ sessions: 1, devices: 1 });
283
+ }));
284
+ it('has no durations to report when nothing has finished', () => __awaiter(void 0, void 0, void 0, function* () {
285
+ yield session({ status: 'running' });
286
+ const res = yield (0, loopbackRequest_1.default)(app()).get('/session-summary');
287
+ (0, chai_1.expect)(res.body.current.medianMs).to.equal(null);
288
+ (0, chai_1.expect)(res.body.current.p90Ms).to.equal(null);
289
+ }));
290
+ it('counts only the sessions the caller may see', () => __awaiter(void 0, void 0, void 0, function* () {
291
+ const team = yield scratch.db.team.create({ data: { name: 'b-team' } });
292
+ yield scratch.db.device.create({
293
+ data: {
294
+ udid: 'shared',
295
+ host: 'http://127.0.0.1:4723',
296
+ nodeId: 'hub-1',
297
+ platform: 'android',
298
+ },
299
+ });
300
+ yield scratch.db.device.create({
301
+ data: {
302
+ udid: 'team-b',
303
+ host: 'http://127.0.0.1:4723',
304
+ nodeId: 'hub-1',
305
+ platform: 'android',
306
+ teamId: team.id,
307
+ },
308
+ });
309
+ yield session({ device_udid: 'shared', status: 'success' });
310
+ yield session({ device_udid: 'team-b', status: 'failed' });
311
+ yield session({ device_udid: 'team-b', status: 'running' });
312
+ caller = {
313
+ kind: 'user-session',
314
+ userId: 'member-1',
315
+ role: 'MEMBER',
316
+ scopes: 'sessions',
317
+ teamIds: [],
318
+ };
319
+ const res = yield (0, loopbackRequest_1.default)(app()).get('/session-summary');
320
+ (0, chai_1.expect)(res.body.current).to.include({ total: 1, passed: 1, failed: 0, running: 0 });
321
+ (0, chai_1.expect)(res.body.runningNow).to.deep.equal({ sessions: 0, devices: 0 });
322
+ }));
323
+ it('refuses a since that is not a date', () => __awaiter(void 0, void 0, void 0, function* () {
324
+ const res = yield (0, loopbackRequest_1.default)(app()).get('/session-summary?since=soon');
325
+ (0, chai_1.expect)(res.status).to.equal(400);
326
+ (0, chai_1.expect)(res.body.error).to.equal('invalid_since');
327
+ }));
328
+ });
329
+ describe('GET /build', () => {
330
+ // The page's pass/fail bar and the session rows must agree on a verdict.
331
+ it('counts every failing status as failed and every passing one as passed', () => __awaiter(void 0, void 0, void 0, function* () {
332
+ const build = yield scratch.db.build.create({ data: { name: 'nightly' } });
333
+ for (const status of [
334
+ 'success',
335
+ 'passed',
336
+ 'ended',
337
+ 'failed',
338
+ 'error',
339
+ 'timeout',
340
+ 'running',
341
+ 'unmarked',
342
+ ]) {
343
+ yield session({ build_id: build.id, status });
344
+ }
345
+ const res = yield (0, loopbackRequest_1.default)(app()).get('/build');
346
+ (0, chai_1.expect)(res.body[0]).to.include({
347
+ sessionCount: 8,
348
+ passedCount: 3,
349
+ failedCount: 3,
350
+ runningCount: 1,
351
+ });
352
+ }));
353
+ });
354
+ });