@xenon-device-management/xenon 2.12.0 → 2.13.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 +2 -2
- package/lib/public/assets/{AnnotationOverlay-Crkn72_3.js → AnnotationOverlay-CzbFYtPI.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-BThVlj_j.js → ApiKeyGate-CA5KFHHJ.js} +1 -1
- package/lib/public/assets/{BugReportButton-Dwa0GnBh.js → BugReportButton-D-DSGg8C.js} +1 -1
- package/lib/public/assets/{DeviceMosaicView-Ccc-pOls.js → DeviceMosaicView-paDEFU4B.js} +1 -1
- package/lib/public/assets/{EmptyState-BeuTrlN_.js → EmptyState-BE_5f8Vw.js} +1 -1
- package/lib/public/assets/{FieldGroup-DBIaNxl1.js → FieldGroup-DqMf7Rf3.js} +1 -1
- package/lib/public/assets/{FilterMenu-BgSAS6iT.js → FilterMenu-BQY75Su3.js} +1 -1
- package/lib/public/assets/{Menu-GCbP6gYN.js → Menu-BiW3os9u.js} +1 -1
- package/lib/public/assets/{Modal-ByESeBRC.js → Modal-DfawTxih.js} +1 -1
- package/lib/public/assets/{RecordingPage-Bwyf3WMl.js → RecordingPage-BvemyRiy.js} +1 -1
- package/lib/public/assets/{RecordingsPage-7kM1rB3i.js → RecordingsPage-CXfw7wpd.js} +1 -1
- package/lib/public/assets/{SegmentedControl-B8tTU8kD.js → SegmentedControl-B7m9kpom.js} +1 -1
- package/lib/public/assets/{SettingCard-Clg_Ijbc.js → SettingCard-Cw6lDD0l.js} +1 -1
- package/lib/public/assets/{Table-CIaR0QWV.js → Table-DlEuFgv-.js} +1 -1
- package/lib/public/assets/{activity-Bengh9SD.js → activity-CnLvNwae.js} +1 -1
- package/lib/public/assets/{ai-settings-G9SymiPH.js → ai-settings-D7Te8o-a.js} +1 -1
- package/lib/public/assets/{api-keys-CrGlbfn-.js → api-keys-DphSVzBr.js} +1 -1
- package/lib/public/assets/{apps-DIbcGVlU.js → apps-vUfu15lX.js} +1 -1
- package/lib/public/assets/{arrow-left-BtJIXXMY.js → arrow-left-CLQWAiCY.js} +1 -1
- package/lib/public/assets/{arrow-right-zm-NagQh.js → arrow-right-K6A5e_Ee.js} +1 -1
- package/lib/public/assets/{arrow-up-right-DdU8YEYp.js → arrow-up-right-DUA_lq6f.js} +1 -1
- package/lib/public/assets/{auth-shell-CHmBFmOz.js → auth-shell-BYRHcZd6.js} +2 -2
- package/lib/public/assets/{builds-page-JkzWKRpU.js → builds-page-C0fTvIG8.js} +1 -1
- package/lib/public/assets/{button-CqhPBGRj.js → button-BxnHCCsX.js} +1 -1
- package/lib/public/assets/{calendar-C7eYYJTo.js → calendar-CqVsn28A.js} +1 -1
- package/lib/public/assets/{check-DpbIM4E0.js → check-R6V3bot0.js} +1 -1
- package/lib/public/assets/{chevron-right-Dl4X1PRz.js → chevron-right-BA2Fntu-.js} +1 -1
- package/lib/public/assets/{circle-check-Du3sjbfV.js → circle-check-D4EzyGzs.js} +1 -1
- package/lib/public/assets/{circle-x-BSSqhpXi.js → circle-x-Cf6AF2B2.js} +1 -1
- package/lib/public/assets/{clock-DMT60v1C.js → clock-EZfet0sS.js} +1 -1
- package/lib/public/assets/{copy-BKNyOehd.js → copy-Bc6P8v9V.js} +1 -1
- package/lib/public/assets/{device-explorer-CoPR8DW3.js → device-explorer-BFXpRq6d.js} +1 -1
- package/lib/public/assets/{download-BM6Xn22t.js → download-Bd8OGLzi.js} +1 -1
- package/lib/public/assets/{forgot-password-B8WqMqBT.js → forgot-password-CoE7Ugtx.js} +1 -1
- package/lib/public/assets/{index-DauQh6ie.js → index-BiL0Enl_.js} +1 -1
- package/lib/public/assets/{index-ClrpAMAT.js → index-CHOr4JCs.js} +2 -2
- package/lib/public/assets/{input-CyKdLnEx.js → input-CJ8z3IO5.js} +1 -1
- package/lib/public/assets/{line-chart-EIBXwYGo.js → line-chart-CPK0ObjV.js} +1 -1
- package/lib/public/assets/{list-checks-fWsgD9bI.js → list-checks-mXGHUVbB.js} +1 -1
- package/lib/public/assets/{lock-CVCe56TH.js → lock-BSXM1xvq.js} +1 -1
- package/lib/public/assets/{login-BJ8a7yVD.js → login-Ct2iW066.js} +1 -1
- package/lib/public/assets/{maintenance-settings-DcRmTLS6.js → maintenance-settings-DaOs00P6.js} +1 -1
- package/lib/public/assets/{monitor-Bw1YQZnL.js → monitor-D8WQK8md.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-C01jbqkO.js → mouse-pointer-2-Cw2tWzDX.js} +1 -1
- package/lib/public/assets/{network-CBGUjJDJ.js → network-Ba_uFxWo.js} +1 -1
- package/lib/public/assets/{overview-Cxe8aQ7C.js → overview-CxrZ7_6k.js} +1 -1
- package/lib/public/assets/{page-header-B92DKLiq.js → page-header-Cm1XL49f.js} +1 -1
- package/lib/public/assets/{play-Ck0L-0_m.js → play-C0CuKLXj.js} +1 -1
- package/lib/public/assets/{plus-Dq3tCy2N.js → plus-CIIvR_6Z.js} +1 -1
- package/lib/public/assets/{profile-page--dkDiKbr.js → profile-page-BZ_e-Bgw.js} +1 -1
- package/lib/public/assets/{recording-group-store-5BYIFFN9.js → recording-group-store-BWeeNs2_.js} +1 -1
- package/lib/public/assets/{reset-password-KYWlfjia.js → reset-password-lfNz8Rm1.js} +1 -1
- package/lib/public/assets/{runbook-page-mnsxgS1d.js → runbook-page-Cnec3VSx.js} +1 -1
- package/lib/public/assets/{select-BBOIZTYm.js → select-CZuCnold.js} +1 -1
- package/lib/public/assets/{selector-detail-redirect-CcI2j_Su.js → selector-detail-redirect-BG3pxFLw.js} +1 -1
- package/lib/public/assets/{selector-health-page-BdJb_x5L.js → selector-health-page-DsyL58tb.js} +1 -1
- package/lib/public/assets/{session-detail-page-BjLaJy8z.js → session-detail-page-ZZyGM413.js} +1 -1
- package/lib/public/assets/{settings-B6cpMhsx.js → settings-DGZ7B_AR.js} +1 -1
- package/lib/public/assets/{stat-tile-BU9e4s36.js → stat-tile-DFhv8JoI.js} +1 -1
- package/lib/public/assets/{tablet-hgbEwrWq.js → tablet-68J5f6zh.js} +1 -1
- package/lib/public/assets/{teams-uiiG4hZM.js → teams-eHSyEpbD.js} +1 -1
- package/lib/public/assets/{trash-2-NK_Iazmg.js → trash-2-CcSsv4fS.js} +1 -1
- package/lib/public/assets/{upload-BUX8TNFi.js → upload-BYf26K71.js} +1 -1
- package/lib/public/assets/{use-builds-data-DZszmFyl.js → use-builds-data-CHW-MhaP.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-BK4B9kRC.js → use-password-reset-mode-DozShXkk.js} +1 -1
- package/lib/public/assets/{users-BU4XBbMT.js → users-BpYnpOl2.js} +1 -1
- package/lib/public/assets/{users-BDh1xjad.js → users-CcSQRoK9.js} +1 -1
- package/lib/public/assets/{video-off-BOjNwT4R.js → video-off-dFKgps73.js} +1 -1
- package/lib/public/assets/{webhook-settings-BMzyUcx5.js → webhook-settings-DD1XjiOD.js} +1 -1
- package/lib/public/assets/{zap-DTWwVMUg.js → zap-COC2tZaH.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/src/app/apiErrors.js +118 -0
- package/lib/src/app/index.js +6 -1
- package/lib/src/app/openapi/control.yaml +3129 -0
- package/lib/src/app/openapi/grid.yaml +2295 -0
- package/lib/src/app/openapi/identity.yaml +2168 -0
- package/lib/src/app/openapi/platform.yaml +2885 -0
- package/lib/src/app/openapi/sessions.yaml +3784 -0
- package/lib/src/app/routers/bug-report.js +4 -1
- package/lib/src/app/routers/config.js +6 -107
- package/lib/src/app/routers/control.js +117 -59
- package/lib/src/app/routers/dashboard.js +13 -7
- package/lib/src/app/routers/grid.js +54 -14
- package/lib/src/app/routers/profile.js +27 -13
- package/lib/src/app/routers/recordings.js +11 -5
- package/lib/src/app/routers/reservation.js +63 -15
- package/lib/src/app/routers/users.js +4 -0
- package/lib/src/app/routers/webhook.js +17 -8
- package/lib/src/app/swagger.js +259 -177
- package/lib/src/data-service/device-service.js +4 -1
- package/lib/src/data-service/deviceFieldOwners.js +1 -0
- package/lib/src/device-managers/AndroidDeviceManager.js +5 -2
- package/lib/src/device-managers/ios/WDAClient.js +32 -32
- package/lib/src/generated/client/edge.js +4 -3
- package/lib/src/generated/client/index-browser.js +1 -0
- package/lib/src/generated/client/index.d.ts +38 -0
- package/lib/src/generated/client/index.js +4 -3
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +2 -0
- package/lib/src/generated/client/wasm.js +1 -0
- package/lib/src/middleware/csrfMiddleware.js +13 -5
- package/lib/src/middleware/rateLimitMiddleware.js +19 -5
- package/lib/src/middleware/roleGuard.js +20 -0
- package/lib/src/services/AIService.js +13 -3
- package/lib/src/services/NotificationService.js +28 -28
- package/lib/src/services/bug-report/BugReportService.js +8 -2
- package/lib/src/services/lease/LeaseService.js +71 -20
- package/lib/src/services/omni-vision/OmniVisionService.js +14 -5
- package/lib/src/services/recording/RecordingOrchestrator.js +16 -2
- package/lib/test/helpers/expressRoutes.js +41 -0
- package/lib/test/integration/team-visibility-control.spec.js +2 -2
- package/lib/test/unit/access-scopes.spec.js +227 -0
- package/lib/test/unit/api-error-handling.spec.js +179 -0
- package/lib/test/unit/bug-report/route.spec.js +29 -0
- package/lib/test/unit/bug-report/service.spec.js +30 -0
- package/lib/test/unit/control-honest-answers.spec.js +134 -0
- package/lib/test/unit/device-allocation-routes.spec.js +232 -0
- package/lib/test/unit/healing-state-endpoints.spec.js +8 -5
- package/lib/test/unit/install-repository-app-team.spec.js +4 -1
- package/lib/test/unit/lease/LeaseService.spec.js +5 -4
- package/lib/test/unit/lease/lease-device-match.spec.js +161 -0
- package/lib/test/unit/lease/lease-session-ownership.spec.js +10 -9
- package/lib/test/unit/omni-vision-failures.spec.js +81 -0
- package/lib/test/unit/openapi-coverage.spec.js +114 -0
- package/lib/test/unit/profile-router.test.js +42 -0
- package/lib/test/unit/rateLimitMiddleware.test.js +49 -0
- package/lib/test/unit/recording-orchestrator.spec.js +80 -0
- package/lib/test/unit/recordings-library-routes.spec.js +37 -0
- package/lib/test/unit/reservation-team-visibility.spec.js +4 -3
- package/lib/test/unit/reset-link.test.js +2 -1
- package/lib/test/unit/stream-ticket-identity.spec.js +1 -1
- package/lib/test/unit/users-router.test.js +14 -1
- package/lib/test/unit/wda-client-failures.spec.js +90 -0
- package/lib/test/unit/webhook-delivery.spec.js +142 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/prisma/migrations/20261004120000_reservation_holder/migration.sql +2 -0
- package/prisma/schema.prisma +2 -0
- package/lib/src/app/swagger-docs.js +0 -1701
|
@@ -0,0 +1,2885 @@
|
|
|
1
|
+
paths:
|
|
2
|
+
# ---------------------------------------------------------------- Health & Ops
|
|
3
|
+
/api/health:
|
|
4
|
+
get:
|
|
5
|
+
operationId: getServerHealth
|
|
6
|
+
summary: Check that the server is up
|
|
7
|
+
description: |
|
|
8
|
+
Liveness probe. Public: no credential, no rate limit. Answers `{ "ok": true }` as soon as
|
|
9
|
+
the API is serving requests. It checks nothing else (not the database, not the devices),
|
|
10
|
+
so use it for load-balancer and Kubernetes liveness checks, not readiness.
|
|
11
|
+
tags:
|
|
12
|
+
- Health & Ops
|
|
13
|
+
security: []
|
|
14
|
+
responses:
|
|
15
|
+
'200':
|
|
16
|
+
description: The server is up.
|
|
17
|
+
content:
|
|
18
|
+
application/json:
|
|
19
|
+
schema:
|
|
20
|
+
type: object
|
|
21
|
+
required:
|
|
22
|
+
- ok
|
|
23
|
+
properties:
|
|
24
|
+
ok:
|
|
25
|
+
type: boolean
|
|
26
|
+
example: true
|
|
27
|
+
example:
|
|
28
|
+
ok: true
|
|
29
|
+
/api/ping:
|
|
30
|
+
get:
|
|
31
|
+
operationId: pingServer
|
|
32
|
+
summary: Check a credential and read the server version
|
|
33
|
+
description: |
|
|
34
|
+
Answers `{ pong, version }` for any valid credential. Use it to confirm that a key pair,
|
|
35
|
+
bearer token or dashboard session is accepted, and to read the running Xenon version.
|
|
36
|
+
Counts against the `read` rate-limit budget.
|
|
37
|
+
tags:
|
|
38
|
+
- Health & Ops
|
|
39
|
+
responses:
|
|
40
|
+
'200':
|
|
41
|
+
description: The credential is valid.
|
|
42
|
+
content:
|
|
43
|
+
application/json:
|
|
44
|
+
schema:
|
|
45
|
+
type: object
|
|
46
|
+
required:
|
|
47
|
+
- pong
|
|
48
|
+
- version
|
|
49
|
+
properties:
|
|
50
|
+
pong:
|
|
51
|
+
type: boolean
|
|
52
|
+
example: true
|
|
53
|
+
version:
|
|
54
|
+
type: string
|
|
55
|
+
description: The running Xenon version.
|
|
56
|
+
example: 2.12.0
|
|
57
|
+
example:
|
|
58
|
+
pong: true
|
|
59
|
+
version: 2.12.0
|
|
60
|
+
'401':
|
|
61
|
+
$ref: '#/components/responses/Unauthorized'
|
|
62
|
+
'429':
|
|
63
|
+
$ref: '#/components/responses/RateLimited'
|
|
64
|
+
/api/metrics:
|
|
65
|
+
get:
|
|
66
|
+
operationId: getPrometheusMetrics
|
|
67
|
+
summary: Read server metrics in Prometheus format
|
|
68
|
+
description: |
|
|
69
|
+
Prometheus text exposition of the server's counters and gauges. Any valid credential may
|
|
70
|
+
read it (no role or scope beyond being signed in); point a scraper at it with a key pair
|
|
71
|
+
or bearer token.
|
|
72
|
+
|
|
73
|
+
Series:
|
|
74
|
+
- `xenon_sessions_total{status="started"|"success"|"failure"}`: persisted session counters.
|
|
75
|
+
- `xenon_sessions_active`: sessions this server tracks right now.
|
|
76
|
+
- `xenon_devices_total`, `xenon_devices_busy`, `xenon_devices_offline`.
|
|
77
|
+
- `xenon_healing_total{status="attempt"|"success"}`: persisted self-healing counters.
|
|
78
|
+
- Per-tier healing series (`xenon_heal_tier_attempts_total`, `..._successes_total`,
|
|
79
|
+
`..._failures_total`, `xenon_heal_tier_duration_seconds_sum`, labelled `tier` and
|
|
80
|
+
`name`), `xenon_heal_all_tiers_failed_total` and
|
|
81
|
+
`xenon_heal_tier_skipped_remaining_total`. These are kept in memory and start again
|
|
82
|
+
from zero when the server restarts.
|
|
83
|
+
tags:
|
|
84
|
+
- Health & Ops
|
|
85
|
+
responses:
|
|
86
|
+
'200':
|
|
87
|
+
description: The metrics, as Prometheus text.
|
|
88
|
+
content:
|
|
89
|
+
text/plain:
|
|
90
|
+
schema:
|
|
91
|
+
type: string
|
|
92
|
+
example: |
|
|
93
|
+
# HELP xenon_sessions_total Total number of sessions across all time
|
|
94
|
+
# TYPE xenon_sessions_total counter
|
|
95
|
+
xenon_sessions_total{status="started"} 1284
|
|
96
|
+
xenon_sessions_total{status="success"} 1190
|
|
97
|
+
xenon_sessions_total{status="failure"} 94
|
|
98
|
+
# HELP xenon_sessions_active Current active sessions in the hub
|
|
99
|
+
# TYPE xenon_sessions_active gauge
|
|
100
|
+
xenon_sessions_active 3
|
|
101
|
+
# HELP xenon_devices_total Total managed devices in the fleet
|
|
102
|
+
# TYPE xenon_devices_total gauge
|
|
103
|
+
xenon_devices_total 12
|
|
104
|
+
'401':
|
|
105
|
+
$ref: '#/components/responses/Unauthorized'
|
|
106
|
+
'429':
|
|
107
|
+
$ref: '#/components/responses/RateLimited'
|
|
108
|
+
# ----------------------------------------------------------------------- Admin
|
|
109
|
+
/api/cliArgs:
|
|
110
|
+
get:
|
|
111
|
+
operationId: listStoredCliArgs
|
|
112
|
+
summary: Read the Appium arguments the server stored
|
|
113
|
+
description: |
|
|
114
|
+
The plugin arguments this lab has stored, one object per stored entry, oldest first. They
|
|
115
|
+
include the database URL, AI and cloud provider settings and the hub URL, so this needs
|
|
116
|
+
the `ADMIN` role and the `admin` scope. Values are redacted the way the server log
|
|
117
|
+
redacts them: a value under a key that looks secret (password, token, API key, auth,
|
|
118
|
+
database URL, and similar) and any string shaped like a provider key reads
|
|
119
|
+
`***REDACTED***`.
|
|
120
|
+
tags:
|
|
121
|
+
- Admin
|
|
122
|
+
responses:
|
|
123
|
+
'200':
|
|
124
|
+
description: The stored arguments, redacted.
|
|
125
|
+
content:
|
|
126
|
+
application/json:
|
|
127
|
+
schema:
|
|
128
|
+
type: array
|
|
129
|
+
items:
|
|
130
|
+
type: object
|
|
131
|
+
additionalProperties: true
|
|
132
|
+
example:
|
|
133
|
+
- platform: both
|
|
134
|
+
hub: http://192.168.1.10:4723
|
|
135
|
+
bindHostOrIp: 192.168.1.20
|
|
136
|
+
databaseUrl: '***REDACTED***'
|
|
137
|
+
aiProvider: gemini
|
|
138
|
+
geminiApiKey: '***REDACTED***'
|
|
139
|
+
'401':
|
|
140
|
+
$ref: '#/components/responses/Unauthorized'
|
|
141
|
+
'403':
|
|
142
|
+
$ref: '#/components/responses/Forbidden'
|
|
143
|
+
'429':
|
|
144
|
+
$ref: '#/components/responses/RateLimited'
|
|
145
|
+
/api/processes:
|
|
146
|
+
get:
|
|
147
|
+
operationId: listSupervisedProcesses
|
|
148
|
+
summary: List the child processes Xenon supervises
|
|
149
|
+
description: |
|
|
150
|
+
A snapshot of the long-lived child processes this server has started and tracks:
|
|
151
|
+
WebDriverAgent, ffmpeg recorders, `adb reverse` forwards, iOS MJPEG forwarders, log
|
|
152
|
+
tailers and others. Useful when a host runs hot or a device seems wedged. `sessionId` and
|
|
153
|
+
`udid` are present only when the process belongs to one. Needs the `ADMIN` role and the
|
|
154
|
+
`admin` scope.
|
|
155
|
+
tags:
|
|
156
|
+
- Admin
|
|
157
|
+
responses:
|
|
158
|
+
'200':
|
|
159
|
+
description: The supervised processes.
|
|
160
|
+
content:
|
|
161
|
+
application/json:
|
|
162
|
+
schema:
|
|
163
|
+
type: array
|
|
164
|
+
items:
|
|
165
|
+
$ref: '#/components/schemas/PlatformProcess'
|
|
166
|
+
example:
|
|
167
|
+
- id: 6f1d2c3b-4a5e-4f60-8a7b-9c0d1e2f3a4b
|
|
168
|
+
udid: 00008110-00084CE80E51401E
|
|
169
|
+
kind: wda
|
|
170
|
+
pid: 48213
|
|
171
|
+
uptimeMs: 734120
|
|
172
|
+
- id: 0b9a8c7d-6e5f-4a3b-8c2d-1e0f9a8b7c6d
|
|
173
|
+
sessionId: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
174
|
+
udid: R5CT32ABCDE
|
|
175
|
+
kind: ffmpeg
|
|
176
|
+
pid: 48977
|
|
177
|
+
uptimeMs: 61500
|
|
178
|
+
'401':
|
|
179
|
+
$ref: '#/components/responses/Unauthorized'
|
|
180
|
+
'403':
|
|
181
|
+
$ref: '#/components/responses/Forbidden'
|
|
182
|
+
'429':
|
|
183
|
+
$ref: '#/components/responses/RateLimited'
|
|
184
|
+
# ---------------------------------------------------------------- Configuration
|
|
185
|
+
/api/config:
|
|
186
|
+
get:
|
|
187
|
+
operationId: getLabSettings
|
|
188
|
+
summary: Read the lab's settings and AI provider
|
|
189
|
+
description: |
|
|
190
|
+
The lab-wide settings stored in the database (health checks and build cleanup), merged
|
|
191
|
+
with the AI settings currently in effect. API keys are never returned: `geminiSet`,
|
|
192
|
+
`openaiSet` and `anthropicSet` say only whether one is configured. A setting never saved
|
|
193
|
+
is absent. Needs the `ADMIN` role or above, as the dashboard's settings pages do; through
|
|
194
|
+
2.12 any signed-in user could read it.
|
|
195
|
+
tags:
|
|
196
|
+
- Configuration
|
|
197
|
+
responses:
|
|
198
|
+
'200':
|
|
199
|
+
description: The settings.
|
|
200
|
+
content:
|
|
201
|
+
application/json:
|
|
202
|
+
schema:
|
|
203
|
+
$ref: '#/components/schemas/PlatformSettings'
|
|
204
|
+
example:
|
|
205
|
+
healthCheckIntervalMs: 300000
|
|
206
|
+
buildCleanupDays: 30
|
|
207
|
+
aiProvider: gemini
|
|
208
|
+
aiModel: gemini-3-flash-preview
|
|
209
|
+
geminiModel: gemini-3-flash-preview
|
|
210
|
+
openaiModel: gpt-4o
|
|
211
|
+
anthropicModel: claude-sonnet-4-6
|
|
212
|
+
ollamaModel: llama3
|
|
213
|
+
geminiSet: true
|
|
214
|
+
openaiSet: false
|
|
215
|
+
anthropicSet: false
|
|
216
|
+
'401':
|
|
217
|
+
$ref: '#/components/responses/Unauthorized'
|
|
218
|
+
'429':
|
|
219
|
+
$ref: '#/components/responses/RateLimited'
|
|
220
|
+
'500':
|
|
221
|
+
description: The settings could not be read.
|
|
222
|
+
content:
|
|
223
|
+
application/json:
|
|
224
|
+
schema:
|
|
225
|
+
$ref: '#/components/schemas/Error'
|
|
226
|
+
example:
|
|
227
|
+
error: true
|
|
228
|
+
message: Can't reach database server
|
|
229
|
+
post:
|
|
230
|
+
operationId: updateLabSettings
|
|
231
|
+
summary: Change the lab's settings and AI provider
|
|
232
|
+
description: |
|
|
233
|
+
Saves the lab-wide settings and changes the AI provider settings. Every field is
|
|
234
|
+
optional; fields not sent are left as they are, and unknown fields are ignored.
|
|
235
|
+
|
|
236
|
+
- Health-check and build-cleanup fields are stored in the database.
|
|
237
|
+
- AI fields (`aiProvider`, `aiModel`, `aiBaseUrl` and the per-provider models) change the
|
|
238
|
+
running server only. They are not stored, so a restart returns to the values the
|
|
239
|
+
server was started with. API keys can't be set here; they come from the server's
|
|
240
|
+
environment.
|
|
241
|
+
|
|
242
|
+
Needs the `SUPER_ADMIN` role and the `admin` scope; an `ADMIN` gets `403` with a message
|
|
243
|
+
saying so (through 2.12 an `ADMIN` could). With the dashboard cookie the request must
|
|
244
|
+
come from the same host (`Origin` or `Referer`).
|
|
245
|
+
tags:
|
|
246
|
+
- Configuration
|
|
247
|
+
requestBody:
|
|
248
|
+
required: true
|
|
249
|
+
content:
|
|
250
|
+
application/json:
|
|
251
|
+
schema:
|
|
252
|
+
$ref: '#/components/schemas/PlatformSettingsUpdate'
|
|
253
|
+
example:
|
|
254
|
+
healthCheckIntervalMs: 300000
|
|
255
|
+
buildCleanupDays: 14
|
|
256
|
+
deleteBuildAssets: true
|
|
257
|
+
aiProvider: anthropic
|
|
258
|
+
anthropicModel: claude-sonnet-4-6
|
|
259
|
+
responses:
|
|
260
|
+
'200':
|
|
261
|
+
description: Saved.
|
|
262
|
+
content:
|
|
263
|
+
application/json:
|
|
264
|
+
schema:
|
|
265
|
+
$ref: '#/components/schemas/Success'
|
|
266
|
+
example:
|
|
267
|
+
success: true
|
|
268
|
+
'401':
|
|
269
|
+
$ref: '#/components/responses/Unauthorized'
|
|
270
|
+
'403':
|
|
271
|
+
$ref: '#/components/responses/PlatformSettingsRefused'
|
|
272
|
+
'429':
|
|
273
|
+
$ref: '#/components/responses/RateLimited'
|
|
274
|
+
'500':
|
|
275
|
+
description: The settings could not be saved.
|
|
276
|
+
content:
|
|
277
|
+
application/json:
|
|
278
|
+
schema:
|
|
279
|
+
$ref: '#/components/schemas/Error'
|
|
280
|
+
example:
|
|
281
|
+
error: true
|
|
282
|
+
message: Unique constraint failed on the fields (`id`)
|
|
283
|
+
/api/config/reset-metrics:
|
|
284
|
+
post:
|
|
285
|
+
operationId: resetDeviceHealCounts
|
|
286
|
+
summary: Reset every device's healed-selector count
|
|
287
|
+
description: |
|
|
288
|
+
Sets every device's healed-selector count (`totalHealedCount`) back to zero. Nothing else
|
|
289
|
+
is reset: the session and healing counters on `/api/metrics` are unchanged. Takes no
|
|
290
|
+
body. Needs the `SUPER_ADMIN` role and the `admin` scope, as every change under
|
|
291
|
+
`/api/config` does. With the dashboard cookie the request must come from the same host.
|
|
292
|
+
tags:
|
|
293
|
+
- Configuration
|
|
294
|
+
responses:
|
|
295
|
+
'200':
|
|
296
|
+
description: Reset.
|
|
297
|
+
content:
|
|
298
|
+
application/json:
|
|
299
|
+
schema:
|
|
300
|
+
$ref: '#/components/schemas/Success'
|
|
301
|
+
example:
|
|
302
|
+
success: true
|
|
303
|
+
'401':
|
|
304
|
+
$ref: '#/components/responses/Unauthorized'
|
|
305
|
+
'403':
|
|
306
|
+
$ref: '#/components/responses/PlatformSettingsRefused'
|
|
307
|
+
'429':
|
|
308
|
+
$ref: '#/components/responses/RateLimited'
|
|
309
|
+
'500':
|
|
310
|
+
description: The counts could not be reset.
|
|
311
|
+
content:
|
|
312
|
+
application/json:
|
|
313
|
+
schema:
|
|
314
|
+
$ref: '#/components/schemas/Error'
|
|
315
|
+
example:
|
|
316
|
+
error: true
|
|
317
|
+
message: Can't reach database server
|
|
318
|
+
/api/config/test-ai:
|
|
319
|
+
post:
|
|
320
|
+
operationId: testAiProviderConnection
|
|
321
|
+
summary: Test a connection to an AI provider
|
|
322
|
+
description: |
|
|
323
|
+
Sends a one-line prompt to an AI provider and says whether it answered. The provider,
|
|
324
|
+
model and base URL are taken from the body, falling back to the server's current
|
|
325
|
+
settings. The API key is the server's own (from its environment); a key in the body is
|
|
326
|
+
used only when the server has none for that provider. Nothing is saved.
|
|
327
|
+
|
|
328
|
+
The answer is `200` whether or not the connection worked: read `success`. A provider that
|
|
329
|
+
answers but is out of quota still counts as a success, with a note in `message`.
|
|
330
|
+
|
|
331
|
+
Needs the `SUPER_ADMIN` role and the `admin` scope. Counts against the `heavy` rate-limit
|
|
332
|
+
budget. With the dashboard cookie the request must come from the same host.
|
|
333
|
+
tags:
|
|
334
|
+
- Configuration
|
|
335
|
+
requestBody:
|
|
336
|
+
required: false
|
|
337
|
+
content:
|
|
338
|
+
application/json:
|
|
339
|
+
schema:
|
|
340
|
+
$ref: '#/components/schemas/PlatformAiTestRequest'
|
|
341
|
+
example:
|
|
342
|
+
aiProvider: ollama
|
|
343
|
+
aiModel: llama3
|
|
344
|
+
aiBaseUrl: http://localhost:11434
|
|
345
|
+
responses:
|
|
346
|
+
'200':
|
|
347
|
+
description: The result of the test.
|
|
348
|
+
content:
|
|
349
|
+
application/json:
|
|
350
|
+
schema:
|
|
351
|
+
$ref: '#/components/schemas/PlatformAiTestResult'
|
|
352
|
+
examples:
|
|
353
|
+
connected:
|
|
354
|
+
value:
|
|
355
|
+
success: true
|
|
356
|
+
message: Successfully connected to gemini!
|
|
357
|
+
failed:
|
|
358
|
+
value:
|
|
359
|
+
success: false
|
|
360
|
+
message: >-
|
|
361
|
+
Connection failed: OpenAI API Key missing. Please set XENON_OPENAI_API_KEY
|
|
362
|
+
environment variable.
|
|
363
|
+
'401':
|
|
364
|
+
$ref: '#/components/responses/Unauthorized'
|
|
365
|
+
'403':
|
|
366
|
+
$ref: '#/components/responses/Forbidden'
|
|
367
|
+
'429':
|
|
368
|
+
$ref: '#/components/responses/RateLimited'
|
|
369
|
+
# --------------------------------------------------------------------- Webhooks
|
|
370
|
+
/api/webhook:
|
|
371
|
+
get:
|
|
372
|
+
operationId: listWebhooks
|
|
373
|
+
summary: List the webhooks Xenon sends events to
|
|
374
|
+
description: |
|
|
375
|
+
Every configured webhook. `events` is stored and returned as a JSON-encoded string, not
|
|
376
|
+
an array. Needs the `ADMIN` role and the `admin` scope: the URLs are often secrets
|
|
377
|
+
(Slack's are).
|
|
378
|
+
tags:
|
|
379
|
+
- Webhooks
|
|
380
|
+
responses:
|
|
381
|
+
'200':
|
|
382
|
+
description: The webhooks.
|
|
383
|
+
content:
|
|
384
|
+
application/json:
|
|
385
|
+
schema:
|
|
386
|
+
type: array
|
|
387
|
+
items:
|
|
388
|
+
$ref: '#/components/schemas/PlatformWebhook'
|
|
389
|
+
example:
|
|
390
|
+
- id: 2d6f4a8e-1b3c-4d5e-9f60-7a8b9c0d1e2f
|
|
391
|
+
url: https://hooks.slack.com/services/T000/B000/XXXXXXXX
|
|
392
|
+
type: slack
|
|
393
|
+
events: '["device_offline","session_failed"]'
|
|
394
|
+
active: true
|
|
395
|
+
payloadTemplate: null
|
|
396
|
+
createdAt: '2026-09-12T08:14:03.000Z'
|
|
397
|
+
updatedAt: '2026-09-12T08:14:03.000Z'
|
|
398
|
+
'401':
|
|
399
|
+
$ref: '#/components/responses/Unauthorized'
|
|
400
|
+
'403':
|
|
401
|
+
$ref: '#/components/responses/Forbidden'
|
|
402
|
+
'429':
|
|
403
|
+
$ref: '#/components/responses/RateLimited'
|
|
404
|
+
'500':
|
|
405
|
+
description: The webhooks could not be read.
|
|
406
|
+
content:
|
|
407
|
+
application/json:
|
|
408
|
+
schema:
|
|
409
|
+
$ref: '#/components/schemas/Error'
|
|
410
|
+
example:
|
|
411
|
+
error: Failed to fetch configurations
|
|
412
|
+
post:
|
|
413
|
+
operationId: createWebhook
|
|
414
|
+
summary: Add a webhook
|
|
415
|
+
description: |
|
|
416
|
+
Adds a webhook, active at once. When one of its `events` happens Xenon POSTs to `url`:
|
|
417
|
+
|
|
418
|
+
- with `payloadTemplate`: the template, with each `{{key}}` (or `{{a.b}}`) replaced from
|
|
419
|
+
the event (`eventType` plus the event's fields), sent as JSON if the result parses as
|
|
420
|
+
JSON, otherwise as `{ "text": "<result>" }`;
|
|
421
|
+
- otherwise, `type: slack`: a Slack message with an attachment;
|
|
422
|
+
- otherwise: `{ "event": "<event>", "payload": { ... } }`.
|
|
423
|
+
|
|
424
|
+
Each delivery is one POST, never retried. A template whose result isn't JSON is sent
|
|
425
|
+
once, as text. A webhook that refuses an event or can't be reached doesn't fail the
|
|
426
|
+
event: the failure is logged on the server and the other webhooks still get it. Use
|
|
427
|
+
`POST /api/webhook/test` to see whether a webhook accepts deliveries.
|
|
428
|
+
|
|
429
|
+
Events are `device_offline`, `session_failed`, `device_new` and
|
|
430
|
+
`selector_health_digest`. Neither `url` nor the event names are checked. Needs the
|
|
431
|
+
`ADMIN` role and the `admin` scope. With the dashboard cookie the request must come from
|
|
432
|
+
the same host.
|
|
433
|
+
tags:
|
|
434
|
+
- Webhooks
|
|
435
|
+
requestBody:
|
|
436
|
+
required: true
|
|
437
|
+
content:
|
|
438
|
+
application/json:
|
|
439
|
+
schema:
|
|
440
|
+
$ref: '#/components/schemas/PlatformWebhookCreate'
|
|
441
|
+
example:
|
|
442
|
+
url: https://hooks.slack.com/services/T000/B000/XXXXXXXX
|
|
443
|
+
events:
|
|
444
|
+
- device_offline
|
|
445
|
+
- session_failed
|
|
446
|
+
type: slack
|
|
447
|
+
responses:
|
|
448
|
+
'200':
|
|
449
|
+
description: The webhook as stored.
|
|
450
|
+
content:
|
|
451
|
+
application/json:
|
|
452
|
+
schema:
|
|
453
|
+
$ref: '#/components/schemas/PlatformWebhook'
|
|
454
|
+
example:
|
|
455
|
+
id: 2d6f4a8e-1b3c-4d5e-9f60-7a8b9c0d1e2f
|
|
456
|
+
url: https://hooks.slack.com/services/T000/B000/XXXXXXXX
|
|
457
|
+
type: slack
|
|
458
|
+
events: '["device_offline","session_failed"]'
|
|
459
|
+
active: true
|
|
460
|
+
payloadTemplate: null
|
|
461
|
+
createdAt: '2026-10-04T09:30:12.000Z'
|
|
462
|
+
updatedAt: '2026-10-04T09:30:12.000Z'
|
|
463
|
+
'400':
|
|
464
|
+
description: '`url` or the `events` array is missing.'
|
|
465
|
+
content:
|
|
466
|
+
application/json:
|
|
467
|
+
schema:
|
|
468
|
+
$ref: '#/components/schemas/Error'
|
|
469
|
+
example:
|
|
470
|
+
error: 'Invalid parameters: url and events array required'
|
|
471
|
+
'401':
|
|
472
|
+
$ref: '#/components/responses/Unauthorized'
|
|
473
|
+
'403':
|
|
474
|
+
$ref: '#/components/responses/Forbidden'
|
|
475
|
+
'429':
|
|
476
|
+
$ref: '#/components/responses/RateLimited'
|
|
477
|
+
'500':
|
|
478
|
+
description: The webhook could not be saved.
|
|
479
|
+
content:
|
|
480
|
+
application/json:
|
|
481
|
+
schema:
|
|
482
|
+
$ref: '#/components/schemas/Error'
|
|
483
|
+
example:
|
|
484
|
+
error: Failed to save configuration
|
|
485
|
+
/api/webhook/{id}:
|
|
486
|
+
delete:
|
|
487
|
+
operationId: deleteWebhook
|
|
488
|
+
summary: Remove a webhook
|
|
489
|
+
description: |
|
|
490
|
+
Removes a webhook. Needs the `ADMIN` role and the `admin` scope. With the dashboard
|
|
491
|
+
cookie the request must come from the same host. An unknown id answers `404`.
|
|
492
|
+
tags:
|
|
493
|
+
- Webhooks
|
|
494
|
+
parameters:
|
|
495
|
+
- in: path
|
|
496
|
+
name: id
|
|
497
|
+
required: true
|
|
498
|
+
description: The webhook's id.
|
|
499
|
+
schema:
|
|
500
|
+
type: string
|
|
501
|
+
example: 2d6f4a8e-1b3c-4d5e-9f60-7a8b9c0d1e2f
|
|
502
|
+
responses:
|
|
503
|
+
'200':
|
|
504
|
+
description: Removed.
|
|
505
|
+
content:
|
|
506
|
+
application/json:
|
|
507
|
+
schema:
|
|
508
|
+
$ref: '#/components/schemas/Success'
|
|
509
|
+
example:
|
|
510
|
+
success: true
|
|
511
|
+
'401':
|
|
512
|
+
$ref: '#/components/responses/Unauthorized'
|
|
513
|
+
'403':
|
|
514
|
+
$ref: '#/components/responses/Forbidden'
|
|
515
|
+
'404':
|
|
516
|
+
description: No webhook has this id.
|
|
517
|
+
content:
|
|
518
|
+
application/json:
|
|
519
|
+
schema:
|
|
520
|
+
$ref: '#/components/schemas/Error'
|
|
521
|
+
example:
|
|
522
|
+
error: not_found
|
|
523
|
+
message: Webhook not found
|
|
524
|
+
'429':
|
|
525
|
+
$ref: '#/components/responses/RateLimited'
|
|
526
|
+
'500':
|
|
527
|
+
description: Not removed.
|
|
528
|
+
content:
|
|
529
|
+
application/json:
|
|
530
|
+
schema:
|
|
531
|
+
$ref: '#/components/schemas/Error'
|
|
532
|
+
example:
|
|
533
|
+
error: Failed to delete configuration
|
|
534
|
+
/api/webhook/test:
|
|
535
|
+
post:
|
|
536
|
+
operationId: testWebhook
|
|
537
|
+
summary: Send a test message to a URL
|
|
538
|
+
description: |
|
|
539
|
+
Sends a sample `device_new` event (a device named `Test Device`, udid
|
|
540
|
+
`test-device-udid`, host `127.0.0.1`) to `url` the way a webhook with this `type` and
|
|
541
|
+
`payloadTemplate` gets its real events (see `POST /api/webhook`): the filled-in
|
|
542
|
+
template if there is one, else a Slack message for `type: slack` (the default), else
|
|
543
|
+
`{ "event": "device_new", "payload": { ... } }`. No stored webhook is involved.
|
|
544
|
+
|
|
545
|
+
Answers `200` when `url` accepted the delivery (a 2xx), and `502 delivery_failed`, with
|
|
546
|
+
the reason, when it refused it or couldn't be reached. Needs the `ADMIN` role and the
|
|
547
|
+
`admin` scope. With the dashboard cookie the request must come from the same host.
|
|
548
|
+
tags:
|
|
549
|
+
- Webhooks
|
|
550
|
+
requestBody:
|
|
551
|
+
required: true
|
|
552
|
+
content:
|
|
553
|
+
application/json:
|
|
554
|
+
schema:
|
|
555
|
+
type: object
|
|
556
|
+
required:
|
|
557
|
+
- url
|
|
558
|
+
properties:
|
|
559
|
+
url:
|
|
560
|
+
type: string
|
|
561
|
+
format: uri
|
|
562
|
+
description: Where to send the test message.
|
|
563
|
+
example: https://hooks.slack.com/services/T000/B000/XXXXXXXX
|
|
564
|
+
type:
|
|
565
|
+
type: string
|
|
566
|
+
default: slack
|
|
567
|
+
description: >-
|
|
568
|
+
The webhook's type: `slack` for a Slack message, anything else for the
|
|
569
|
+
generic `{ event, payload }` body. Ignored when `payloadTemplate` is given.
|
|
570
|
+
example: slack
|
|
571
|
+
payloadTemplate:
|
|
572
|
+
type: string
|
|
573
|
+
nullable: true
|
|
574
|
+
description: >-
|
|
575
|
+
The webhook's template, filled in from the sample event as a real event's
|
|
576
|
+
would be.
|
|
577
|
+
example: '{"text": "{{eventType}}: {{name}} ({{udid}})"}'
|
|
578
|
+
example:
|
|
579
|
+
url: https://hooks.slack.com/services/T000/B000/XXXXXXXX
|
|
580
|
+
type: slack
|
|
581
|
+
responses:
|
|
582
|
+
'200':
|
|
583
|
+
description: Delivered; `url` answered with a 2xx.
|
|
584
|
+
content:
|
|
585
|
+
application/json:
|
|
586
|
+
schema:
|
|
587
|
+
$ref: '#/components/schemas/Success'
|
|
588
|
+
example:
|
|
589
|
+
success: true
|
|
590
|
+
'400':
|
|
591
|
+
description: '`url` is missing or not a string.'
|
|
592
|
+
content:
|
|
593
|
+
application/json:
|
|
594
|
+
schema:
|
|
595
|
+
$ref: '#/components/schemas/Error'
|
|
596
|
+
example:
|
|
597
|
+
error: url is required
|
|
598
|
+
'401':
|
|
599
|
+
$ref: '#/components/responses/Unauthorized'
|
|
600
|
+
'403':
|
|
601
|
+
$ref: '#/components/responses/Forbidden'
|
|
602
|
+
'429':
|
|
603
|
+
$ref: '#/components/responses/RateLimited'
|
|
604
|
+
'502':
|
|
605
|
+
description: "`url` refused the delivery (a non-2xx answer) or couldn't be reached."
|
|
606
|
+
content:
|
|
607
|
+
application/json:
|
|
608
|
+
schema:
|
|
609
|
+
$ref: '#/components/schemas/Error'
|
|
610
|
+
examples:
|
|
611
|
+
refused:
|
|
612
|
+
summary: The webhook refused it
|
|
613
|
+
value:
|
|
614
|
+
error: delivery_failed
|
|
615
|
+
message: Request failed with status code 404
|
|
616
|
+
unreachable:
|
|
617
|
+
summary: The webhook couldn't be reached
|
|
618
|
+
value:
|
|
619
|
+
error: delivery_failed
|
|
620
|
+
message: getaddrinfo ENOTFOUND hooks.example.invalid
|
|
621
|
+
# ----------------------------------------------------------------- Applications
|
|
622
|
+
/api/apps:
|
|
623
|
+
get:
|
|
624
|
+
operationId: listApps
|
|
625
|
+
summary: List the uploaded apps you can see
|
|
626
|
+
description: |
|
|
627
|
+
The uploaded app builds visible to the caller, newest first, each with its team. Admins
|
|
628
|
+
see every app; members see shared apps (`teamId` null) and their teams' apps. Any
|
|
629
|
+
signed-in user may call it.
|
|
630
|
+
tags:
|
|
631
|
+
- Applications
|
|
632
|
+
responses:
|
|
633
|
+
'200':
|
|
634
|
+
description: The apps.
|
|
635
|
+
content:
|
|
636
|
+
application/json:
|
|
637
|
+
schema:
|
|
638
|
+
type: array
|
|
639
|
+
items:
|
|
640
|
+
$ref: '#/components/schemas/PlatformAppWithTeam'
|
|
641
|
+
example:
|
|
642
|
+
- id: 9adc3f2e-7b1a-4c6d-8e9f-0a1b2c3d4e5f
|
|
643
|
+
name: checkout-2.4.1.apk
|
|
644
|
+
filename: checkout-2.4.1.apk
|
|
645
|
+
filepath: /home/lab/.cache/xenon/apps/9adc3f2e-7b1a-4c6d-8e9f-0a1b2c3d4e5f.apk
|
|
646
|
+
mimetype: application/vnd.android.package-archive
|
|
647
|
+
size: 48213377
|
|
648
|
+
packageName: com.example.checkout
|
|
649
|
+
version: 2.4.1
|
|
650
|
+
platform: android
|
|
651
|
+
md5: 5f4dcc3b5aa765d61d8327deb882cf99
|
|
652
|
+
teamId: 7e1c2a90-3b4d-4f5e-8a6b-1c2d3e4f5a6b
|
|
653
|
+
team:
|
|
654
|
+
id: 7e1c2a90-3b4d-4f5e-8a6b-1c2d3e4f5a6b
|
|
655
|
+
name: Payments
|
|
656
|
+
createdAt: '2026-09-30T14:02:11.000Z'
|
|
657
|
+
updatedAt: '2026-09-30T14:02:11.000Z'
|
|
658
|
+
'401':
|
|
659
|
+
$ref: '#/components/responses/Unauthorized'
|
|
660
|
+
'429':
|
|
661
|
+
$ref: '#/components/responses/RateLimited'
|
|
662
|
+
'500':
|
|
663
|
+
$ref: '#/components/responses/InternalError'
|
|
664
|
+
/api/apps/upload:
|
|
665
|
+
post:
|
|
666
|
+
operationId: uploadApp
|
|
667
|
+
summary: Upload an app build
|
|
668
|
+
description: |
|
|
669
|
+
Stores an app build (multipart field `app`), in a team's library or the shared pool.
|
|
670
|
+
`.apk` files are read for their package name and version, and marked `android`; `.ipa`
|
|
671
|
+
files are marked `ios`; any other file is stored with no platform.
|
|
672
|
+
|
|
673
|
+
Uploading bytes that are already stored (same MD5) stores nothing new and returns the
|
|
674
|
+
existing app as it is, in whatever team it is already in. Move it with
|
|
675
|
+
`PUT /api/apps/{id}/team`.
|
|
676
|
+
|
|
677
|
+
Needs the `ADMIN` role and the `devices` scope (or `admin`). With the dashboard cookie the
|
|
678
|
+
request must come from the same host.
|
|
679
|
+
tags:
|
|
680
|
+
- Applications
|
|
681
|
+
requestBody:
|
|
682
|
+
required: true
|
|
683
|
+
content:
|
|
684
|
+
multipart/form-data:
|
|
685
|
+
schema:
|
|
686
|
+
type: object
|
|
687
|
+
required:
|
|
688
|
+
- app
|
|
689
|
+
properties:
|
|
690
|
+
app:
|
|
691
|
+
type: string
|
|
692
|
+
format: binary
|
|
693
|
+
description: The app file (`.apk` or `.ipa`).
|
|
694
|
+
teamId:
|
|
695
|
+
type: string
|
|
696
|
+
description: The team whose members may see it. Empty or absent means the shared pool.
|
|
697
|
+
example: 7e1c2a90-3b4d-4f5e-8a6b-1c2d3e4f5a6b
|
|
698
|
+
responses:
|
|
699
|
+
'200':
|
|
700
|
+
description: The stored app (new, or the existing one with the same bytes).
|
|
701
|
+
content:
|
|
702
|
+
application/json:
|
|
703
|
+
schema:
|
|
704
|
+
$ref: '#/components/schemas/PlatformApp'
|
|
705
|
+
example:
|
|
706
|
+
id: 9adc3f2e-7b1a-4c6d-8e9f-0a1b2c3d4e5f
|
|
707
|
+
name: checkout-2.4.1.apk
|
|
708
|
+
filename: checkout-2.4.1.apk
|
|
709
|
+
filepath: /home/lab/.cache/xenon/apps/9adc3f2e-7b1a-4c6d-8e9f-0a1b2c3d4e5f.apk
|
|
710
|
+
mimetype: application/vnd.android.package-archive
|
|
711
|
+
size: 48213377
|
|
712
|
+
packageName: com.example.checkout
|
|
713
|
+
version: 2.4.1
|
|
714
|
+
platform: android
|
|
715
|
+
md5: 5f4dcc3b5aa765d61d8327deb882cf99
|
|
716
|
+
teamId: 7e1c2a90-3b4d-4f5e-8a6b-1c2d3e4f5a6b
|
|
717
|
+
createdAt: '2026-10-04T09:12:44.000Z'
|
|
718
|
+
updatedAt: '2026-10-04T09:12:44.000Z'
|
|
719
|
+
'400':
|
|
720
|
+
description: No file, no `app` field, a `teamId` that isn't a string, or an unknown team.
|
|
721
|
+
content:
|
|
722
|
+
application/json:
|
|
723
|
+
schema:
|
|
724
|
+
$ref: '#/components/schemas/Error'
|
|
725
|
+
examples:
|
|
726
|
+
noFile:
|
|
727
|
+
value:
|
|
728
|
+
error: No files were uploaded.
|
|
729
|
+
noAppField:
|
|
730
|
+
value:
|
|
731
|
+
error: Field "app" is required.
|
|
732
|
+
unknownTeam:
|
|
733
|
+
value:
|
|
734
|
+
error: team not found
|
|
735
|
+
'401':
|
|
736
|
+
$ref: '#/components/responses/Unauthorized'
|
|
737
|
+
'403':
|
|
738
|
+
$ref: '#/components/responses/Forbidden'
|
|
739
|
+
'429':
|
|
740
|
+
$ref: '#/components/responses/RateLimited'
|
|
741
|
+
'500':
|
|
742
|
+
$ref: '#/components/responses/InternalError'
|
|
743
|
+
/api/apps/{id}:
|
|
744
|
+
delete:
|
|
745
|
+
operationId: deleteApp
|
|
746
|
+
summary: Delete an uploaded app
|
|
747
|
+
description: |
|
|
748
|
+
Deletes the app's file and its record. Needs the `ADMIN` role and the `devices` scope (or
|
|
749
|
+
`admin`). With the dashboard cookie the request must come from the same host.
|
|
750
|
+
tags:
|
|
751
|
+
- Applications
|
|
752
|
+
parameters:
|
|
753
|
+
- $ref: '#/components/parameters/PlatformAppId'
|
|
754
|
+
responses:
|
|
755
|
+
'204':
|
|
756
|
+
description: Deleted.
|
|
757
|
+
'401':
|
|
758
|
+
$ref: '#/components/responses/Unauthorized'
|
|
759
|
+
'403':
|
|
760
|
+
$ref: '#/components/responses/Forbidden'
|
|
761
|
+
'404':
|
|
762
|
+
$ref: '#/components/responses/PlatformAppNotFound'
|
|
763
|
+
'429':
|
|
764
|
+
$ref: '#/components/responses/RateLimited'
|
|
765
|
+
'500':
|
|
766
|
+
$ref: '#/components/responses/InternalError'
|
|
767
|
+
/api/apps/{id}/download:
|
|
768
|
+
get:
|
|
769
|
+
operationId: downloadApp
|
|
770
|
+
summary: Download an uploaded app
|
|
771
|
+
description: |
|
|
772
|
+
The app file, as an attachment under its original file name. An app outside the caller's
|
|
773
|
+
teams answers `404`, exactly like an unknown id.
|
|
774
|
+
|
|
775
|
+
Besides the usual credentials, this accepts `?ticket=`: a single-use ticket bound to this
|
|
776
|
+
one app, valid for 10 minutes, which Xenon puts in the app URL it hands an Appium driver
|
|
777
|
+
(the driver downloads the app with no credentials). A ticket that is invalid, expired,
|
|
778
|
+
already used or for another app answers `401 invalid ticket`. Tickets can't be minted
|
|
779
|
+
through the API.
|
|
780
|
+
tags:
|
|
781
|
+
- Applications
|
|
782
|
+
parameters:
|
|
783
|
+
- $ref: '#/components/parameters/PlatformAppId'
|
|
784
|
+
- in: query
|
|
785
|
+
name: ticket
|
|
786
|
+
required: false
|
|
787
|
+
description: A single-use app download ticket, in place of a credential.
|
|
788
|
+
schema:
|
|
789
|
+
type: string
|
|
790
|
+
example: eyJhbGciOiJSUzI1NiIsImtpZCI6Inhlbm9uLTEifQ.eyJhdWQiOiJ4ZW5vbi1hcHAtZG93bmxvYWQifQ.c2ln
|
|
791
|
+
responses:
|
|
792
|
+
'200':
|
|
793
|
+
description: The app file.
|
|
794
|
+
headers:
|
|
795
|
+
Content-Disposition:
|
|
796
|
+
description: '`attachment; filename="<original name>"`.'
|
|
797
|
+
schema:
|
|
798
|
+
type: string
|
|
799
|
+
example: attachment; filename="checkout-2.4.1.apk"
|
|
800
|
+
content:
|
|
801
|
+
application/octet-stream:
|
|
802
|
+
schema:
|
|
803
|
+
type: string
|
|
804
|
+
format: binary
|
|
805
|
+
'401':
|
|
806
|
+
description: No valid credential, or an invalid ticket.
|
|
807
|
+
content:
|
|
808
|
+
application/json:
|
|
809
|
+
schema:
|
|
810
|
+
$ref: '#/components/schemas/Error'
|
|
811
|
+
examples:
|
|
812
|
+
unauthenticated:
|
|
813
|
+
value:
|
|
814
|
+
error: unauthenticated
|
|
815
|
+
invalidTicket:
|
|
816
|
+
value:
|
|
817
|
+
error: invalid ticket
|
|
818
|
+
'404':
|
|
819
|
+
$ref: '#/components/responses/PlatformAppNotFound'
|
|
820
|
+
'429':
|
|
821
|
+
$ref: '#/components/responses/RateLimited'
|
|
822
|
+
'500':
|
|
823
|
+
$ref: '#/components/responses/InternalError'
|
|
824
|
+
/api/apps/{id}/team:
|
|
825
|
+
put:
|
|
826
|
+
operationId: setAppTeam
|
|
827
|
+
summary: Move an app to a team or the shared pool
|
|
828
|
+
description: |
|
|
829
|
+
Sets which team's members may see the app. `teamId: null` (or an empty string, or no
|
|
830
|
+
`teamId`) moves it to the shared pool, which everyone sees. Needs the `ADMIN` role and the
|
|
831
|
+
`admin` scope. With the dashboard cookie the request must come from the same host.
|
|
832
|
+
tags:
|
|
833
|
+
- Applications
|
|
834
|
+
parameters:
|
|
835
|
+
- $ref: '#/components/parameters/PlatformAppId'
|
|
836
|
+
requestBody:
|
|
837
|
+
required: true
|
|
838
|
+
content:
|
|
839
|
+
application/json:
|
|
840
|
+
schema:
|
|
841
|
+
type: object
|
|
842
|
+
properties:
|
|
843
|
+
teamId:
|
|
844
|
+
type: string
|
|
845
|
+
nullable: true
|
|
846
|
+
description: The team's id, or null for the shared pool.
|
|
847
|
+
example: 7e1c2a90-3b4d-4f5e-8a6b-1c2d3e4f5a6b
|
|
848
|
+
example:
|
|
849
|
+
teamId: 7e1c2a90-3b4d-4f5e-8a6b-1c2d3e4f5a6b
|
|
850
|
+
responses:
|
|
851
|
+
'200':
|
|
852
|
+
description: Moved.
|
|
853
|
+
content:
|
|
854
|
+
application/json:
|
|
855
|
+
schema:
|
|
856
|
+
type: object
|
|
857
|
+
required:
|
|
858
|
+
- ok
|
|
859
|
+
- updated
|
|
860
|
+
properties:
|
|
861
|
+
ok:
|
|
862
|
+
type: boolean
|
|
863
|
+
example: true
|
|
864
|
+
updated:
|
|
865
|
+
type: integer
|
|
866
|
+
description: Apps changed (always 1 here).
|
|
867
|
+
example: 1
|
|
868
|
+
example:
|
|
869
|
+
ok: true
|
|
870
|
+
updated: 1
|
|
871
|
+
'400':
|
|
872
|
+
description: '`teamId` is neither a string nor null.'
|
|
873
|
+
content:
|
|
874
|
+
application/json:
|
|
875
|
+
schema:
|
|
876
|
+
$ref: '#/components/schemas/Error'
|
|
877
|
+
example:
|
|
878
|
+
error: teamId must be a string or null
|
|
879
|
+
'401':
|
|
880
|
+
$ref: '#/components/responses/Unauthorized'
|
|
881
|
+
'403':
|
|
882
|
+
$ref: '#/components/responses/Forbidden'
|
|
883
|
+
'404':
|
|
884
|
+
description: No such app, or no such team.
|
|
885
|
+
content:
|
|
886
|
+
application/json:
|
|
887
|
+
schema:
|
|
888
|
+
$ref: '#/components/schemas/Error'
|
|
889
|
+
examples:
|
|
890
|
+
unknownApp:
|
|
891
|
+
value:
|
|
892
|
+
error: App not found
|
|
893
|
+
unknownTeam:
|
|
894
|
+
value:
|
|
895
|
+
error: team not found
|
|
896
|
+
'429':
|
|
897
|
+
$ref: '#/components/responses/RateLimited'
|
|
898
|
+
'500':
|
|
899
|
+
$ref: '#/components/responses/InternalError'
|
|
900
|
+
# ------------------------------------------------------------------- Recordings
|
|
901
|
+
/api/recordings:
|
|
902
|
+
get:
|
|
903
|
+
operationId: listRecordings
|
|
904
|
+
summary: List recordings, newest first
|
|
905
|
+
description: |
|
|
906
|
+
The recordings library: one summary per recording (a group of one or more phones
|
|
907
|
+
recorded together), newest first, filtered and paged, with facets for the filter menus.
|
|
908
|
+
|
|
909
|
+
Visibility: admins see every recording. Others see the phones they can see (their teams'
|
|
910
|
+
and the shared pool), so a recording that mixes teams shows only those phones, and a
|
|
911
|
+
recording with none of them is left out. The person who started a recording also keeps
|
|
912
|
+
seeing their own phones that have since been unplugged.
|
|
913
|
+
|
|
914
|
+
`facets` count every recording the caller can see, before the filters. `total` counts
|
|
915
|
+
those that match the filters. Pass `nextCursor` back as `cursor` for the next page.
|
|
916
|
+
Any signed-in user may call it.
|
|
917
|
+
tags:
|
|
918
|
+
- Recordings
|
|
919
|
+
parameters:
|
|
920
|
+
- in: query
|
|
921
|
+
name: limit
|
|
922
|
+
required: false
|
|
923
|
+
description: Recordings per page, at most 200.
|
|
924
|
+
schema:
|
|
925
|
+
type: integer
|
|
926
|
+
minimum: 1
|
|
927
|
+
maximum: 200
|
|
928
|
+
default: 50
|
|
929
|
+
- in: query
|
|
930
|
+
name: cursor
|
|
931
|
+
required: false
|
|
932
|
+
description: '`nextCursor` from the previous page.'
|
|
933
|
+
schema:
|
|
934
|
+
type: string
|
|
935
|
+
example: 1759568400000_c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
|
|
936
|
+
- in: query
|
|
937
|
+
name: udid
|
|
938
|
+
required: false
|
|
939
|
+
description: Only recordings that include this phone.
|
|
940
|
+
schema:
|
|
941
|
+
type: string
|
|
942
|
+
example: R5CT32ABCDE
|
|
943
|
+
- in: query
|
|
944
|
+
name: startedBy
|
|
945
|
+
required: false
|
|
946
|
+
description: Only recordings started by this user id, or `unknown` for those with no recorded starter.
|
|
947
|
+
schema:
|
|
948
|
+
type: string
|
|
949
|
+
example: 3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9
|
|
950
|
+
- in: query
|
|
951
|
+
name: since
|
|
952
|
+
required: false
|
|
953
|
+
description: Only recordings that started at or after this time (ISO 8601).
|
|
954
|
+
schema:
|
|
955
|
+
type: string
|
|
956
|
+
format: date-time
|
|
957
|
+
example: '2026-09-27T00:00:00Z'
|
|
958
|
+
- in: query
|
|
959
|
+
name: q
|
|
960
|
+
required: false
|
|
961
|
+
description: Case-insensitive text matched against phone names, udids and bookmark labels.
|
|
962
|
+
schema:
|
|
963
|
+
type: string
|
|
964
|
+
example: login
|
|
965
|
+
responses:
|
|
966
|
+
'200':
|
|
967
|
+
description: A page of recordings.
|
|
968
|
+
content:
|
|
969
|
+
application/json:
|
|
970
|
+
schema:
|
|
971
|
+
$ref: '#/components/schemas/RecordingLibraryPage'
|
|
972
|
+
example:
|
|
973
|
+
recordings:
|
|
974
|
+
- groupId: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
|
|
975
|
+
startedAt: '2026-10-04T08:20:00.000Z'
|
|
976
|
+
endedAt: '2026-10-04T08:23:41.000Z'
|
|
977
|
+
durationMs: 221400
|
|
978
|
+
status: done
|
|
979
|
+
phones:
|
|
980
|
+
- recordingId: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
981
|
+
udid: R5CT32ABCDE
|
|
982
|
+
name: Galaxy S23
|
|
983
|
+
platform: android
|
|
984
|
+
status: STOPPED
|
|
985
|
+
offsetMs: -320
|
|
986
|
+
durationMs: 221080
|
|
987
|
+
failReason: null
|
|
988
|
+
annotationCount: 2
|
|
989
|
+
startedBy:
|
|
990
|
+
id: 3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9
|
|
991
|
+
name: Priya Raman
|
|
992
|
+
bookmarkCount: 1
|
|
993
|
+
annotationCount: 2
|
|
994
|
+
keptUntil: '2026-11-03T08:20:00.000Z'
|
|
995
|
+
sizeBytes: 18342011
|
|
996
|
+
hasComposite: false
|
|
997
|
+
nextCursor: null
|
|
998
|
+
total: 1
|
|
999
|
+
facets:
|
|
1000
|
+
phones:
|
|
1001
|
+
- udid: R5CT32ABCDE
|
|
1002
|
+
name: Galaxy S23
|
|
1003
|
+
count: 1
|
|
1004
|
+
people:
|
|
1005
|
+
- id: 3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9
|
|
1006
|
+
name: Priya Raman
|
|
1007
|
+
count: 1
|
|
1008
|
+
unknownCount: 0
|
|
1009
|
+
when:
|
|
1010
|
+
any: 1
|
|
1011
|
+
24h: 1
|
|
1012
|
+
7d: 1
|
|
1013
|
+
30d: 1
|
|
1014
|
+
retention:
|
|
1015
|
+
days: 30
|
|
1016
|
+
maxCount: 100
|
|
1017
|
+
'400':
|
|
1018
|
+
description: A query parameter is invalid.
|
|
1019
|
+
content:
|
|
1020
|
+
application/json:
|
|
1021
|
+
schema:
|
|
1022
|
+
$ref: '#/components/schemas/Error'
|
|
1023
|
+
examples:
|
|
1024
|
+
limit:
|
|
1025
|
+
value:
|
|
1026
|
+
error: limit must be a whole number from 1
|
|
1027
|
+
since:
|
|
1028
|
+
value:
|
|
1029
|
+
error: since must be an ISO time
|
|
1030
|
+
'401':
|
|
1031
|
+
$ref: '#/components/responses/Unauthorized'
|
|
1032
|
+
'403':
|
|
1033
|
+
$ref: '#/components/responses/Forbidden'
|
|
1034
|
+
'429':
|
|
1035
|
+
$ref: '#/components/responses/RateLimited'
|
|
1036
|
+
'500':
|
|
1037
|
+
$ref: '#/components/responses/RecordingInternalError'
|
|
1038
|
+
post:
|
|
1039
|
+
operationId: startRecording
|
|
1040
|
+
summary: Start recording one or more phones
|
|
1041
|
+
description: |
|
|
1042
|
+
Starts one recording group: one video per phone and, for two or more phones, a
|
|
1043
|
+
side-by-side composite video. The phone's live stream is started if it isn't running.
|
|
1044
|
+
Each phone is held for the caller (as a live preview is) until the recording ends.
|
|
1045
|
+
|
|
1046
|
+
All or nothing up front: if any phone is busy, or the server's cap on simultaneous
|
|
1047
|
+
recordings would be exceeded, nothing starts (`409`). A phone is busy when it is already
|
|
1048
|
+
being recorded, held by another user, or running an Appium session. Your own preview hold
|
|
1049
|
+
doesn't count. After that, a phone whose capture fails to start is left out of
|
|
1050
|
+
`recordings` and the rest record on.
|
|
1051
|
+
|
|
1052
|
+
Members may record only phones they can see: if any is outside their teams, or unknown,
|
|
1053
|
+
the answer is `404` and nothing starts. Any signed-in user whose credential has the `devices` scope (or `admin`) may call it.
|
|
1054
|
+
With the dashboard cookie the request must come from the same host.
|
|
1055
|
+
tags:
|
|
1056
|
+
- Recordings
|
|
1057
|
+
requestBody:
|
|
1058
|
+
required: true
|
|
1059
|
+
content:
|
|
1060
|
+
application/json:
|
|
1061
|
+
schema:
|
|
1062
|
+
type: object
|
|
1063
|
+
required:
|
|
1064
|
+
- udids
|
|
1065
|
+
properties:
|
|
1066
|
+
udids:
|
|
1067
|
+
type: array
|
|
1068
|
+
minItems: 1
|
|
1069
|
+
items:
|
|
1070
|
+
type: string
|
|
1071
|
+
description: The phones to record.
|
|
1072
|
+
example:
|
|
1073
|
+
- R5CT32ABCDE
|
|
1074
|
+
- 00008110-00084CE80E51401E
|
|
1075
|
+
sessionId:
|
|
1076
|
+
type: string
|
|
1077
|
+
description: An Appium session to link the recordings to. It must be an existing session's id.
|
|
1078
|
+
example: 5b2e9c1a-0f3d-4e6a-8b7c-2d1e0f9a8b7c
|
|
1079
|
+
note:
|
|
1080
|
+
type: string
|
|
1081
|
+
description: Accepted, but not stored.
|
|
1082
|
+
example:
|
|
1083
|
+
udids:
|
|
1084
|
+
- R5CT32ABCDE
|
|
1085
|
+
- 00008110-00084CE80E51401E
|
|
1086
|
+
responses:
|
|
1087
|
+
'202':
|
|
1088
|
+
description: Recording started.
|
|
1089
|
+
content:
|
|
1090
|
+
application/json:
|
|
1091
|
+
schema:
|
|
1092
|
+
type: object
|
|
1093
|
+
required:
|
|
1094
|
+
- groupId
|
|
1095
|
+
- recordings
|
|
1096
|
+
- startedAt
|
|
1097
|
+
- compositeEnabled
|
|
1098
|
+
properties:
|
|
1099
|
+
groupId:
|
|
1100
|
+
type: string
|
|
1101
|
+
example: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
|
|
1102
|
+
recordings:
|
|
1103
|
+
type: array
|
|
1104
|
+
description: The phones now recording. A phone whose capture failed to start is not listed.
|
|
1105
|
+
items:
|
|
1106
|
+
$ref: '#/components/schemas/RecordingStarted'
|
|
1107
|
+
startedAt:
|
|
1108
|
+
type: string
|
|
1109
|
+
format: date-time
|
|
1110
|
+
compositeEnabled:
|
|
1111
|
+
type: boolean
|
|
1112
|
+
description: Whether a side-by-side composite is being recorded.
|
|
1113
|
+
example:
|
|
1114
|
+
groupId: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
|
|
1115
|
+
recordings:
|
|
1116
|
+
- id: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
1117
|
+
udid: R5CT32ABCDE
|
|
1118
|
+
status: RECORDING
|
|
1119
|
+
- id: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
|
|
1120
|
+
udid: 00008110-00084CE80E51401E
|
|
1121
|
+
status: RECORDING
|
|
1122
|
+
startedAt: '2026-10-04T08:20:00.000Z'
|
|
1123
|
+
compositeEnabled: true
|
|
1124
|
+
'400':
|
|
1125
|
+
description: '`udids` is missing, empty, or holds something other than non-empty strings.'
|
|
1126
|
+
content:
|
|
1127
|
+
application/json:
|
|
1128
|
+
schema:
|
|
1129
|
+
$ref: '#/components/schemas/Error'
|
|
1130
|
+
example:
|
|
1131
|
+
error: udids must be a non-empty array
|
|
1132
|
+
'401':
|
|
1133
|
+
$ref: '#/components/responses/Unauthorized'
|
|
1134
|
+
'403':
|
|
1135
|
+
$ref: '#/components/responses/Forbidden'
|
|
1136
|
+
'404':
|
|
1137
|
+
$ref: '#/components/responses/RecordingNotFound'
|
|
1138
|
+
'409':
|
|
1139
|
+
$ref: '#/components/responses/RecordingStartConflict'
|
|
1140
|
+
'429':
|
|
1141
|
+
$ref: '#/components/responses/RateLimited'
|
|
1142
|
+
'500':
|
|
1143
|
+
$ref: '#/components/responses/RecordingInternalError'
|
|
1144
|
+
/api/recordings/active:
|
|
1145
|
+
get:
|
|
1146
|
+
operationId: listMyActiveRecordings
|
|
1147
|
+
summary: List your recordings that are still running
|
|
1148
|
+
description: |
|
|
1149
|
+
The recording groups still running that the caller holds a phone of, so the Live
|
|
1150
|
+
devices page can pick them back up after a reload. A group is the caller's when they hold
|
|
1151
|
+
the preview hold on any of its phones; admins get only their own too. Phones the caller
|
|
1152
|
+
can't see are left out, with their marks. Only marks still on screen (not yet cleared)
|
|
1153
|
+
are returned.
|
|
1154
|
+
|
|
1155
|
+
`startedAt` is the group's t=0, which mark timecodes count from. `serverNow` lets the
|
|
1156
|
+
client correct for clock skew. Any signed-in user may call it.
|
|
1157
|
+
tags:
|
|
1158
|
+
- Recordings
|
|
1159
|
+
responses:
|
|
1160
|
+
'200':
|
|
1161
|
+
description: The caller's running recordings.
|
|
1162
|
+
content:
|
|
1163
|
+
application/json:
|
|
1164
|
+
schema:
|
|
1165
|
+
type: object
|
|
1166
|
+
required:
|
|
1167
|
+
- serverNow
|
|
1168
|
+
- groups
|
|
1169
|
+
properties:
|
|
1170
|
+
serverNow:
|
|
1171
|
+
type: integer
|
|
1172
|
+
description: The server's clock, epoch milliseconds.
|
|
1173
|
+
example: 1759566120000
|
|
1174
|
+
groups:
|
|
1175
|
+
type: array
|
|
1176
|
+
items:
|
|
1177
|
+
$ref: '#/components/schemas/RecordingActiveGroup'
|
|
1178
|
+
example:
|
|
1179
|
+
serverNow: 1759566120000
|
|
1180
|
+
groups:
|
|
1181
|
+
- groupId: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
|
|
1182
|
+
startedAt: '2026-10-04T08:20:00.000Z'
|
|
1183
|
+
recordings:
|
|
1184
|
+
- id: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
1185
|
+
udid: R5CT32ABCDE
|
|
1186
|
+
annotations:
|
|
1187
|
+
- recordingId: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
1188
|
+
shape: RECT
|
|
1189
|
+
geometry: '{"x":0.12,"y":0.4,"w":0.3,"h":0.08}'
|
|
1190
|
+
color: red
|
|
1191
|
+
text: null
|
|
1192
|
+
timecodeMs: 41250
|
|
1193
|
+
compositeEnabled: false
|
|
1194
|
+
'401':
|
|
1195
|
+
$ref: '#/components/responses/Unauthorized'
|
|
1196
|
+
'403':
|
|
1197
|
+
$ref: '#/components/responses/Forbidden'
|
|
1198
|
+
'429':
|
|
1199
|
+
$ref: '#/components/responses/RateLimited'
|
|
1200
|
+
'500':
|
|
1201
|
+
$ref: '#/components/responses/RecordingInternalError'
|
|
1202
|
+
/api/recordings/{groupId}:
|
|
1203
|
+
get:
|
|
1204
|
+
operationId: getRecording
|
|
1205
|
+
summary: Get one recording with its bookmarks and marks
|
|
1206
|
+
description: |
|
|
1207
|
+
One recording group: its stored rows (one per phone), its summary, and its bookmarks and
|
|
1208
|
+
marks, each sorted by timecode. Only the phones the caller can see are included; with
|
|
1209
|
+
none, the answer is `404`. Any signed-in user may call it.
|
|
1210
|
+
tags:
|
|
1211
|
+
- Recordings
|
|
1212
|
+
parameters:
|
|
1213
|
+
- $ref: '#/components/parameters/RecordingGroupId'
|
|
1214
|
+
responses:
|
|
1215
|
+
'200':
|
|
1216
|
+
description: The recording.
|
|
1217
|
+
content:
|
|
1218
|
+
application/json:
|
|
1219
|
+
schema:
|
|
1220
|
+
type: object
|
|
1221
|
+
required:
|
|
1222
|
+
- groupId
|
|
1223
|
+
- recordings
|
|
1224
|
+
- summary
|
|
1225
|
+
- bookmarks
|
|
1226
|
+
- annotations
|
|
1227
|
+
properties:
|
|
1228
|
+
groupId:
|
|
1229
|
+
type: string
|
|
1230
|
+
example: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
|
|
1231
|
+
recordings:
|
|
1232
|
+
type: array
|
|
1233
|
+
description: The stored rows of the phones the caller can see, with their bookmarks and marks as stored.
|
|
1234
|
+
items:
|
|
1235
|
+
$ref: '#/components/schemas/RecordingRow'
|
|
1236
|
+
summary:
|
|
1237
|
+
$ref: '#/components/schemas/RecordingSummary'
|
|
1238
|
+
bookmarks:
|
|
1239
|
+
type: array
|
|
1240
|
+
items:
|
|
1241
|
+
$ref: '#/components/schemas/RecordingBookmarkView'
|
|
1242
|
+
annotations:
|
|
1243
|
+
type: array
|
|
1244
|
+
items:
|
|
1245
|
+
$ref: '#/components/schemas/RecordingAnnotationView'
|
|
1246
|
+
example:
|
|
1247
|
+
groupId: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
|
|
1248
|
+
recordings:
|
|
1249
|
+
- id: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
1250
|
+
group_id: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
|
|
1251
|
+
device_udid: R5CT32ABCDE
|
|
1252
|
+
device_host: 127.0.0.1
|
|
1253
|
+
session_id: null
|
|
1254
|
+
started_at: '2026-10-04T08:20:00.000Z'
|
|
1255
|
+
ended_at: '2026-10-04T08:23:41.000Z'
|
|
1256
|
+
status: STOPPED
|
|
1257
|
+
file_path: /home/lab/.cache/xenon/assets/8f14e45f-ceea-467a-9b36-2f1c5d0e7a11/video/8f14e45f-ceea-467a-9b36-2f1c5d0e7a11.mp4
|
|
1258
|
+
duration_ms: 221080
|
|
1259
|
+
size_bytes: 18342011
|
|
1260
|
+
device_snapshot: null
|
|
1261
|
+
fail_reason: null
|
|
1262
|
+
started_by: 3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9
|
|
1263
|
+
bookmarks:
|
|
1264
|
+
- id: 4c5d6e7f-8a9b-4c0d-9e1f-2a3b4c5d6e7f
|
|
1265
|
+
recording_id: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
1266
|
+
timecode_ms: 38000
|
|
1267
|
+
label: Login failed
|
|
1268
|
+
note: Spinner never stops
|
|
1269
|
+
created_at: '2026-10-04T08:20:38.000Z'
|
|
1270
|
+
annotations: []
|
|
1271
|
+
summary:
|
|
1272
|
+
groupId: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
|
|
1273
|
+
startedAt: '2026-10-04T08:20:00.000Z'
|
|
1274
|
+
endedAt: '2026-10-04T08:23:41.000Z'
|
|
1275
|
+
durationMs: 221400
|
|
1276
|
+
status: done
|
|
1277
|
+
phones:
|
|
1278
|
+
- recordingId: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
1279
|
+
udid: R5CT32ABCDE
|
|
1280
|
+
name: Galaxy S23
|
|
1281
|
+
platform: android
|
|
1282
|
+
status: STOPPED
|
|
1283
|
+
offsetMs: -320
|
|
1284
|
+
durationMs: 221080
|
|
1285
|
+
failReason: null
|
|
1286
|
+
annotationCount: 0
|
|
1287
|
+
startedBy:
|
|
1288
|
+
id: 3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9
|
|
1289
|
+
name: Priya Raman
|
|
1290
|
+
bookmarkCount: 1
|
|
1291
|
+
annotationCount: 0
|
|
1292
|
+
keptUntil: '2026-11-03T08:20:00.000Z'
|
|
1293
|
+
sizeBytes: 18342011
|
|
1294
|
+
hasComposite: false
|
|
1295
|
+
bookmarks:
|
|
1296
|
+
- id: 4c5d6e7f-8a9b-4c0d-9e1f-2a3b4c5d6e7f
|
|
1297
|
+
recordingId: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
1298
|
+
timecodeMs: 38000
|
|
1299
|
+
label: Login failed
|
|
1300
|
+
note: Spinner never stops
|
|
1301
|
+
annotations: []
|
|
1302
|
+
'401':
|
|
1303
|
+
$ref: '#/components/responses/Unauthorized'
|
|
1304
|
+
'403':
|
|
1305
|
+
$ref: '#/components/responses/Forbidden'
|
|
1306
|
+
'404':
|
|
1307
|
+
$ref: '#/components/responses/RecordingNotFound'
|
|
1308
|
+
'429':
|
|
1309
|
+
$ref: '#/components/responses/RateLimited'
|
|
1310
|
+
'500':
|
|
1311
|
+
$ref: '#/components/responses/RecordingInternalError'
|
|
1312
|
+
delete:
|
|
1313
|
+
operationId: deleteRecording
|
|
1314
|
+
summary: Delete a recording
|
|
1315
|
+
description: |
|
|
1316
|
+
Deletes the recording's videos, bookmarks and marks, for the phones the caller can see.
|
|
1317
|
+
A phone on another team stays, with the composite video, until no phone of the recording
|
|
1318
|
+
is left; an admin sees every phone, so deletes the whole recording. Irreversible.
|
|
1319
|
+
|
|
1320
|
+
Only the person who started the recording, or an admin, may delete it (`403 not_owner`).
|
|
1321
|
+
A recording any of whose phones is still recording can't be deleted (`409`). An API key
|
|
1322
|
+
needs the `devices` scope (or `admin`). With the dashboard cookie the request must come
|
|
1323
|
+
from the same host. The checks run in the order `404`, `409`, `403`.
|
|
1324
|
+
tags:
|
|
1325
|
+
- Recordings
|
|
1326
|
+
parameters:
|
|
1327
|
+
- $ref: '#/components/parameters/RecordingGroupId'
|
|
1328
|
+
responses:
|
|
1329
|
+
'204':
|
|
1330
|
+
description: Deleted.
|
|
1331
|
+
'401':
|
|
1332
|
+
$ref: '#/components/responses/Unauthorized'
|
|
1333
|
+
'403':
|
|
1334
|
+
description: |
|
|
1335
|
+
Not the person who started it, or the credential lacks the `devices` scope, or a
|
|
1336
|
+
browser request failed the same-origin check.
|
|
1337
|
+
content:
|
|
1338
|
+
application/json:
|
|
1339
|
+
schema:
|
|
1340
|
+
$ref: '#/components/schemas/Error'
|
|
1341
|
+
examples:
|
|
1342
|
+
notOwner:
|
|
1343
|
+
value:
|
|
1344
|
+
error: not_owner
|
|
1345
|
+
message: Only the person who recorded it, or an admin, can delete it.
|
|
1346
|
+
scope:
|
|
1347
|
+
value:
|
|
1348
|
+
error: insufficient scope
|
|
1349
|
+
'404':
|
|
1350
|
+
$ref: '#/components/responses/RecordingNotFound'
|
|
1351
|
+
'409':
|
|
1352
|
+
description: A phone of this recording is still recording.
|
|
1353
|
+
content:
|
|
1354
|
+
application/json:
|
|
1355
|
+
schema:
|
|
1356
|
+
$ref: '#/components/schemas/Error'
|
|
1357
|
+
example:
|
|
1358
|
+
error: recording_in_progress
|
|
1359
|
+
message: Stop the recording on Live devices before deleting it.
|
|
1360
|
+
'429':
|
|
1361
|
+
$ref: '#/components/responses/RateLimited'
|
|
1362
|
+
'500':
|
|
1363
|
+
$ref: '#/components/responses/RecordingInternalError'
|
|
1364
|
+
/api/recordings/{groupId}/add-device:
|
|
1365
|
+
post:
|
|
1366
|
+
operationId: addPhoneToRecording
|
|
1367
|
+
summary: Add a phone to a running recording
|
|
1368
|
+
description: |
|
|
1369
|
+
Starts recording one more phone in an existing group. Its marks are placed on the group's
|
|
1370
|
+
timeline by how late it joined. It doesn't join the side-by-side composite, which keeps
|
|
1371
|
+
the phones it started with.
|
|
1372
|
+
|
|
1373
|
+
Like starting a recording, it is refused with `409` if the phone is busy or the server's
|
|
1374
|
+
recording cap is reached, and nothing starts. A member gets `404` for a group they can't
|
|
1375
|
+
see any phone of, or a phone outside their teams. Any signed-in user whose credential has the `devices` scope (or `admin`) may call it.
|
|
1376
|
+
With the dashboard cookie the request must come from the same host.
|
|
1377
|
+
tags:
|
|
1378
|
+
- Recordings
|
|
1379
|
+
parameters:
|
|
1380
|
+
- $ref: '#/components/parameters/RecordingGroupId'
|
|
1381
|
+
requestBody:
|
|
1382
|
+
required: true
|
|
1383
|
+
content:
|
|
1384
|
+
application/json:
|
|
1385
|
+
schema:
|
|
1386
|
+
type: object
|
|
1387
|
+
required:
|
|
1388
|
+
- udid
|
|
1389
|
+
properties:
|
|
1390
|
+
udid:
|
|
1391
|
+
type: string
|
|
1392
|
+
description: The phone to add.
|
|
1393
|
+
example: R5CT32ABCDE
|
|
1394
|
+
example:
|
|
1395
|
+
udid: R5CT32ABCDE
|
|
1396
|
+
responses:
|
|
1397
|
+
'201':
|
|
1398
|
+
description: The phone is recording.
|
|
1399
|
+
content:
|
|
1400
|
+
application/json:
|
|
1401
|
+
schema:
|
|
1402
|
+
type: object
|
|
1403
|
+
required:
|
|
1404
|
+
- recording
|
|
1405
|
+
properties:
|
|
1406
|
+
recording:
|
|
1407
|
+
$ref: '#/components/schemas/RecordingStarted'
|
|
1408
|
+
example:
|
|
1409
|
+
recording:
|
|
1410
|
+
id: 6e7f8a9b-0c1d-4e2f-8a3b-4c5d6e7f8a9b
|
|
1411
|
+
udid: R5CT32ABCDE
|
|
1412
|
+
status: RECORDING
|
|
1413
|
+
'400':
|
|
1414
|
+
description: '`udid` is missing or empty.'
|
|
1415
|
+
content:
|
|
1416
|
+
application/json:
|
|
1417
|
+
schema:
|
|
1418
|
+
$ref: '#/components/schemas/Error'
|
|
1419
|
+
example:
|
|
1420
|
+
error: udid must be a non-empty string
|
|
1421
|
+
'401':
|
|
1422
|
+
$ref: '#/components/responses/Unauthorized'
|
|
1423
|
+
'403':
|
|
1424
|
+
$ref: '#/components/responses/Forbidden'
|
|
1425
|
+
'404':
|
|
1426
|
+
$ref: '#/components/responses/RecordingNotFound'
|
|
1427
|
+
'409':
|
|
1428
|
+
$ref: '#/components/responses/RecordingStartConflict'
|
|
1429
|
+
'429':
|
|
1430
|
+
$ref: '#/components/responses/RateLimited'
|
|
1431
|
+
'500':
|
|
1432
|
+
$ref: '#/components/responses/RecordingInternalError'
|
|
1433
|
+
/api/recordings/{groupId}/stop:
|
|
1434
|
+
post:
|
|
1435
|
+
operationId: stopRecording
|
|
1436
|
+
summary: Stop a recording
|
|
1437
|
+
description: |
|
|
1438
|
+
Stops every phone of the group, the side-by-side composite first, and finishes each video
|
|
1439
|
+
separately: a phone whose video fails doesn't stop the others. Each phone is then let go
|
|
1440
|
+
(its preview hold released once nobody watches it).
|
|
1441
|
+
|
|
1442
|
+
Stopping is group-wide even for a caller who sees only some of its phones; the answer
|
|
1443
|
+
lists only the phones they can see. Each video's `status` is `STOPPED`, or `FAILED` when
|
|
1444
|
+
its file is missing or too small to play. `durationMs` is the video's own length.
|
|
1445
|
+
|
|
1446
|
+
A phone whose recording had already ended (its stream ended earlier, or the group was
|
|
1447
|
+
already stopped) is left as it is: its end, length and failure reason are kept, and the
|
|
1448
|
+
phone isn't let go a second time. It is reported with its stored `status`, `durationMs`
|
|
1449
|
+
and `sizeBytes`, so stopping a group again changes none of its videos. A phone whose
|
|
1450
|
+
stream is ending at that very moment is reported as it stands, and may still say
|
|
1451
|
+
`RECORDING`.
|
|
1452
|
+
|
|
1453
|
+
A member gets `404` for a group they can't see any phone of. Any signed-in user whose credential has the `devices` scope (or `admin`) may call it.
|
|
1454
|
+
With the dashboard cookie the request must come from the same host.
|
|
1455
|
+
tags:
|
|
1456
|
+
- Recordings
|
|
1457
|
+
parameters:
|
|
1458
|
+
- $ref: '#/components/parameters/RecordingGroupId'
|
|
1459
|
+
responses:
|
|
1460
|
+
'200':
|
|
1461
|
+
description: Stopped.
|
|
1462
|
+
content:
|
|
1463
|
+
application/json:
|
|
1464
|
+
schema:
|
|
1465
|
+
type: object
|
|
1466
|
+
required:
|
|
1467
|
+
- groupId
|
|
1468
|
+
- recordings
|
|
1469
|
+
properties:
|
|
1470
|
+
groupId:
|
|
1471
|
+
type: string
|
|
1472
|
+
example: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
|
|
1473
|
+
recordings:
|
|
1474
|
+
type: array
|
|
1475
|
+
items:
|
|
1476
|
+
$ref: '#/components/schemas/RecordingStopped'
|
|
1477
|
+
example:
|
|
1478
|
+
groupId: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
|
|
1479
|
+
recordings:
|
|
1480
|
+
- id: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
1481
|
+
udid: R5CT32ABCDE
|
|
1482
|
+
status: STOPPED
|
|
1483
|
+
durationMs: 221080
|
|
1484
|
+
sizeBytes: 18342011
|
|
1485
|
+
'401':
|
|
1486
|
+
$ref: '#/components/responses/Unauthorized'
|
|
1487
|
+
'403':
|
|
1488
|
+
$ref: '#/components/responses/Forbidden'
|
|
1489
|
+
'404':
|
|
1490
|
+
$ref: '#/components/responses/RecordingNotFound'
|
|
1491
|
+
'429':
|
|
1492
|
+
$ref: '#/components/responses/RateLimited'
|
|
1493
|
+
'500':
|
|
1494
|
+
$ref: '#/components/responses/RecordingInternalError'
|
|
1495
|
+
/api/recordings/{groupId}/bookmark:
|
|
1496
|
+
post:
|
|
1497
|
+
operationId: addRecordingBookmark
|
|
1498
|
+
summary: Add a bookmark to a phone's recording
|
|
1499
|
+
description: |
|
|
1500
|
+
Adds a labelled bookmark at a point on the group's timeline, on one phone's recording.
|
|
1501
|
+
The recording must belong to this group and be on a phone the caller can see, otherwise
|
|
1502
|
+
`404`. Dashboards watching the recording are told at once. Any signed-in user whose credential has the `devices` scope (or `admin`) may call it.
|
|
1503
|
+
With the dashboard cookie the request must come from the same host.
|
|
1504
|
+
tags:
|
|
1505
|
+
- Recordings
|
|
1506
|
+
parameters:
|
|
1507
|
+
- $ref: '#/components/parameters/RecordingGroupId'
|
|
1508
|
+
requestBody:
|
|
1509
|
+
required: true
|
|
1510
|
+
content:
|
|
1511
|
+
application/json:
|
|
1512
|
+
schema:
|
|
1513
|
+
type: object
|
|
1514
|
+
required:
|
|
1515
|
+
- recordingId
|
|
1516
|
+
- timecodeMs
|
|
1517
|
+
- label
|
|
1518
|
+
properties:
|
|
1519
|
+
recordingId:
|
|
1520
|
+
type: string
|
|
1521
|
+
description: The phone's recording (`recordings[].id`).
|
|
1522
|
+
example: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
1523
|
+
timecodeMs:
|
|
1524
|
+
type: integer
|
|
1525
|
+
description: Milliseconds from the group's t=0.
|
|
1526
|
+
example: 38000
|
|
1527
|
+
label:
|
|
1528
|
+
type: string
|
|
1529
|
+
example: Login failed
|
|
1530
|
+
note:
|
|
1531
|
+
type: string
|
|
1532
|
+
example: Spinner never stops
|
|
1533
|
+
responses:
|
|
1534
|
+
'201':
|
|
1535
|
+
description: The bookmark as stored.
|
|
1536
|
+
content:
|
|
1537
|
+
application/json:
|
|
1538
|
+
schema:
|
|
1539
|
+
$ref: '#/components/schemas/RecordingBookmarkRow'
|
|
1540
|
+
example:
|
|
1541
|
+
id: 4c5d6e7f-8a9b-4c0d-9e1f-2a3b4c5d6e7f
|
|
1542
|
+
recording_id: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
1543
|
+
timecode_ms: 38000
|
|
1544
|
+
label: Login failed
|
|
1545
|
+
note: Spinner never stops
|
|
1546
|
+
created_at: '2026-10-04T08:20:38.000Z'
|
|
1547
|
+
'400':
|
|
1548
|
+
description: A required field is missing or has the wrong type.
|
|
1549
|
+
content:
|
|
1550
|
+
application/json:
|
|
1551
|
+
schema:
|
|
1552
|
+
$ref: '#/components/schemas/Error'
|
|
1553
|
+
example:
|
|
1554
|
+
error: recordingId (string), timecodeMs (number) and label (string) are required
|
|
1555
|
+
'401':
|
|
1556
|
+
$ref: '#/components/responses/Unauthorized'
|
|
1557
|
+
'403':
|
|
1558
|
+
$ref: '#/components/responses/Forbidden'
|
|
1559
|
+
'404':
|
|
1560
|
+
$ref: '#/components/responses/RecordingNotFound'
|
|
1561
|
+
'429':
|
|
1562
|
+
$ref: '#/components/responses/RateLimited'
|
|
1563
|
+
'500':
|
|
1564
|
+
$ref: '#/components/responses/RecordingInternalError'
|
|
1565
|
+
/api/recordings/{groupId}/annotation:
|
|
1566
|
+
post:
|
|
1567
|
+
operationId: addRecordingAnnotation
|
|
1568
|
+
summary: Draw a mark on a phone's recording
|
|
1569
|
+
description: |
|
|
1570
|
+
Adds a mark (a box, circle, arrow, text or freehand stroke) at a point on the group's
|
|
1571
|
+
timeline, on one phone's recording. It stays on screen until the marks are cleared
|
|
1572
|
+
(`annotations/clear`), and is burned into the downloaded videos.
|
|
1573
|
+
|
|
1574
|
+
`geometry` is a JSON string with the mark's box in coordinates from 0 to 1 of the frame:
|
|
1575
|
+
`{"x":…,"y":…,"w":…,"h":…}`. `image` is optional: the mark as the browser drew it, a PNG
|
|
1576
|
+
data URL of at most 2 MiB decoded, burned in as drawn instead of the server's own drawing.
|
|
1577
|
+
|
|
1578
|
+
The recording must belong to this group and be on a phone the caller can see, otherwise
|
|
1579
|
+
`404`. Dashboards watching the recording are told at once. Any signed-in user whose credential has the `devices` scope (or `admin`) may call it.
|
|
1580
|
+
With the dashboard cookie the request must come from the same host.
|
|
1581
|
+
tags:
|
|
1582
|
+
- Recordings
|
|
1583
|
+
parameters:
|
|
1584
|
+
- $ref: '#/components/parameters/RecordingGroupId'
|
|
1585
|
+
requestBody:
|
|
1586
|
+
required: true
|
|
1587
|
+
content:
|
|
1588
|
+
application/json:
|
|
1589
|
+
schema:
|
|
1590
|
+
type: object
|
|
1591
|
+
required:
|
|
1592
|
+
- recordingId
|
|
1593
|
+
- timecodeMs
|
|
1594
|
+
- shape
|
|
1595
|
+
- geometry
|
|
1596
|
+
- color
|
|
1597
|
+
properties:
|
|
1598
|
+
recordingId:
|
|
1599
|
+
type: string
|
|
1600
|
+
example: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
1601
|
+
timecodeMs:
|
|
1602
|
+
type: integer
|
|
1603
|
+
description: Milliseconds from the group's t=0.
|
|
1604
|
+
example: 41250
|
|
1605
|
+
shape:
|
|
1606
|
+
type: string
|
|
1607
|
+
description: Any other value is drawn as a box.
|
|
1608
|
+
enum:
|
|
1609
|
+
- RECT
|
|
1610
|
+
- CIRCLE
|
|
1611
|
+
- ARROW
|
|
1612
|
+
- TEXT
|
|
1613
|
+
- FREEHAND
|
|
1614
|
+
example: RECT
|
|
1615
|
+
geometry:
|
|
1616
|
+
type: string
|
|
1617
|
+
description: JSON with `x`, `y`, `w`, `h`, each from 0 to 1 of the frame.
|
|
1618
|
+
example: '{"x":0.12,"y":0.4,"w":0.3,"h":0.08}'
|
|
1619
|
+
color:
|
|
1620
|
+
type: string
|
|
1621
|
+
example: red
|
|
1622
|
+
text:
|
|
1623
|
+
type: string
|
|
1624
|
+
description: The text of a `TEXT` mark.
|
|
1625
|
+
example: Wrong total
|
|
1626
|
+
author:
|
|
1627
|
+
type: string
|
|
1628
|
+
description: Stored with the mark.
|
|
1629
|
+
image:
|
|
1630
|
+
type: string
|
|
1631
|
+
description: The mark as drawn, `data:image/png;base64,…`, at most 2 MiB decoded.
|
|
1632
|
+
example: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
|
|
1633
|
+
responses:
|
|
1634
|
+
'201':
|
|
1635
|
+
description: The mark as stored.
|
|
1636
|
+
content:
|
|
1637
|
+
application/json:
|
|
1638
|
+
schema:
|
|
1639
|
+
$ref: '#/components/schemas/RecordingAnnotationRow'
|
|
1640
|
+
example:
|
|
1641
|
+
id: 9b8a7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d
|
|
1642
|
+
recording_id: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
1643
|
+
timecode_ms: 41250
|
|
1644
|
+
end_timecode_ms: null
|
|
1645
|
+
shape: RECT
|
|
1646
|
+
geometry: '{"x":0.12,"y":0.4,"w":0.3,"h":0.08}'
|
|
1647
|
+
color: red
|
|
1648
|
+
text: null
|
|
1649
|
+
author: null
|
|
1650
|
+
created_at: '2026-10-04T08:20:41.000Z'
|
|
1651
|
+
'400':
|
|
1652
|
+
description: A required field is missing or has the wrong type, or `image` is refused.
|
|
1653
|
+
content:
|
|
1654
|
+
application/json:
|
|
1655
|
+
schema:
|
|
1656
|
+
$ref: '#/components/schemas/Error'
|
|
1657
|
+
examples:
|
|
1658
|
+
fields:
|
|
1659
|
+
value:
|
|
1660
|
+
error: recordingId, timecodeMs, shape, geometry, color are required
|
|
1661
|
+
notPng:
|
|
1662
|
+
value:
|
|
1663
|
+
error: image is not a PNG
|
|
1664
|
+
tooLarge:
|
|
1665
|
+
value:
|
|
1666
|
+
error: image exceeds 2097152 bytes
|
|
1667
|
+
'401':
|
|
1668
|
+
$ref: '#/components/responses/Unauthorized'
|
|
1669
|
+
'403':
|
|
1670
|
+
$ref: '#/components/responses/Forbidden'
|
|
1671
|
+
'404':
|
|
1672
|
+
$ref: '#/components/responses/RecordingNotFound'
|
|
1673
|
+
'429':
|
|
1674
|
+
$ref: '#/components/responses/RateLimited'
|
|
1675
|
+
'500':
|
|
1676
|
+
$ref: '#/components/responses/RecordingInternalError'
|
|
1677
|
+
/api/recordings/{groupId}/annotations/clear:
|
|
1678
|
+
post:
|
|
1679
|
+
operationId: clearRecordingAnnotations
|
|
1680
|
+
summary: Clear the marks on screen at a point in time
|
|
1681
|
+
description: |
|
|
1682
|
+
Ends, at `timecodeMs`, every mark of the group still on screen that started at or before
|
|
1683
|
+
it. Marks are kept (they show up to that point in the video), not deleted. Marks that
|
|
1684
|
+
started later, or were already cleared, are left alone, so repeating a clear changes
|
|
1685
|
+
nothing. Only the marks on phones the caller can see are cleared; an admin's clear covers
|
|
1686
|
+
the whole group.
|
|
1687
|
+
|
|
1688
|
+
A member gets `404` for a group they can't see any phone of. Any signed-in user whose credential has the `devices` scope (or `admin`) may call it.
|
|
1689
|
+
With the dashboard cookie the request must come from the same host.
|
|
1690
|
+
tags:
|
|
1691
|
+
- Recordings
|
|
1692
|
+
parameters:
|
|
1693
|
+
- $ref: '#/components/parameters/RecordingGroupId'
|
|
1694
|
+
requestBody:
|
|
1695
|
+
required: true
|
|
1696
|
+
content:
|
|
1697
|
+
application/json:
|
|
1698
|
+
schema:
|
|
1699
|
+
type: object
|
|
1700
|
+
required:
|
|
1701
|
+
- timecodeMs
|
|
1702
|
+
properties:
|
|
1703
|
+
timecodeMs:
|
|
1704
|
+
type: number
|
|
1705
|
+
minimum: 0
|
|
1706
|
+
description: Milliseconds from the group's t=0. Rounded to a whole millisecond.
|
|
1707
|
+
example: 52000
|
|
1708
|
+
example:
|
|
1709
|
+
timecodeMs: 52000
|
|
1710
|
+
responses:
|
|
1711
|
+
'200':
|
|
1712
|
+
description: How many marks were ended.
|
|
1713
|
+
content:
|
|
1714
|
+
application/json:
|
|
1715
|
+
schema:
|
|
1716
|
+
type: object
|
|
1717
|
+
required:
|
|
1718
|
+
- cleared
|
|
1719
|
+
properties:
|
|
1720
|
+
cleared:
|
|
1721
|
+
type: integer
|
|
1722
|
+
example: 3
|
|
1723
|
+
example:
|
|
1724
|
+
cleared: 3
|
|
1725
|
+
'400':
|
|
1726
|
+
description: '`timecodeMs` is missing, negative or not a number.'
|
|
1727
|
+
content:
|
|
1728
|
+
application/json:
|
|
1729
|
+
schema:
|
|
1730
|
+
$ref: '#/components/schemas/Error'
|
|
1731
|
+
example:
|
|
1732
|
+
error: timecodeMs must be a finite number >= 0
|
|
1733
|
+
'401':
|
|
1734
|
+
$ref: '#/components/responses/Unauthorized'
|
|
1735
|
+
'403':
|
|
1736
|
+
$ref: '#/components/responses/Forbidden'
|
|
1737
|
+
'404':
|
|
1738
|
+
$ref: '#/components/responses/RecordingNotFound'
|
|
1739
|
+
'429':
|
|
1740
|
+
$ref: '#/components/responses/RateLimited'
|
|
1741
|
+
'500':
|
|
1742
|
+
$ref: '#/components/responses/RecordingInternalError'
|
|
1743
|
+
/api/recordings/{groupId}/composite.mp4:
|
|
1744
|
+
get:
|
|
1745
|
+
operationId: getRecordingCompositeVideo
|
|
1746
|
+
summary: Download the side-by-side video of a recording
|
|
1747
|
+
description: |
|
|
1748
|
+
The composite video of a recording of two or more phones, all phones side by side, with
|
|
1749
|
+
the marks burned in when that rendering works (otherwise without them).
|
|
1750
|
+
|
|
1751
|
+
`404 composite_not_found` when the recording has no composite (one phone, or the
|
|
1752
|
+
composite didn't start) and for a caller who can't see every phone in it, since it shows
|
|
1753
|
+
them all. Composites recorded before layouts were kept are served to admins only.
|
|
1754
|
+
|
|
1755
|
+
Supports `Range` requests so a player can seek (`206`, or `416` with `Content-Range:
|
|
1756
|
+
bytes */<size>`), and sends `ETag` and `Last-Modified`. Any signed-in user may call it.
|
|
1757
|
+
tags:
|
|
1758
|
+
- Recordings
|
|
1759
|
+
parameters:
|
|
1760
|
+
- $ref: '#/components/parameters/RecordingGroupId'
|
|
1761
|
+
- $ref: '#/components/parameters/RecordingRange'
|
|
1762
|
+
responses:
|
|
1763
|
+
'200':
|
|
1764
|
+
description: The composite video.
|
|
1765
|
+
headers:
|
|
1766
|
+
Accept-Ranges:
|
|
1767
|
+
schema:
|
|
1768
|
+
type: string
|
|
1769
|
+
example: bytes
|
|
1770
|
+
ETag:
|
|
1771
|
+
schema:
|
|
1772
|
+
type: string
|
|
1773
|
+
example: W/"117c3b7-19a8f2c4e10"
|
|
1774
|
+
Last-Modified:
|
|
1775
|
+
schema:
|
|
1776
|
+
type: string
|
|
1777
|
+
example: Sun, 04 Oct 2026 08:24:31 GMT
|
|
1778
|
+
content:
|
|
1779
|
+
video/mp4:
|
|
1780
|
+
schema:
|
|
1781
|
+
type: string
|
|
1782
|
+
format: binary
|
|
1783
|
+
'206':
|
|
1784
|
+
$ref: '#/components/responses/RecordingVideoPartial'
|
|
1785
|
+
'401':
|
|
1786
|
+
$ref: '#/components/responses/Unauthorized'
|
|
1787
|
+
'403':
|
|
1788
|
+
$ref: '#/components/responses/Forbidden'
|
|
1789
|
+
'404':
|
|
1790
|
+
description: >-
|
|
1791
|
+
No composite the caller may have, or its file went missing before it could be sent.
|
|
1792
|
+
content:
|
|
1793
|
+
application/json:
|
|
1794
|
+
schema:
|
|
1795
|
+
$ref: '#/components/schemas/Error'
|
|
1796
|
+
examples:
|
|
1797
|
+
notFound:
|
|
1798
|
+
value:
|
|
1799
|
+
error: composite_not_found
|
|
1800
|
+
noFile:
|
|
1801
|
+
value:
|
|
1802
|
+
error: video_not_found
|
|
1803
|
+
'416':
|
|
1804
|
+
$ref: '#/components/responses/RecordingRangeNotSatisfiable'
|
|
1805
|
+
'429':
|
|
1806
|
+
$ref: '#/components/responses/RateLimited'
|
|
1807
|
+
'500':
|
|
1808
|
+
description: The file could not be sent.
|
|
1809
|
+
content:
|
|
1810
|
+
application/json:
|
|
1811
|
+
schema:
|
|
1812
|
+
$ref: '#/components/schemas/Error'
|
|
1813
|
+
example:
|
|
1814
|
+
error: internal
|
|
1815
|
+
/api/recordings/{groupId}/video.mp4:
|
|
1816
|
+
get:
|
|
1817
|
+
operationId: downloadRecordingVideo
|
|
1818
|
+
summary: Download one phone's video, with marks
|
|
1819
|
+
description: |
|
|
1820
|
+
One phone's video as an attachment (`<udid>.mp4`), with its marks burned in when that
|
|
1821
|
+
rendering works. Pick the phone with `udid`; without it, this works only when the caller
|
|
1822
|
+
can see exactly one phone with a video in the group (otherwise use `videos.zip`).
|
|
1823
|
+
|
|
1824
|
+
Supports `Range` requests (`206`, or `416` with `Content-Range: bytes */<size>`). Only
|
|
1825
|
+
phones the caller can see are served. Any signed-in user may call it.
|
|
1826
|
+
tags:
|
|
1827
|
+
- Recordings
|
|
1828
|
+
parameters:
|
|
1829
|
+
- $ref: '#/components/parameters/RecordingGroupId'
|
|
1830
|
+
- in: query
|
|
1831
|
+
name: udid
|
|
1832
|
+
required: false
|
|
1833
|
+
description: The phone whose video to download.
|
|
1834
|
+
schema:
|
|
1835
|
+
type: string
|
|
1836
|
+
example: R5CT32ABCDE
|
|
1837
|
+
- $ref: '#/components/parameters/RecordingRange'
|
|
1838
|
+
responses:
|
|
1839
|
+
'200':
|
|
1840
|
+
$ref: '#/components/responses/RecordingVideoAttachment'
|
|
1841
|
+
'206':
|
|
1842
|
+
$ref: '#/components/responses/RecordingVideoPartial'
|
|
1843
|
+
'401':
|
|
1844
|
+
$ref: '#/components/responses/Unauthorized'
|
|
1845
|
+
'403':
|
|
1846
|
+
$ref: '#/components/responses/Forbidden'
|
|
1847
|
+
'404':
|
|
1848
|
+
description: No phone of the group the caller can see, or no video for the phone asked for.
|
|
1849
|
+
content:
|
|
1850
|
+
application/json:
|
|
1851
|
+
schema:
|
|
1852
|
+
$ref: '#/components/schemas/Error'
|
|
1853
|
+
examples:
|
|
1854
|
+
notFound:
|
|
1855
|
+
value:
|
|
1856
|
+
error: not_found
|
|
1857
|
+
noVideoForPhone:
|
|
1858
|
+
value:
|
|
1859
|
+
error: video_not_found
|
|
1860
|
+
message: No playable video for that device
|
|
1861
|
+
severalVideos:
|
|
1862
|
+
value:
|
|
1863
|
+
error: video_not_found
|
|
1864
|
+
message: Use videos.zip when the group has multiple recordings
|
|
1865
|
+
'416':
|
|
1866
|
+
$ref: '#/components/responses/RecordingRangeNotSatisfiable'
|
|
1867
|
+
'429':
|
|
1868
|
+
$ref: '#/components/responses/RateLimited'
|
|
1869
|
+
'500':
|
|
1870
|
+
$ref: '#/components/responses/RecordingInternalError'
|
|
1871
|
+
/api/recordings/{groupId}/source.mp4:
|
|
1872
|
+
get:
|
|
1873
|
+
operationId: streamRecordingSourceVideo
|
|
1874
|
+
summary: Stream one phone's video, without marks
|
|
1875
|
+
description: |
|
|
1876
|
+
One phone's video as recorded, with no marks burned in: what the recording page's player
|
|
1877
|
+
plays (it draws the marks itself). Supports `Range` requests so the player can seek
|
|
1878
|
+
(`206`, or `416` with `Content-Range: bytes */<size>`). With `download=1` it is sent as an
|
|
1879
|
+
attachment named `<udid>.mp4`.
|
|
1880
|
+
|
|
1881
|
+
The recording must belong to this group and be on a phone the caller can see, otherwise
|
|
1882
|
+
`404`. Any signed-in user may call it.
|
|
1883
|
+
tags:
|
|
1884
|
+
- Recordings
|
|
1885
|
+
parameters:
|
|
1886
|
+
- $ref: '#/components/parameters/RecordingGroupId'
|
|
1887
|
+
- in: query
|
|
1888
|
+
name: recordingId
|
|
1889
|
+
required: true
|
|
1890
|
+
description: The phone's recording (`recordings[].id`).
|
|
1891
|
+
schema:
|
|
1892
|
+
type: string
|
|
1893
|
+
example: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
1894
|
+
- in: query
|
|
1895
|
+
name: download
|
|
1896
|
+
required: false
|
|
1897
|
+
description: '`1` to send it as an attachment.'
|
|
1898
|
+
schema:
|
|
1899
|
+
type: string
|
|
1900
|
+
enum:
|
|
1901
|
+
- '1'
|
|
1902
|
+
- $ref: '#/components/parameters/RecordingRange'
|
|
1903
|
+
responses:
|
|
1904
|
+
'200':
|
|
1905
|
+
description: The video.
|
|
1906
|
+
headers:
|
|
1907
|
+
Accept-Ranges:
|
|
1908
|
+
schema:
|
|
1909
|
+
type: string
|
|
1910
|
+
example: bytes
|
|
1911
|
+
content:
|
|
1912
|
+
video/mp4:
|
|
1913
|
+
schema:
|
|
1914
|
+
type: string
|
|
1915
|
+
format: binary
|
|
1916
|
+
'206':
|
|
1917
|
+
$ref: '#/components/responses/RecordingVideoPartial'
|
|
1918
|
+
'400':
|
|
1919
|
+
description: '`recordingId` is missing.'
|
|
1920
|
+
content:
|
|
1921
|
+
application/json:
|
|
1922
|
+
schema:
|
|
1923
|
+
$ref: '#/components/schemas/Error'
|
|
1924
|
+
example:
|
|
1925
|
+
error: recordingId query param is required
|
|
1926
|
+
'401':
|
|
1927
|
+
$ref: '#/components/responses/Unauthorized'
|
|
1928
|
+
'403':
|
|
1929
|
+
$ref: '#/components/responses/Forbidden'
|
|
1930
|
+
'404':
|
|
1931
|
+
description: Not a recording of this group the caller can see, or its file is gone.
|
|
1932
|
+
content:
|
|
1933
|
+
application/json:
|
|
1934
|
+
schema:
|
|
1935
|
+
$ref: '#/components/schemas/Error'
|
|
1936
|
+
examples:
|
|
1937
|
+
notFound:
|
|
1938
|
+
value:
|
|
1939
|
+
error: not_found
|
|
1940
|
+
noFile:
|
|
1941
|
+
value:
|
|
1942
|
+
error: video_not_found
|
|
1943
|
+
'416':
|
|
1944
|
+
$ref: '#/components/responses/RecordingRangeNotSatisfiable'
|
|
1945
|
+
'429':
|
|
1946
|
+
$ref: '#/components/responses/RateLimited'
|
|
1947
|
+
'500':
|
|
1948
|
+
$ref: '#/components/responses/RecordingInternalError'
|
|
1949
|
+
/api/recordings/{groupId}/exports/annotated.mp4:
|
|
1950
|
+
get:
|
|
1951
|
+
operationId: exportAnnotatedRecordingVideo
|
|
1952
|
+
summary: Download one phone's video with marks burned in
|
|
1953
|
+
description: |
|
|
1954
|
+
One phone's video with its marks burned into the picture. Rendered on first request and
|
|
1955
|
+
then kept, so the first download of a recording with marks can take a while (it is
|
|
1956
|
+
usually prepared when the recording stops). A recording with no marks is served as
|
|
1957
|
+
recorded. Streamed whole: no `Range` support.
|
|
1958
|
+
|
|
1959
|
+
The recording must belong to this group and be on a phone the caller can see, otherwise
|
|
1960
|
+
`404`. Any signed-in user may call it.
|
|
1961
|
+
tags:
|
|
1962
|
+
- Recordings
|
|
1963
|
+
parameters:
|
|
1964
|
+
- $ref: '#/components/parameters/RecordingGroupId'
|
|
1965
|
+
- in: query
|
|
1966
|
+
name: recordingId
|
|
1967
|
+
required: true
|
|
1968
|
+
description: The phone's recording (`recordings[].id`).
|
|
1969
|
+
schema:
|
|
1970
|
+
type: string
|
|
1971
|
+
example: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
1972
|
+
responses:
|
|
1973
|
+
'200':
|
|
1974
|
+
description: The video with marks.
|
|
1975
|
+
content:
|
|
1976
|
+
video/mp4:
|
|
1977
|
+
schema:
|
|
1978
|
+
type: string
|
|
1979
|
+
format: binary
|
|
1980
|
+
'400':
|
|
1981
|
+
description: '`recordingId` is missing.'
|
|
1982
|
+
content:
|
|
1983
|
+
application/json:
|
|
1984
|
+
schema:
|
|
1985
|
+
$ref: '#/components/schemas/Error'
|
|
1986
|
+
example:
|
|
1987
|
+
error: recordingId query param is required
|
|
1988
|
+
'401':
|
|
1989
|
+
$ref: '#/components/responses/Unauthorized'
|
|
1990
|
+
'403':
|
|
1991
|
+
$ref: '#/components/responses/Forbidden'
|
|
1992
|
+
'404':
|
|
1993
|
+
$ref: '#/components/responses/RecordingNotFound'
|
|
1994
|
+
'429':
|
|
1995
|
+
$ref: '#/components/responses/RateLimited'
|
|
1996
|
+
'500':
|
|
1997
|
+
description: The video could not be rendered or read.
|
|
1998
|
+
content:
|
|
1999
|
+
application/json:
|
|
2000
|
+
schema:
|
|
2001
|
+
$ref: '#/components/schemas/Error'
|
|
2002
|
+
example:
|
|
2003
|
+
error: render_failed
|
|
2004
|
+
message: ffmpeg exited with code 1
|
|
2005
|
+
/api/recordings/{groupId}/videos.zip:
|
|
2006
|
+
get:
|
|
2007
|
+
operationId: downloadRecordingVideosZip
|
|
2008
|
+
summary: Download a recording's videos as a zip
|
|
2009
|
+
description: |
|
|
2010
|
+
A zip of the recording's videos: `<udid>.mp4` for each phone the caller can see that has
|
|
2011
|
+
a video, with its marks burned in when that rendering works, and `composite.mp4` when
|
|
2012
|
+
there is a composite the caller may have (see `composite.mp4`). Nothing else. Any
|
|
2013
|
+
signed-in user may call it.
|
|
2014
|
+
tags:
|
|
2015
|
+
- Recordings
|
|
2016
|
+
parameters:
|
|
2017
|
+
- $ref: '#/components/parameters/RecordingGroupId'
|
|
2018
|
+
responses:
|
|
2019
|
+
'200':
|
|
2020
|
+
description: The zip, streamed.
|
|
2021
|
+
headers:
|
|
2022
|
+
Content-Disposition:
|
|
2023
|
+
schema:
|
|
2024
|
+
type: string
|
|
2025
|
+
example: attachment; filename="videos-c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21.zip"
|
|
2026
|
+
content:
|
|
2027
|
+
application/zip:
|
|
2028
|
+
schema:
|
|
2029
|
+
type: string
|
|
2030
|
+
format: binary
|
|
2031
|
+
'401':
|
|
2032
|
+
$ref: '#/components/responses/Unauthorized'
|
|
2033
|
+
'403':
|
|
2034
|
+
$ref: '#/components/responses/Forbidden'
|
|
2035
|
+
'404':
|
|
2036
|
+
description: No phone of the group the caller can see, or none of them has a video.
|
|
2037
|
+
content:
|
|
2038
|
+
application/json:
|
|
2039
|
+
schema:
|
|
2040
|
+
$ref: '#/components/schemas/Error'
|
|
2041
|
+
examples:
|
|
2042
|
+
notFound:
|
|
2043
|
+
value:
|
|
2044
|
+
error: not_found
|
|
2045
|
+
noVideos:
|
|
2046
|
+
value:
|
|
2047
|
+
error: no_videos
|
|
2048
|
+
message: No playable videos in this group
|
|
2049
|
+
'429':
|
|
2050
|
+
$ref: '#/components/responses/RateLimited'
|
|
2051
|
+
'500':
|
|
2052
|
+
$ref: '#/components/responses/RecordingInternalError'
|
|
2053
|
+
/api/recordings/{groupId}/bundle.zip:
|
|
2054
|
+
get:
|
|
2055
|
+
operationId: downloadRecordingProofBundle
|
|
2056
|
+
summary: Download a recording's proof bundle
|
|
2057
|
+
description: |
|
|
2058
|
+
A self-contained zip of the recording, for the phones the caller can see:
|
|
2059
|
+
|
|
2060
|
+
- `manifest.json`: the group and each phone's recording (id, status, times, size);
|
|
2061
|
+
- `README.md`: a readable summary;
|
|
2062
|
+
- `composite.mp4`, when there is a composite the caller may have (marks burned in when
|
|
2063
|
+
that rendering works);
|
|
2064
|
+
- per phone, under `devices/<udid>/`: `video.mp4` (as recorded, no marks),
|
|
2065
|
+
`bookmarks.json`, `annotations.json` and `device.json`.
|
|
2066
|
+
|
|
2067
|
+
Streamed as it is built: an error after it has started cuts the download short. Any
|
|
2068
|
+
signed-in user may call it.
|
|
2069
|
+
tags:
|
|
2070
|
+
- Recordings
|
|
2071
|
+
parameters:
|
|
2072
|
+
- $ref: '#/components/parameters/RecordingGroupId'
|
|
2073
|
+
responses:
|
|
2074
|
+
'200':
|
|
2075
|
+
description: The proof bundle, streamed.
|
|
2076
|
+
headers:
|
|
2077
|
+
Content-Disposition:
|
|
2078
|
+
schema:
|
|
2079
|
+
type: string
|
|
2080
|
+
example: attachment; filename="proof-c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21.zip"
|
|
2081
|
+
content:
|
|
2082
|
+
application/zip:
|
|
2083
|
+
schema:
|
|
2084
|
+
type: string
|
|
2085
|
+
format: binary
|
|
2086
|
+
'401':
|
|
2087
|
+
$ref: '#/components/responses/Unauthorized'
|
|
2088
|
+
'403':
|
|
2089
|
+
$ref: '#/components/responses/Forbidden'
|
|
2090
|
+
'404':
|
|
2091
|
+
$ref: '#/components/responses/RecordingNotFound'
|
|
2092
|
+
'429':
|
|
2093
|
+
$ref: '#/components/responses/RateLimited'
|
|
2094
|
+
'500':
|
|
2095
|
+
$ref: '#/components/responses/RecordingInternalError'
|
|
2096
|
+
|
|
2097
|
+
components:
|
|
2098
|
+
parameters:
|
|
2099
|
+
PlatformAppId:
|
|
2100
|
+
in: path
|
|
2101
|
+
name: id
|
|
2102
|
+
required: true
|
|
2103
|
+
description: The app's id.
|
|
2104
|
+
schema:
|
|
2105
|
+
type: string
|
|
2106
|
+
example: 9adc3f2e-7b1a-4c6d-8e9f-0a1b2c3d4e5f
|
|
2107
|
+
RecordingGroupId:
|
|
2108
|
+
in: path
|
|
2109
|
+
name: groupId
|
|
2110
|
+
required: true
|
|
2111
|
+
description: The recording's group id.
|
|
2112
|
+
schema:
|
|
2113
|
+
type: string
|
|
2114
|
+
example: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
|
|
2115
|
+
RecordingRange:
|
|
2116
|
+
in: header
|
|
2117
|
+
name: Range
|
|
2118
|
+
required: false
|
|
2119
|
+
description: A byte range, for seeking or resuming.
|
|
2120
|
+
schema:
|
|
2121
|
+
type: string
|
|
2122
|
+
example: bytes=0-1048575
|
|
2123
|
+
responses:
|
|
2124
|
+
PlatformSettingsRefused:
|
|
2125
|
+
description: The caller isn't a `SUPER_ADMIN`, or the credential lacks the `admin` scope.
|
|
2126
|
+
content:
|
|
2127
|
+
application/json:
|
|
2128
|
+
schema:
|
|
2129
|
+
$ref: '#/components/schemas/Error'
|
|
2130
|
+
examples:
|
|
2131
|
+
notSuperAdmin:
|
|
2132
|
+
summary: Not a super admin
|
|
2133
|
+
value:
|
|
2134
|
+
error: Only a super admin can change the lab's settings.
|
|
2135
|
+
message: Only a super admin can change the lab's settings.
|
|
2136
|
+
scope:
|
|
2137
|
+
summary: The admin scope is missing
|
|
2138
|
+
value:
|
|
2139
|
+
error: insufficient scope
|
|
2140
|
+
PlatformAppNotFound:
|
|
2141
|
+
description: No such app, or one outside the caller's teams (the two answer the same).
|
|
2142
|
+
content:
|
|
2143
|
+
application/json:
|
|
2144
|
+
schema:
|
|
2145
|
+
$ref: '#/components/schemas/Error'
|
|
2146
|
+
example:
|
|
2147
|
+
error: App not found
|
|
2148
|
+
RecordingNotFound:
|
|
2149
|
+
description: |
|
|
2150
|
+
No such recording or phone, or none the caller can see (the two answer the same).
|
|
2151
|
+
content:
|
|
2152
|
+
application/json:
|
|
2153
|
+
schema:
|
|
2154
|
+
$ref: '#/components/schemas/Error'
|
|
2155
|
+
example:
|
|
2156
|
+
error: not_found
|
|
2157
|
+
RecordingInternalError:
|
|
2158
|
+
description: An unexpected server error.
|
|
2159
|
+
content:
|
|
2160
|
+
application/json:
|
|
2161
|
+
schema:
|
|
2162
|
+
$ref: '#/components/schemas/Error'
|
|
2163
|
+
example:
|
|
2164
|
+
error: internal
|
|
2165
|
+
message: Can't reach database server
|
|
2166
|
+
RecordingStartConflict:
|
|
2167
|
+
description: |
|
|
2168
|
+
Nothing was started: a phone is busy, or the server's cap on simultaneous recordings
|
|
2169
|
+
(`maxConcurrentRecordings`, 4 by default) would be exceeded.
|
|
2170
|
+
content:
|
|
2171
|
+
application/json:
|
|
2172
|
+
schema:
|
|
2173
|
+
$ref: '#/components/schemas/Error'
|
|
2174
|
+
examples:
|
|
2175
|
+
busy:
|
|
2176
|
+
value:
|
|
2177
|
+
error: device_busy
|
|
2178
|
+
busyDevices:
|
|
2179
|
+
- udid: R5CT32ABCDE
|
|
2180
|
+
reason: manual_other
|
|
2181
|
+
blockId: manual_3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9_R5CT32ABCDE
|
|
2182
|
+
message: 1 of 2 selected devices are busy. Recording was not started.
|
|
2183
|
+
cap:
|
|
2184
|
+
value:
|
|
2185
|
+
error: concurrency_cap
|
|
2186
|
+
limit: 4
|
|
2187
|
+
active: 4
|
|
2188
|
+
message: Server-wide recording cap reached (4/4).
|
|
2189
|
+
RecordingVideoAttachment:
|
|
2190
|
+
description: The video, as an attachment.
|
|
2191
|
+
headers:
|
|
2192
|
+
Content-Disposition:
|
|
2193
|
+
schema:
|
|
2194
|
+
type: string
|
|
2195
|
+
example: attachment; filename="R5CT32ABCDE.mp4"
|
|
2196
|
+
Accept-Ranges:
|
|
2197
|
+
schema:
|
|
2198
|
+
type: string
|
|
2199
|
+
example: bytes
|
|
2200
|
+
content:
|
|
2201
|
+
video/mp4:
|
|
2202
|
+
schema:
|
|
2203
|
+
type: string
|
|
2204
|
+
format: binary
|
|
2205
|
+
RecordingVideoPartial:
|
|
2206
|
+
description: The requested byte range.
|
|
2207
|
+
headers:
|
|
2208
|
+
Content-Range:
|
|
2209
|
+
schema:
|
|
2210
|
+
type: string
|
|
2211
|
+
example: bytes 0-1048575/18342011
|
|
2212
|
+
content:
|
|
2213
|
+
video/mp4:
|
|
2214
|
+
schema:
|
|
2215
|
+
type: string
|
|
2216
|
+
format: binary
|
|
2217
|
+
RecordingRangeNotSatisfiable:
|
|
2218
|
+
description: The `Range` lies outside the file.
|
|
2219
|
+
headers:
|
|
2220
|
+
Content-Range:
|
|
2221
|
+
description: '`bytes */<size>`: the length to ask within.'
|
|
2222
|
+
schema:
|
|
2223
|
+
type: string
|
|
2224
|
+
example: bytes */18342011
|
|
2225
|
+
content:
|
|
2226
|
+
application/json:
|
|
2227
|
+
schema:
|
|
2228
|
+
$ref: '#/components/schemas/Error'
|
|
2229
|
+
example:
|
|
2230
|
+
error: range_not_satisfiable
|
|
2231
|
+
schemas:
|
|
2232
|
+
PlatformProcess:
|
|
2233
|
+
type: object
|
|
2234
|
+
required:
|
|
2235
|
+
- id
|
|
2236
|
+
- kind
|
|
2237
|
+
- uptimeMs
|
|
2238
|
+
properties:
|
|
2239
|
+
id:
|
|
2240
|
+
type: string
|
|
2241
|
+
example: 6f1d2c3b-4a5e-4f60-8a7b-9c0d1e2f3a4b
|
|
2242
|
+
sessionId:
|
|
2243
|
+
type: string
|
|
2244
|
+
description: The session it belongs to, if any.
|
|
2245
|
+
example: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
2246
|
+
udid:
|
|
2247
|
+
type: string
|
|
2248
|
+
description: The device it belongs to, if any.
|
|
2249
|
+
example: R5CT32ABCDE
|
|
2250
|
+
kind:
|
|
2251
|
+
type: string
|
|
2252
|
+
enum:
|
|
2253
|
+
- wda
|
|
2254
|
+
- ffmpeg
|
|
2255
|
+
- adb-reverse
|
|
2256
|
+
- ios-mjpeg
|
|
2257
|
+
- log-tailer
|
|
2258
|
+
- other
|
|
2259
|
+
pid:
|
|
2260
|
+
type: integer
|
|
2261
|
+
example: 48213
|
|
2262
|
+
uptimeMs:
|
|
2263
|
+
type: integer
|
|
2264
|
+
example: 734120
|
|
2265
|
+
PlatformSettings:
|
|
2266
|
+
type: object
|
|
2267
|
+
description: The lab's stored settings, merged with the AI settings in effect. A setting never saved is absent.
|
|
2268
|
+
properties:
|
|
2269
|
+
healthCheckIntervalMs:
|
|
2270
|
+
type: integer
|
|
2271
|
+
example: 300000
|
|
2272
|
+
healthCheckSchedule:
|
|
2273
|
+
type: string
|
|
2274
|
+
example: '*/5 * * * *'
|
|
2275
|
+
buildCleanupDays:
|
|
2276
|
+
type: integer
|
|
2277
|
+
example: 30
|
|
2278
|
+
buildCleanupMaxCount:
|
|
2279
|
+
type: integer
|
|
2280
|
+
example: 500
|
|
2281
|
+
buildCleanupSchedule:
|
|
2282
|
+
type: string
|
|
2283
|
+
example: 0 3 * * *
|
|
2284
|
+
deleteBuildAssets:
|
|
2285
|
+
type: boolean
|
|
2286
|
+
aiProvider:
|
|
2287
|
+
type: string
|
|
2288
|
+
example: gemini
|
|
2289
|
+
aiModel:
|
|
2290
|
+
type: string
|
|
2291
|
+
aiBaseUrl:
|
|
2292
|
+
type: string
|
|
2293
|
+
geminiModel:
|
|
2294
|
+
type: string
|
|
2295
|
+
openaiModel:
|
|
2296
|
+
type: string
|
|
2297
|
+
anthropicModel:
|
|
2298
|
+
type: string
|
|
2299
|
+
ollamaModel:
|
|
2300
|
+
type: string
|
|
2301
|
+
geminiSet:
|
|
2302
|
+
type: boolean
|
|
2303
|
+
description: Whether a Gemini API key is configured.
|
|
2304
|
+
openaiSet:
|
|
2305
|
+
type: boolean
|
|
2306
|
+
description: Whether an OpenAI API key is configured.
|
|
2307
|
+
anthropicSet:
|
|
2308
|
+
type: boolean
|
|
2309
|
+
description: Whether an Anthropic API key is configured.
|
|
2310
|
+
PlatformSettingsUpdate:
|
|
2311
|
+
type: object
|
|
2312
|
+
description: Any subset. Unknown fields are ignored.
|
|
2313
|
+
properties:
|
|
2314
|
+
healthCheckIntervalMs:
|
|
2315
|
+
type: integer
|
|
2316
|
+
example: 300000
|
|
2317
|
+
healthCheckSchedule:
|
|
2318
|
+
type: string
|
|
2319
|
+
description: A cron expression.
|
|
2320
|
+
example: '*/5 * * * *'
|
|
2321
|
+
buildCleanupDays:
|
|
2322
|
+
type: integer
|
|
2323
|
+
example: 30
|
|
2324
|
+
buildCleanupMaxCount:
|
|
2325
|
+
type: integer
|
|
2326
|
+
example: 500
|
|
2327
|
+
buildCleanupSchedule:
|
|
2328
|
+
type: string
|
|
2329
|
+
description: A cron expression.
|
|
2330
|
+
example: 0 3 * * *
|
|
2331
|
+
deleteBuildAssets:
|
|
2332
|
+
type: boolean
|
|
2333
|
+
aiProvider:
|
|
2334
|
+
type: string
|
|
2335
|
+
description: Not stored; until the server restarts.
|
|
2336
|
+
enum:
|
|
2337
|
+
- gemini
|
|
2338
|
+
- openai
|
|
2339
|
+
- anthropic
|
|
2340
|
+
- ollama
|
|
2341
|
+
aiModel:
|
|
2342
|
+
type: string
|
|
2343
|
+
description: Not stored; until the server restarts.
|
|
2344
|
+
aiBaseUrl:
|
|
2345
|
+
type: string
|
|
2346
|
+
description: Not stored; until the server restarts.
|
|
2347
|
+
geminiModel:
|
|
2348
|
+
type: string
|
|
2349
|
+
description: Not stored; until the server restarts.
|
|
2350
|
+
openaiModel:
|
|
2351
|
+
type: string
|
|
2352
|
+
description: Not stored; until the server restarts.
|
|
2353
|
+
anthropicModel:
|
|
2354
|
+
type: string
|
|
2355
|
+
description: Not stored; until the server restarts.
|
|
2356
|
+
ollamaModel:
|
|
2357
|
+
type: string
|
|
2358
|
+
description: Not stored; until the server restarts.
|
|
2359
|
+
PlatformAiTestRequest:
|
|
2360
|
+
type: object
|
|
2361
|
+
description: Every field is optional and falls back to the server's current setting.
|
|
2362
|
+
properties:
|
|
2363
|
+
aiProvider:
|
|
2364
|
+
type: string
|
|
2365
|
+
enum:
|
|
2366
|
+
- gemini
|
|
2367
|
+
- openai
|
|
2368
|
+
- anthropic
|
|
2369
|
+
- ollama
|
|
2370
|
+
aiModel:
|
|
2371
|
+
type: string
|
|
2372
|
+
example: gpt-4o
|
|
2373
|
+
aiBaseUrl:
|
|
2374
|
+
type: string
|
|
2375
|
+
description: For OpenAI-compatible endpoints and Ollama.
|
|
2376
|
+
example: http://localhost:11434
|
|
2377
|
+
geminiApiKey:
|
|
2378
|
+
type: string
|
|
2379
|
+
description: Used only when the server has no Gemini key of its own.
|
|
2380
|
+
openaiApiKey:
|
|
2381
|
+
type: string
|
|
2382
|
+
description: Used only when the server has no OpenAI key of its own.
|
|
2383
|
+
anthropicApiKey:
|
|
2384
|
+
type: string
|
|
2385
|
+
description: Used only when the server has no Anthropic key of its own.
|
|
2386
|
+
PlatformAiTestResult:
|
|
2387
|
+
type: object
|
|
2388
|
+
required:
|
|
2389
|
+
- success
|
|
2390
|
+
- message
|
|
2391
|
+
properties:
|
|
2392
|
+
success:
|
|
2393
|
+
type: boolean
|
|
2394
|
+
message:
|
|
2395
|
+
type: string
|
|
2396
|
+
PlatformWebhook:
|
|
2397
|
+
type: object
|
|
2398
|
+
properties:
|
|
2399
|
+
id:
|
|
2400
|
+
type: string
|
|
2401
|
+
example: 2d6f4a8e-1b3c-4d5e-9f60-7a8b9c0d1e2f
|
|
2402
|
+
url:
|
|
2403
|
+
type: string
|
|
2404
|
+
example: https://hooks.slack.com/services/T000/B000/XXXXXXXX
|
|
2405
|
+
type:
|
|
2406
|
+
type: string
|
|
2407
|
+
description: '`slack` sends a Slack message; anything else a generic JSON body.'
|
|
2408
|
+
example: slack
|
|
2409
|
+
events:
|
|
2410
|
+
type: string
|
|
2411
|
+
description: The subscribed events, as a JSON-encoded array.
|
|
2412
|
+
example: '["device_offline","session_failed"]'
|
|
2413
|
+
active:
|
|
2414
|
+
type: boolean
|
|
2415
|
+
payloadTemplate:
|
|
2416
|
+
type: string
|
|
2417
|
+
nullable: true
|
|
2418
|
+
createdAt:
|
|
2419
|
+
type: string
|
|
2420
|
+
format: date-time
|
|
2421
|
+
updatedAt:
|
|
2422
|
+
type: string
|
|
2423
|
+
format: date-time
|
|
2424
|
+
PlatformWebhookCreate:
|
|
2425
|
+
type: object
|
|
2426
|
+
required:
|
|
2427
|
+
- url
|
|
2428
|
+
- events
|
|
2429
|
+
properties:
|
|
2430
|
+
url:
|
|
2431
|
+
type: string
|
|
2432
|
+
format: uri
|
|
2433
|
+
description: Where to POST events.
|
|
2434
|
+
events:
|
|
2435
|
+
type: array
|
|
2436
|
+
description: The events to send.
|
|
2437
|
+
items:
|
|
2438
|
+
type: string
|
|
2439
|
+
enum:
|
|
2440
|
+
- device_offline
|
|
2441
|
+
- session_failed
|
|
2442
|
+
- device_new
|
|
2443
|
+
- selector_health_digest
|
|
2444
|
+
type:
|
|
2445
|
+
type: string
|
|
2446
|
+
description: '`slack` for a Slack message, anything else for `{ event, payload }`.'
|
|
2447
|
+
default: slack
|
|
2448
|
+
example: slack
|
|
2449
|
+
payloadTemplate:
|
|
2450
|
+
type: string
|
|
2451
|
+
description: A body template with `{{key}}` placeholders, used in place of the default body.
|
|
2452
|
+
example: '{"text":"{{eventType}} on {{udid}}"}'
|
|
2453
|
+
PlatformApp:
|
|
2454
|
+
type: object
|
|
2455
|
+
description: An uploaded app build.
|
|
2456
|
+
properties:
|
|
2457
|
+
id:
|
|
2458
|
+
type: string
|
|
2459
|
+
example: 9adc3f2e-7b1a-4c6d-8e9f-0a1b2c3d4e5f
|
|
2460
|
+
name:
|
|
2461
|
+
type: string
|
|
2462
|
+
example: checkout-2.4.1.apk
|
|
2463
|
+
filename:
|
|
2464
|
+
type: string
|
|
2465
|
+
example: checkout-2.4.1.apk
|
|
2466
|
+
filepath:
|
|
2467
|
+
type: string
|
|
2468
|
+
description: Where the file is stored on the server.
|
|
2469
|
+
mimetype:
|
|
2470
|
+
type: string
|
|
2471
|
+
example: application/vnd.android.package-archive
|
|
2472
|
+
size:
|
|
2473
|
+
type: integer
|
|
2474
|
+
description: Bytes.
|
|
2475
|
+
example: 48213377
|
|
2476
|
+
packageName:
|
|
2477
|
+
type: string
|
|
2478
|
+
nullable: true
|
|
2479
|
+
description: Read from an `.apk`; null for other files.
|
|
2480
|
+
example: com.example.checkout
|
|
2481
|
+
version:
|
|
2482
|
+
type: string
|
|
2483
|
+
nullable: true
|
|
2484
|
+
example: 2.4.1
|
|
2485
|
+
platform:
|
|
2486
|
+
type: string
|
|
2487
|
+
nullable: true
|
|
2488
|
+
enum:
|
|
2489
|
+
- android
|
|
2490
|
+
- ios
|
|
2491
|
+
md5:
|
|
2492
|
+
type: string
|
|
2493
|
+
nullable: true
|
|
2494
|
+
example: 5f4dcc3b5aa765d61d8327deb882cf99
|
|
2495
|
+
teamId:
|
|
2496
|
+
type: string
|
|
2497
|
+
nullable: true
|
|
2498
|
+
description: The team whose members see it; null is the shared pool.
|
|
2499
|
+
createdAt:
|
|
2500
|
+
type: string
|
|
2501
|
+
format: date-time
|
|
2502
|
+
updatedAt:
|
|
2503
|
+
type: string
|
|
2504
|
+
format: date-time
|
|
2505
|
+
PlatformAppWithTeam:
|
|
2506
|
+
allOf:
|
|
2507
|
+
- $ref: '#/components/schemas/PlatformApp'
|
|
2508
|
+
- type: object
|
|
2509
|
+
properties:
|
|
2510
|
+
team:
|
|
2511
|
+
type: object
|
|
2512
|
+
nullable: true
|
|
2513
|
+
properties:
|
|
2514
|
+
id:
|
|
2515
|
+
type: string
|
|
2516
|
+
name:
|
|
2517
|
+
type: string
|
|
2518
|
+
RecordingStarted:
|
|
2519
|
+
type: object
|
|
2520
|
+
required:
|
|
2521
|
+
- id
|
|
2522
|
+
- udid
|
|
2523
|
+
- status
|
|
2524
|
+
properties:
|
|
2525
|
+
id:
|
|
2526
|
+
type: string
|
|
2527
|
+
description: The phone's recording id.
|
|
2528
|
+
example: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
|
|
2529
|
+
udid:
|
|
2530
|
+
type: string
|
|
2531
|
+
example: R5CT32ABCDE
|
|
2532
|
+
status:
|
|
2533
|
+
type: string
|
|
2534
|
+
enum:
|
|
2535
|
+
- RECORDING
|
|
2536
|
+
RecordingStopped:
|
|
2537
|
+
type: object
|
|
2538
|
+
required:
|
|
2539
|
+
- id
|
|
2540
|
+
- udid
|
|
2541
|
+
- status
|
|
2542
|
+
properties:
|
|
2543
|
+
id:
|
|
2544
|
+
type: string
|
|
2545
|
+
udid:
|
|
2546
|
+
type: string
|
|
2547
|
+
status:
|
|
2548
|
+
type: string
|
|
2549
|
+
description: >-
|
|
2550
|
+
`RECORDING` only for a phone whose stream was ending at the moment of the stop (its
|
|
2551
|
+
video is being finished separately).
|
|
2552
|
+
enum:
|
|
2553
|
+
- STOPPED
|
|
2554
|
+
- FAILED
|
|
2555
|
+
- RECORDING
|
|
2556
|
+
durationMs:
|
|
2557
|
+
type: integer
|
|
2558
|
+
description: The video's length.
|
|
2559
|
+
sizeBytes:
|
|
2560
|
+
type: integer
|
|
2561
|
+
description: Absent when there is no file.
|
|
2562
|
+
RecordingPhone:
|
|
2563
|
+
type: object
|
|
2564
|
+
description: One phone of a recording, as the library shows it.
|
|
2565
|
+
properties:
|
|
2566
|
+
recordingId:
|
|
2567
|
+
type: string
|
|
2568
|
+
udid:
|
|
2569
|
+
type: string
|
|
2570
|
+
name:
|
|
2571
|
+
type: string
|
|
2572
|
+
description: The phone's name, or its udid when unknown.
|
|
2573
|
+
platform:
|
|
2574
|
+
type: string
|
|
2575
|
+
nullable: true
|
|
2576
|
+
status:
|
|
2577
|
+
type: string
|
|
2578
|
+
enum:
|
|
2579
|
+
- RECORDING
|
|
2580
|
+
- STOPPED
|
|
2581
|
+
- FAILED
|
|
2582
|
+
- DISCARDED
|
|
2583
|
+
offsetMs:
|
|
2584
|
+
type: integer
|
|
2585
|
+
description: Where this phone's video starts on the group's timeline; negative if before t=0.
|
|
2586
|
+
durationMs:
|
|
2587
|
+
type: integer
|
|
2588
|
+
nullable: true
|
|
2589
|
+
failReason:
|
|
2590
|
+
type: string
|
|
2591
|
+
nullable: true
|
|
2592
|
+
example: source_ended
|
|
2593
|
+
annotationCount:
|
|
2594
|
+
type: integer
|
|
2595
|
+
RecordingSummary:
|
|
2596
|
+
type: object
|
|
2597
|
+
description: A recording group, summarized over the phones the caller can see.
|
|
2598
|
+
properties:
|
|
2599
|
+
groupId:
|
|
2600
|
+
type: string
|
|
2601
|
+
startedAt:
|
|
2602
|
+
type: string
|
|
2603
|
+
format: date-time
|
|
2604
|
+
description: The group's t=0, which marks and bookmarks count from.
|
|
2605
|
+
endedAt:
|
|
2606
|
+
type: string
|
|
2607
|
+
format: date-time
|
|
2608
|
+
nullable: true
|
|
2609
|
+
durationMs:
|
|
2610
|
+
type: integer
|
|
2611
|
+
nullable: true
|
|
2612
|
+
description: The timeline's length; null while recording.
|
|
2613
|
+
status:
|
|
2614
|
+
type: string
|
|
2615
|
+
description: '`recording` while any phone records, `failed` when every phone failed, else `done`.'
|
|
2616
|
+
enum:
|
|
2617
|
+
- recording
|
|
2618
|
+
- done
|
|
2619
|
+
- failed
|
|
2620
|
+
phones:
|
|
2621
|
+
type: array
|
|
2622
|
+
items:
|
|
2623
|
+
$ref: '#/components/schemas/RecordingPhone'
|
|
2624
|
+
startedBy:
|
|
2625
|
+
type: object
|
|
2626
|
+
nullable: true
|
|
2627
|
+
properties:
|
|
2628
|
+
id:
|
|
2629
|
+
type: string
|
|
2630
|
+
name:
|
|
2631
|
+
type: string
|
|
2632
|
+
bookmarkCount:
|
|
2633
|
+
type: integer
|
|
2634
|
+
annotationCount:
|
|
2635
|
+
type: integer
|
|
2636
|
+
keptUntil:
|
|
2637
|
+
type: string
|
|
2638
|
+
format: date-time
|
|
2639
|
+
description: When the retention sweep removes it.
|
|
2640
|
+
sizeBytes:
|
|
2641
|
+
type: integer
|
|
2642
|
+
hasComposite:
|
|
2643
|
+
type: boolean
|
|
2644
|
+
description: Whether `composite.mp4` would serve this caller.
|
|
2645
|
+
RecordingLibraryPage:
|
|
2646
|
+
type: object
|
|
2647
|
+
properties:
|
|
2648
|
+
recordings:
|
|
2649
|
+
type: array
|
|
2650
|
+
items:
|
|
2651
|
+
$ref: '#/components/schemas/RecordingSummary'
|
|
2652
|
+
nextCursor:
|
|
2653
|
+
type: string
|
|
2654
|
+
nullable: true
|
|
2655
|
+
description: Pass as `cursor` for the next page; null on the last.
|
|
2656
|
+
total:
|
|
2657
|
+
type: integer
|
|
2658
|
+
description: Recordings matching the filters, across all pages.
|
|
2659
|
+
facets:
|
|
2660
|
+
type: object
|
|
2661
|
+
description: Counts over every recording the caller can see, before the filters.
|
|
2662
|
+
properties:
|
|
2663
|
+
phones:
|
|
2664
|
+
type: array
|
|
2665
|
+
items:
|
|
2666
|
+
type: object
|
|
2667
|
+
properties:
|
|
2668
|
+
udid:
|
|
2669
|
+
type: string
|
|
2670
|
+
name:
|
|
2671
|
+
type: string
|
|
2672
|
+
count:
|
|
2673
|
+
type: integer
|
|
2674
|
+
people:
|
|
2675
|
+
type: array
|
|
2676
|
+
items:
|
|
2677
|
+
type: object
|
|
2678
|
+
properties:
|
|
2679
|
+
id:
|
|
2680
|
+
type: string
|
|
2681
|
+
name:
|
|
2682
|
+
type: string
|
|
2683
|
+
count:
|
|
2684
|
+
type: integer
|
|
2685
|
+
unknownCount:
|
|
2686
|
+
type: integer
|
|
2687
|
+
description: Recordings with no recorded starter.
|
|
2688
|
+
when:
|
|
2689
|
+
type: object
|
|
2690
|
+
properties:
|
|
2691
|
+
any:
|
|
2692
|
+
type: integer
|
|
2693
|
+
24h:
|
|
2694
|
+
type: integer
|
|
2695
|
+
7d:
|
|
2696
|
+
type: integer
|
|
2697
|
+
30d:
|
|
2698
|
+
type: integer
|
|
2699
|
+
retention:
|
|
2700
|
+
type: object
|
|
2701
|
+
properties:
|
|
2702
|
+
days:
|
|
2703
|
+
type: integer
|
|
2704
|
+
description: Days a recording is kept.
|
|
2705
|
+
maxCount:
|
|
2706
|
+
type: integer
|
|
2707
|
+
description: Recordings kept at most.
|
|
2708
|
+
RecordingBookmarkRow:
|
|
2709
|
+
type: object
|
|
2710
|
+
description: A bookmark as stored.
|
|
2711
|
+
properties:
|
|
2712
|
+
id:
|
|
2713
|
+
type: string
|
|
2714
|
+
recording_id:
|
|
2715
|
+
type: string
|
|
2716
|
+
timecode_ms:
|
|
2717
|
+
type: integer
|
|
2718
|
+
label:
|
|
2719
|
+
type: string
|
|
2720
|
+
note:
|
|
2721
|
+
type: string
|
|
2722
|
+
nullable: true
|
|
2723
|
+
created_at:
|
|
2724
|
+
type: string
|
|
2725
|
+
format: date-time
|
|
2726
|
+
RecordingAnnotationRow:
|
|
2727
|
+
type: object
|
|
2728
|
+
description: A mark as stored.
|
|
2729
|
+
properties:
|
|
2730
|
+
id:
|
|
2731
|
+
type: string
|
|
2732
|
+
recording_id:
|
|
2733
|
+
type: string
|
|
2734
|
+
timecode_ms:
|
|
2735
|
+
type: integer
|
|
2736
|
+
end_timecode_ms:
|
|
2737
|
+
type: integer
|
|
2738
|
+
nullable: true
|
|
2739
|
+
description: When it was cleared; null while on screen.
|
|
2740
|
+
shape:
|
|
2741
|
+
type: string
|
|
2742
|
+
geometry:
|
|
2743
|
+
type: string
|
|
2744
|
+
color:
|
|
2745
|
+
type: string
|
|
2746
|
+
text:
|
|
2747
|
+
type: string
|
|
2748
|
+
nullable: true
|
|
2749
|
+
author:
|
|
2750
|
+
type: string
|
|
2751
|
+
nullable: true
|
|
2752
|
+
created_at:
|
|
2753
|
+
type: string
|
|
2754
|
+
format: date-time
|
|
2755
|
+
RecordingRow:
|
|
2756
|
+
type: object
|
|
2757
|
+
description: One phone's recording as stored.
|
|
2758
|
+
properties:
|
|
2759
|
+
id:
|
|
2760
|
+
type: string
|
|
2761
|
+
group_id:
|
|
2762
|
+
type: string
|
|
2763
|
+
device_udid:
|
|
2764
|
+
type: string
|
|
2765
|
+
device_host:
|
|
2766
|
+
type: string
|
|
2767
|
+
session_id:
|
|
2768
|
+
type: string
|
|
2769
|
+
nullable: true
|
|
2770
|
+
started_at:
|
|
2771
|
+
type: string
|
|
2772
|
+
format: date-time
|
|
2773
|
+
ended_at:
|
|
2774
|
+
type: string
|
|
2775
|
+
format: date-time
|
|
2776
|
+
nullable: true
|
|
2777
|
+
status:
|
|
2778
|
+
type: string
|
|
2779
|
+
enum:
|
|
2780
|
+
- RECORDING
|
|
2781
|
+
- STOPPED
|
|
2782
|
+
- FAILED
|
|
2783
|
+
- DISCARDED
|
|
2784
|
+
file_path:
|
|
2785
|
+
type: string
|
|
2786
|
+
description: Where the video is stored on the server.
|
|
2787
|
+
duration_ms:
|
|
2788
|
+
type: integer
|
|
2789
|
+
nullable: true
|
|
2790
|
+
size_bytes:
|
|
2791
|
+
type: integer
|
|
2792
|
+
nullable: true
|
|
2793
|
+
device_snapshot:
|
|
2794
|
+
type: string
|
|
2795
|
+
nullable: true
|
|
2796
|
+
fail_reason:
|
|
2797
|
+
type: string
|
|
2798
|
+
nullable: true
|
|
2799
|
+
started_by:
|
|
2800
|
+
type: string
|
|
2801
|
+
nullable: true
|
|
2802
|
+
description: The user who started it.
|
|
2803
|
+
bookmarks:
|
|
2804
|
+
type: array
|
|
2805
|
+
items:
|
|
2806
|
+
$ref: '#/components/schemas/RecordingBookmarkRow'
|
|
2807
|
+
annotations:
|
|
2808
|
+
type: array
|
|
2809
|
+
items:
|
|
2810
|
+
$ref: '#/components/schemas/RecordingAnnotationRow'
|
|
2811
|
+
RecordingBookmarkView:
|
|
2812
|
+
type: object
|
|
2813
|
+
properties:
|
|
2814
|
+
id:
|
|
2815
|
+
type: string
|
|
2816
|
+
recordingId:
|
|
2817
|
+
type: string
|
|
2818
|
+
timecodeMs:
|
|
2819
|
+
type: integer
|
|
2820
|
+
label:
|
|
2821
|
+
type: string
|
|
2822
|
+
note:
|
|
2823
|
+
type: string
|
|
2824
|
+
nullable: true
|
|
2825
|
+
RecordingAnnotationView:
|
|
2826
|
+
type: object
|
|
2827
|
+
properties:
|
|
2828
|
+
id:
|
|
2829
|
+
type: string
|
|
2830
|
+
recordingId:
|
|
2831
|
+
type: string
|
|
2832
|
+
timecodeMs:
|
|
2833
|
+
type: integer
|
|
2834
|
+
endTimecodeMs:
|
|
2835
|
+
type: integer
|
|
2836
|
+
nullable: true
|
|
2837
|
+
shape:
|
|
2838
|
+
type: string
|
|
2839
|
+
geometry:
|
|
2840
|
+
type: string
|
|
2841
|
+
color:
|
|
2842
|
+
type: string
|
|
2843
|
+
text:
|
|
2844
|
+
type: string
|
|
2845
|
+
nullable: true
|
|
2846
|
+
RecordingActiveGroup:
|
|
2847
|
+
type: object
|
|
2848
|
+
properties:
|
|
2849
|
+
groupId:
|
|
2850
|
+
type: string
|
|
2851
|
+
startedAt:
|
|
2852
|
+
type: string
|
|
2853
|
+
format: date-time
|
|
2854
|
+
description: The group's t=0.
|
|
2855
|
+
recordings:
|
|
2856
|
+
type: array
|
|
2857
|
+
items:
|
|
2858
|
+
type: object
|
|
2859
|
+
properties:
|
|
2860
|
+
id:
|
|
2861
|
+
type: string
|
|
2862
|
+
udid:
|
|
2863
|
+
type: string
|
|
2864
|
+
annotations:
|
|
2865
|
+
type: array
|
|
2866
|
+
description: Marks still on screen.
|
|
2867
|
+
items:
|
|
2868
|
+
type: object
|
|
2869
|
+
properties:
|
|
2870
|
+
recordingId:
|
|
2871
|
+
type: string
|
|
2872
|
+
shape:
|
|
2873
|
+
type: string
|
|
2874
|
+
geometry:
|
|
2875
|
+
type: string
|
|
2876
|
+
color:
|
|
2877
|
+
type: string
|
|
2878
|
+
text:
|
|
2879
|
+
type: string
|
|
2880
|
+
nullable: true
|
|
2881
|
+
timecodeMs:
|
|
2882
|
+
type: integer
|
|
2883
|
+
compositeEnabled:
|
|
2884
|
+
type: boolean
|
|
2885
|
+
description: Whether `composite.mp4` would serve this caller.
|