@andrian.yablonskyy/thub-coordinator 1.0.31 → 1.0.33
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/README.md +1 -1
- package/package.json +2 -2
- package/public/js/client-config.js +3 -20
- package/public/js/config-import.js +59 -0
- package/src/api/agent.js +3 -3
- package/src/api/resource.js +2 -1
- package/src/api/sse.js +24 -2
- package/src/db/migrations/021_add_client_config_file.sql +8 -0
- package/src/dev/virtual.js +6 -17
- package/src/services/jobs.js +25 -16
- package/src/services/registry.js +97 -32
- package/src/services/scheduler.js +1 -6
- package/src/web/routes.js +40 -0
- package/test/scheduler.test.js +104 -22
- package/views/index.pug +1 -0
- package/views/jobs/show.pug +8 -0
- package/views/mixins/client-config.pug +32 -95
- package/views/mixins/resource.pug +46 -28
- package/views/resources/list.pug +1 -0
package/README.md
CHANGED
|
@@ -159,7 +159,7 @@ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla J
|
|
|
159
159
|
|
|
160
160
|
Notifications (results of dashboard actions, errors) appear as toasts in the top-right corner and close themselves by severity: **errors stay until closed**, warnings close after **30 s**, info and success after **10 s**. A one-time secret such as a rotated resource token never closes on its own.
|
|
161
161
|
|
|
162
|
-
**Resource card.** Clicking a resource on `/` or `/resources` opens a card with its name, type, status, host uptime and current task/status with its duration (both from the Client's heartbeats, anchored to the Coordinator's clock; as of the last heartbeat for an OUT_OF_SERVICE resource), Client version, IP addresses, capabilities (reported at registration: HW udev devices with a **missing** flag for any whose device node doesn't exist
|
|
162
|
+
**Resource card.** Clicking a resource on `/` or `/resources` opens a card with its name, type, status, host uptime and current task/status with its duration (both from the Client's heartbeats, anchored to the Coordinator's clock; as of the last heartbeat for an OUT_OF_SERVICE resource), Client version, IP addresses, capabilities (reported at registration: HW udev devices with a **missing** flag for any whose device node doesn't exist; SW image, source and limits), labels, groups, busy source/reason, last heartbeat and (admins only) the maintenance/rotate-token actions. IP addresses are the Client host's interface addresses except loopback (reported at registration and on every heartbeat) plus the external address — the one the Coordinator last saw the Client connect from, taken from `X-Forwarded-For` when the request came through a proxy `trustProxy` trusts. Admins get a **Cancel** button on it, enabled only while the Client is doing something: it cancels its current job, releases a manual local lock, or aborts a self-update hold on its host (the last two delivered as `unlock` / `cancel-update` heartbeat commands).
|
|
163
163
|
|
|
164
164
|
The Coordinator's own version (`ver. X.Y.Z`) is shown under the TestHub logo, top left of every page.
|
|
165
165
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.33",
|
|
4
4
|
"description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"engines": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"lint:fix": "eslint . --fix"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@andrian.yablonskyy/thub-common": "^1.0.
|
|
22
|
+
"@andrian.yablonskyy/thub-common": "^1.0.28",
|
|
23
23
|
"better-sqlite3": "^12.4.1",
|
|
24
24
|
"express": "^5.1.0",
|
|
25
25
|
"express-session": "^1.18.2",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file packages/coordinator/public/js/client-config.js
|
|
3
3
|
* @description Dashboard: the resource card's Capabilities form — add/remove device rows, and turn the form into
|
|
4
|
-
*
|
|
4
|
+
* an HW Client's hw-devices config section (JSON) on Save
|
|
5
5
|
*
|
|
6
6
|
* @author Andrian Yablonskyy
|
|
7
7
|
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
@@ -24,9 +24,6 @@
|
|
|
24
24
|
|
|
25
25
|
function rows(form, kind){
|
|
26
26
|
return [...form.querySelectorAll(`[data-table="${kind}"] tbody [data-row]`)].map((tr) => {
|
|
27
|
-
if (kind === 'relays'){
|
|
28
|
-
return compact({ channel: num(tr, 'channel'), baseUrl: text(tr, 'baseUrl') });
|
|
29
|
-
}
|
|
30
27
|
let extra = {};
|
|
31
28
|
try {
|
|
32
29
|
extra = JSON.parse(tr.dataset.extra || '{}');
|
|
@@ -44,26 +41,12 @@
|
|
|
44
41
|
});
|
|
45
42
|
}
|
|
46
43
|
|
|
44
|
+
// An HW Client's hw-devices section (the only Clients with a form).
|
|
47
45
|
function sectionFrom(form){
|
|
48
|
-
if (form.dataset.clientConfig === 'sw'){
|
|
49
|
-
return compact({
|
|
50
|
-
image: text(form, 'image'),
|
|
51
|
-
registry: text(form, 'registry'),
|
|
52
|
-
cpus: num(form, 'cpus'),
|
|
53
|
-
memory: text(form, 'memory'),
|
|
54
|
-
cmd: text(form, 'cmd') ? text(form, 'cmd').split(/\s+/) : undefined,
|
|
55
|
-
allowDockerHub: val(form, 'allowDockerHub').checked,
|
|
56
|
-
allowJobImages: val(form, 'allowJobImages').checked
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
const power = form.querySelector('[data-power]'),
|
|
60
|
-
method = text(power, 'method');
|
|
61
46
|
return {
|
|
62
47
|
stlinks: rows(form, 'stlinks'),
|
|
63
48
|
uarts: rows(form, 'uarts'),
|
|
64
|
-
usbs: rows(form, 'usbs')
|
|
65
|
-
relays: rows(form, 'relays'),
|
|
66
|
-
power: method ? compact({ method, hub: text(power, 'hub'), port: num(power, 'port'), baseUrl: text(power, 'baseUrl') }) : null
|
|
49
|
+
usbs: rows(form, 'usbs')
|
|
67
50
|
};
|
|
68
51
|
}
|
|
69
52
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/public/js/config-import.js
|
|
3
|
+
* @description Dashboard: the resource card's config Import button — picks a JSON file, puts it in the import form
|
|
4
|
+
* and submits it, confirmed first (confirm.js) with what will and won't be applied
|
|
5
|
+
*
|
|
6
|
+
* @author Andrian Yablonskyy
|
|
7
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
8
|
+
*
|
|
9
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
10
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
11
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
12
|
+
* from AdSystem.PRO.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
(function (){
|
|
18
|
+
const IGNORED = ['joinKey', 'coordinatorUrl', 'name'],
|
|
19
|
+
MAX_BYTES = 64 * 1024;
|
|
20
|
+
|
|
21
|
+
document.querySelectorAll('form[data-config-import]').forEach((form) => {
|
|
22
|
+
const picker = form.querySelector('[data-config-import-file]');
|
|
23
|
+
form.querySelector('[data-config-import-pick]').addEventListener('click', () => {
|
|
24
|
+
picker.value = ''; // the same file again still fires `change`
|
|
25
|
+
picker.click();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
picker.addEventListener('change', async () => {
|
|
29
|
+
const file = picker.files[0];
|
|
30
|
+
if (!file){
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const text = file.size > MAX_BYTES ? '' : await file.text();
|
|
34
|
+
form.querySelector('input[name="file"]').value = text;
|
|
35
|
+
form.querySelector('input[name="fileName"]').value = file.name;
|
|
36
|
+
|
|
37
|
+
let parsed = null;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(text);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
parsed = null;
|
|
43
|
+
}
|
|
44
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)){
|
|
45
|
+
// Nothing to confirm: the Coordinator says what's wrong with it.
|
|
46
|
+
delete form.dataset.confirm;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const present = IGNORED.filter((k) => k in parsed);
|
|
50
|
+
form.dataset.confirm = `Import ${file.name} into ${form.dataset.resourceName}? ` +
|
|
51
|
+
'It replaces the Client\'s config (capabilities, labels, groups, heartbeat settings, …); the Client writes it on its next heartbeat ' +
|
|
52
|
+
'and restarts once it has no job running.\n\n' +
|
|
53
|
+
`Ignored: joinKey, coordinatorUrl and name${present.length ? ` (in this file: ${present.join(', ')})` : ''}, ` +
|
|
54
|
+
'and the Client\'s id, file paths and secrets — each Client keeps its own.';
|
|
55
|
+
}
|
|
56
|
+
form.requestSubmit();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
})();
|
package/src/api/agent.js
CHANGED
|
@@ -47,7 +47,7 @@ function createAgentRouter({ services, config }){
|
|
|
47
47
|
}
|
|
48
48
|
const resource = job.resource_id ? services.registry.get(job.resource_id) : null;
|
|
49
49
|
res.json({
|
|
50
|
-
...job,
|
|
50
|
+
...services.jobs.publicJob(job),
|
|
51
51
|
resource: resource ? { id: resource.id, name: resource.name } : job.resource_name ? { id: null, name: job.resource_name } : null
|
|
52
52
|
});
|
|
53
53
|
});
|
|
@@ -60,13 +60,13 @@ function createAgentRouter({ services, config }){
|
|
|
60
60
|
mine: req.query.mine === 'true' || req.query.mine === '1',
|
|
61
61
|
limit: req.query.limit ? Number(req.query.limit) : undefined
|
|
62
62
|
});
|
|
63
|
-
res.json({ jobs });
|
|
63
|
+
res.json({ jobs: jobs.map(services.jobs.publicJob) });
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
router.post('/jobs/:id/cancel', auth, (req, res, next) => {
|
|
67
67
|
try {
|
|
68
68
|
const job = services.jobs.cancel(req.params.id, { agentId: req.agent.id, isAdmin: false });
|
|
69
|
-
res.json(job);
|
|
69
|
+
res.json(services.jobs.publicJob(job));
|
|
70
70
|
}
|
|
71
71
|
catch (err){
|
|
72
72
|
next(err);
|
package/src/api/resource.js
CHANGED
|
@@ -51,7 +51,7 @@ function createResourceRouter({ services, config }){
|
|
|
51
51
|
// the resource by name (see registry.registerAuto).
|
|
52
52
|
router.post('/resources/register', requireJoinKey(config), (req, res, next) => {
|
|
53
53
|
try {
|
|
54
|
-
const { clientId, name, type, labels, groups, hostInfo, capabilities, config: clientConfig } = req.body;
|
|
54
|
+
const { clientId, name, type, labels, groups, hostInfo, capabilities, config: clientConfig, configFile } = req.body;
|
|
55
55
|
if (!clientId){
|
|
56
56
|
return res.status(400).json({ error: 'clientId is required (persisted in the Client\'s .client-id file)' });
|
|
57
57
|
}
|
|
@@ -67,6 +67,7 @@ function createResourceRouter({ services, config }){
|
|
|
67
67
|
hostInfo,
|
|
68
68
|
capabilities,
|
|
69
69
|
config: clientConfig,
|
|
70
|
+
configFile,
|
|
70
71
|
remoteAddr: req.ip,
|
|
71
72
|
clientVersion: appVersion(req, 'client')
|
|
72
73
|
});
|
package/src/api/sse.js
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
|
-
const { TERMINAL_JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
16
|
+
const { TERMINAL_JOB_STATES, JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
17
|
+
|
|
18
|
+
const WAITING_CHECK_MS = 15_000;
|
|
17
19
|
|
|
18
20
|
// §6.4: log / state / end events, resumable via Last-Event-ID (§7: "the
|
|
19
21
|
// Agent reconnects with exponential backoff and resumes from the last seq").
|
|
@@ -62,7 +64,26 @@ function attachJobStream(req, res, { jobId, services, config }){
|
|
|
62
64
|
const resourceName = (id) => (id ? registry.get(id)?.name : undefined);
|
|
63
65
|
writeEvent('state', { state: current.state, resource: resourceName(current.resource_id) });
|
|
64
66
|
|
|
65
|
-
|
|
67
|
+
// While QUEUED: why no Client has taken it yet (`waiting`), re-checked
|
|
68
|
+
// as Clients come and go; sent again only when the reason changes.
|
|
69
|
+
let lastReason = null;
|
|
70
|
+
function checkWaiting(){
|
|
71
|
+
const job = jobs.get(jobId);
|
|
72
|
+
if (job?.state !== JOB_STATES.QUEUED){
|
|
73
|
+
lastReason = null;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const { type, labels = [], group, client } = job.spec.target,
|
|
77
|
+
reason = registry.waitingReason(type, labels, group, client);
|
|
78
|
+
if (reason && reason !== lastReason){
|
|
79
|
+
writeEvent('waiting', { reason });
|
|
80
|
+
}
|
|
81
|
+
lastReason = reason;
|
|
82
|
+
}
|
|
83
|
+
checkWaiting();
|
|
84
|
+
const waitingTimer = setInterval(checkWaiting, WAITING_CHECK_MS),
|
|
85
|
+
|
|
86
|
+
onLog = (entry) => {
|
|
66
87
|
if (entry.jobId === jobId){
|
|
67
88
|
writeEvent('log', { ts: entry.ts, stream: entry.stream, line: entry.line }, entry.seq);
|
|
68
89
|
}
|
|
@@ -82,6 +103,7 @@ function attachJobStream(req, res, { jobId, services, config }){
|
|
|
82
103
|
};
|
|
83
104
|
|
|
84
105
|
function cleanup(){
|
|
106
|
+
clearInterval(waitingTimer);
|
|
85
107
|
bus.off('job.log', onLog);
|
|
86
108
|
bus.off('job.state', onState);
|
|
87
109
|
bus.off('job.finished', onFinished);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- Client config Export / Import (README §10, resource card Actions).
|
|
2
|
+
-- `client_config_file` is the Client's whole config file as it last
|
|
3
|
+
-- reported it at registration, its secrets (artifactory.token,
|
|
4
|
+
-- sw.registryAuth) left out. `config_import` is an imported file's fields
|
|
5
|
+
-- beyond the hw/sw section, as JSON { revision, fields }: sent with every
|
|
6
|
+
-- set-config until the Client reports applying that revision.
|
|
7
|
+
ALTER TABLE resources ADD COLUMN client_config_file TEXT;
|
|
8
|
+
ALTER TABLE resources ADD COLUMN config_import TEXT;
|
package/src/dev/virtual.js
CHANGED
|
@@ -29,9 +29,7 @@ const DEV_AGENT_NAME = 'virtual-agent',
|
|
|
29
29
|
name: 'virtual-sw-01',
|
|
30
30
|
type: 'sw',
|
|
31
31
|
labels: ['virtual', 'emulator'],
|
|
32
|
-
capabilities: {
|
|
33
|
-
sw: { image: 'dut-emulator:dev', registry: null, allowDockerHub: true, allowJobImages: true, cpus: 2, memory: '2g' }
|
|
34
|
-
},
|
|
32
|
+
capabilities: { sw: {} },
|
|
35
33
|
address: '10.0.0.41'
|
|
36
34
|
},
|
|
37
35
|
{
|
|
@@ -43,9 +41,7 @@ const DEV_AGENT_NAME = 'virtual-agent',
|
|
|
43
41
|
hw: {
|
|
44
42
|
stlinks: [{ path: '/dev/thub/dut1-stlink', index: 1, serial: '066DFF485457725187092834', present: true }],
|
|
45
43
|
uarts: [{ path: '/dev/thub/dut1-uart', index: 1, baudRate: 115200, present: true }],
|
|
46
|
-
usbs: [{ path: '/dev/thub/dut1-usb', index: 1, present: false }]
|
|
47
|
-
relays: [{ channel: 0, baseUrl: 'http://localhost:3000' }],
|
|
48
|
-
power: { method: 'relay' }
|
|
44
|
+
usbs: [{ path: '/dev/thub/dut1-usb', index: 1, present: false }]
|
|
49
45
|
}
|
|
50
46
|
},
|
|
51
47
|
address: '10.0.0.42'
|
|
@@ -188,21 +184,14 @@ function startVirtualClients(services, config){
|
|
|
188
184
|
labels: def.labels,
|
|
189
185
|
hostInfo: { hostname: `${def.name}.virtual`, platform: 'linux', addresses: [{ iface: 'eth0', address: def.address, family: 'IPv4' }], timeZone: 'UTC' },
|
|
190
186
|
capabilities: { ...def.capabilities, rebootSupported: false },
|
|
191
|
-
// Its
|
|
187
|
+
// Its editable section as a real Client reports it: an HW Client's
|
|
188
|
+
// hw-devices; an SW Client has none ({}).
|
|
192
189
|
config: def.type === 'sw'
|
|
193
|
-
? {
|
|
194
|
-
image: def.capabilities.sw.image,
|
|
195
|
-
allowDockerHub: def.capabilities.sw.allowDockerHub,
|
|
196
|
-
allowJobImages: def.capabilities.sw.allowJobImages,
|
|
197
|
-
cpus: def.capabilities.sw.cpus,
|
|
198
|
-
memory: def.capabilities.sw.memory
|
|
199
|
-
}
|
|
190
|
+
? {}
|
|
200
191
|
: {
|
|
201
192
|
stlinks: [{ index: 1, serial: '066DFF485457725187092834', devpath: '3.3.4.3.1' }],
|
|
202
193
|
uarts: [{ index: 1, baudRate: 115200, devpath: '3.3.3.2' }],
|
|
203
|
-
usbs: [{ index: 1 }]
|
|
204
|
-
relays: [{ channel: 0, baseUrl: 'http://localhost:3000' }],
|
|
205
|
-
power: { method: 'relay' }
|
|
194
|
+
usbs: [{ index: 1 }]
|
|
206
195
|
},
|
|
207
196
|
remoteAddr: '127.0.0.1',
|
|
208
197
|
clientVersion: version
|
package/src/services/jobs.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
|
-
const { validateJobSpec, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
|
|
16
|
+
const { validateJobSpec, maskEnv, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
|
|
17
17
|
{ paginate } = require('./list-prefs'),
|
|
18
18
|
// Named in spec errors: a field the Agent sends but this thub-common
|
|
19
19
|
// doesn't know means the Coordinator needs an update.
|
|
@@ -149,20 +149,8 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
149
149
|
spec.target.client = resource.id;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const needs = { jobImage: Boolean(spec.image) },
|
|
155
|
-
{ type, labels, group, client } = spec.target;
|
|
156
|
-
if (needs.jobImage && registry.everSatisfiable(type, labels, group, client) && !registry.everSatisfiable(type, labels, group, client, needs)){
|
|
157
|
-
throw Object.assign(
|
|
158
|
-
new Error(
|
|
159
|
-
`No matching SW Client runs job-supplied Docker images (${spec.image}) — enable it on a Client ` +
|
|
160
|
-
'with "sw": { "allowJobImages": true } in its config'
|
|
161
|
-
),
|
|
162
|
-
{ status: 422 }
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
if (!registry.everSatisfiable(type, labels, group, client, needs)){
|
|
152
|
+
const { type, labels, group, client } = spec.target;
|
|
153
|
+
if (!registry.everSatisfiable(type, labels, group, client)){
|
|
166
154
|
throw Object.assign(
|
|
167
155
|
new Error(
|
|
168
156
|
`No registered resource can ever satisfy type=${spec.target.type} labels=${spec.target.labels.join(',')}` +
|
|
@@ -248,6 +236,10 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
248
236
|
bus.emit('job.queued', { jobId: id });
|
|
249
237
|
}
|
|
250
238
|
if (TERMINAL_JOB_STATES.has(state)){
|
|
239
|
+
// A LOST job may be requeued (markLost), and needs its env for that.
|
|
240
|
+
if (state !== JOB_STATES.LOST){
|
|
241
|
+
forgetEnv(id);
|
|
242
|
+
}
|
|
251
243
|
bus.emit('job.finished', { jobId: id, state });
|
|
252
244
|
// LOST means the resource is presumed OUT_OF_SERVICE (that's why the
|
|
253
245
|
// job went LOST) — it only comes back via a real heartbeat (§4.1),
|
|
@@ -378,14 +370,30 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
378
370
|
if (job.state === JOB_STATES.LOST || TERMINAL_JOB_STATES.has(job.state)){
|
|
379
371
|
return job;
|
|
380
372
|
}
|
|
381
|
-
const spec = job.spec;
|
|
382
373
|
setState(id, JOB_STATES.LOST);
|
|
383
374
|
if (config.scheduler.requeueOnLost && job.attempt < 2){
|
|
384
375
|
return setState(id, JOB_STATES.QUEUED);
|
|
385
376
|
}
|
|
377
|
+
forgetEnv(id);
|
|
386
378
|
return get(id);
|
|
387
379
|
}
|
|
388
380
|
|
|
381
|
+
// A finished job's `--env` values (registry passwords etc.) aren't kept:
|
|
382
|
+
// only the names stay, masked.
|
|
383
|
+
function forgetEnv(id){
|
|
384
|
+
const row = db.prepare('SELECT spec FROM jobs WHERE id = ?').get(id),
|
|
385
|
+
spec = row && JSON.parse(row.spec);
|
|
386
|
+
if (spec?.env){
|
|
387
|
+
db.prepare('UPDATE jobs SET spec = ? WHERE id = ?').run(JSON.stringify({ ...spec, env: maskEnv(spec.env) }), id);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// A job as the Agent API shows it: `--env` values masked (any Agent token
|
|
392
|
+
// can read any job; only the Client running it gets them).
|
|
393
|
+
function publicJob(job){
|
|
394
|
+
return job?.spec?.env ? { ...job, spec: { ...job.spec, env: maskEnv(job.spec.env) } } : job;
|
|
395
|
+
}
|
|
396
|
+
|
|
389
397
|
function checkTimeouts(){
|
|
390
398
|
const now = Date.now(),
|
|
391
399
|
active = db
|
|
@@ -513,6 +521,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
513
521
|
|
|
514
522
|
return {
|
|
515
523
|
get,
|
|
524
|
+
publicJob,
|
|
516
525
|
list,
|
|
517
526
|
page,
|
|
518
527
|
create,
|
package/src/services/registry.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
16
|
const { v4: uuid } = require('uuid'),
|
|
17
|
-
{ RESOURCE_STATES, BUSY_SOURCES, ACTIVE_JOB_STATES, compareVersions, parseCron, validateClientConfig
|
|
17
|
+
{ RESOURCE_STATES, BUSY_SOURCES, ACTIVE_JOB_STATES, compareVersions, parseCron, validateClientConfig, importClientConfigFile,
|
|
18
|
+
shareableClientConfigFile, HW_DEVICES_SECTION } = require('@andrian.yablonskyy/thub-common'),
|
|
18
19
|
{ generateToken, hashToken } = require('./tokens');
|
|
19
20
|
|
|
20
21
|
function rowToResource(row){
|
|
@@ -29,6 +30,8 @@ function rowToResource(row){
|
|
|
29
30
|
capabilities: row.capabilities ? JSON.parse(row.capabilities) : null,
|
|
30
31
|
client_config: row.client_config ? JSON.parse(row.client_config) : null,
|
|
31
32
|
config_desired: row.config_desired ? JSON.parse(row.config_desired) : null,
|
|
33
|
+
client_config_file: row.client_config_file ? JSON.parse(row.client_config_file) : null,
|
|
34
|
+
config_import: row.config_import ? JSON.parse(row.config_import) : null,
|
|
32
35
|
usb_scan: row.usb_scan ? JSON.parse(row.usb_scan) : null,
|
|
33
36
|
activity: row.activity ? JSON.parse(row.activity) : null
|
|
34
37
|
};
|
|
@@ -86,29 +89,17 @@ function sanitizeCapabilities(caps){
|
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
function sanitizeTypedCapabilities(caps){
|
|
92
|
+
// An SW Client has no settings to report (older ones still send some).
|
|
89
93
|
if (caps.sw && typeof caps.sw === 'object'){
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
sw: {
|
|
93
|
-
image: str(sw.image),
|
|
94
|
-
registry: str(sw.registry),
|
|
95
|
-
allowDockerHub: sw.allowDockerHub === true,
|
|
96
|
-
allowJobImages: sw.allowJobImages === true,
|
|
97
|
-
cpus: num(sw.cpus),
|
|
98
|
-
memory: str(sw.memory, 16)
|
|
99
|
-
}
|
|
100
|
-
};
|
|
94
|
+
return { sw: {} };
|
|
101
95
|
}
|
|
102
96
|
if (caps.hw && typeof caps.hw === 'object'){
|
|
103
|
-
const hw = caps.hw
|
|
104
|
-
power = hw.power && typeof hw.power === 'object' ? hw.power : null;
|
|
97
|
+
const hw = caps.hw;
|
|
105
98
|
return {
|
|
106
99
|
hw: {
|
|
107
100
|
stlinks: capList(hw.stlinks).map(device),
|
|
108
101
|
uarts: capList(hw.uarts).map(device),
|
|
109
|
-
usbs: capList(hw.usbs).map(device)
|
|
110
|
-
relays: capList(hw.relays).map((r) => ({ channel: num(r?.channel), baseUrl: str(r?.baseUrl) })),
|
|
111
|
-
power: power ? { method: str(power.method, 32), hub: str(power.hub, 64), port: num(power.port) } : null
|
|
102
|
+
usbs: capList(hw.usbs).map(device)
|
|
112
103
|
}
|
|
113
104
|
};
|
|
114
105
|
}
|
|
@@ -195,7 +186,8 @@ function createRegistryService(db, { bus, events }){
|
|
|
195
186
|
// Config tab. Stored as reported, if it's a plain object of sane size.
|
|
196
187
|
// A dashboard rename (name_override) wins over the name the Client
|
|
197
188
|
// presents; that one is kept as reported_name.
|
|
198
|
-
|
|
189
|
+
// And `configFile`: the whole config file (secrets excluded), for Export.
|
|
190
|
+
function registerAuto({ config, configFile, ...registration }){
|
|
199
191
|
const known = registration.clientId ? getByClientId(registration.clientId) : null,
|
|
200
192
|
result = registerAutoInner({ ...registration, name: known?.name_override || registration.name });
|
|
201
193
|
db.prepare('UPDATE resources SET reported_name = ? WHERE id = ?').run(registration.name, result.resourceId);
|
|
@@ -205,6 +197,12 @@ function createRegistryService(db, { bus, events }){
|
|
|
205
197
|
db.prepare('UPDATE resources SET client_config = ? WHERE id = ?').run(json, result.resourceId);
|
|
206
198
|
}
|
|
207
199
|
}
|
|
200
|
+
if (configFile && typeof configFile === 'object' && !Array.isArray(configFile)){
|
|
201
|
+
const json = JSON.stringify(shareableClientConfigFile(configFile));
|
|
202
|
+
if (json.length <= 64 * 1024){
|
|
203
|
+
db.prepare('UPDATE resources SET client_config_file = ? WHERE id = ?').run(json, result.resourceId);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
208
206
|
return result;
|
|
209
207
|
}
|
|
210
208
|
|
|
@@ -529,13 +527,10 @@ function createRegistryService(db, { bus, events }){
|
|
|
529
527
|
);
|
|
530
528
|
}
|
|
531
529
|
|
|
532
|
-
|
|
533
|
-
// jobImage (the job's own Docker image; sw.allowJobImages).
|
|
534
|
-
function matchesTarget(r, labels, groupId, resourceId, needs = {}){
|
|
530
|
+
function matchesTarget(r, labels, groupId, resourceId){
|
|
535
531
|
return labels.every((l) => r.labels.includes(l)) &&
|
|
536
532
|
(!groupId || r.group_ids.includes(groupId)) &&
|
|
537
|
-
(!resourceId || r.id === resourceId)
|
|
538
|
-
(!needs.jobImage || r.capabilities?.sw?.allowJobImages === true);
|
|
533
|
+
(!resourceId || r.id === resourceId);
|
|
539
534
|
}
|
|
540
535
|
|
|
541
536
|
// Admin: remove a resource from the registry (dashboard "Remove"). Refused
|
|
@@ -653,13 +648,60 @@ function createRegistryService(db, { bus, events }){
|
|
|
653
648
|
return get(resourceId);
|
|
654
649
|
}
|
|
655
650
|
|
|
656
|
-
// { revision, type, config } while the Client hasn't applied the
|
|
651
|
+
// { revision, type, config[, file] } while the Client hasn't applied the
|
|
652
|
+
// saved one; `file`: an import's other fields, until it's applied.
|
|
657
653
|
function pendingConfig(resourceId){
|
|
658
654
|
const r = get(resourceId);
|
|
659
655
|
if (!r || !r.config_desired || r.config_applied_revision >= r.config_revision){
|
|
660
656
|
return null;
|
|
661
657
|
}
|
|
662
|
-
|
|
658
|
+
const file = pendingImport(r);
|
|
659
|
+
return { revision: r.config_revision, type: r.type, config: r.config_desired, ...(file ? { file } : {}) };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function pendingImport(r){
|
|
663
|
+
return r.config_import && r.config_import.revision > r.config_applied_revision ? r.config_import.fields : null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Import (resource card): a whole config file, as the Client's next config
|
|
667
|
+
// revision — its hw/sw section as if saved on the Config tabs (the one
|
|
668
|
+
// already there if the file has none), its other fields besides. Never
|
|
669
|
+
// taken from it: joinKey, coordinatorUrl, name, the Client's identity and
|
|
670
|
+
// host paths, secrets (importClientConfigFile). Returns { resource, ignored }.
|
|
671
|
+
function importClientConfig(resourceId, file, { by } = {}){
|
|
672
|
+
const r = get(resourceId);
|
|
673
|
+
if (!r){
|
|
674
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
675
|
+
}
|
|
676
|
+
const { valid, errors, section, fields, ignored } = importClientConfigFile(r.type, file);
|
|
677
|
+
if (!valid){
|
|
678
|
+
throw Object.assign(new Error(`Can't import this config into ${r.name}: ${errors.join('; ')}`), { status: 400 });
|
|
679
|
+
}
|
|
680
|
+
const desired = section || r.config_desired || r.client_config;
|
|
681
|
+
if (!desired){
|
|
682
|
+
throw Object.assign(new Error(`Can't import this config into ${r.name}: it has no "${HW_DEVICES_SECTION}" section, and the Client hasn't reported one`),
|
|
683
|
+
{ status: 400 });
|
|
684
|
+
}
|
|
685
|
+
const revision = r.config_revision + 1;
|
|
686
|
+
db.prepare(`UPDATE resources SET config_desired = ?, config_revision = ?, config_error = NULL, config_import = ?
|
|
687
|
+
WHERE id = ?`).run(JSON.stringify(desired), revision, JSON.stringify({ revision, fields }), resourceId);
|
|
688
|
+
events.record('resource', resourceId, 'resource.config_imported', { by, revision, fields: Object.keys(fields), ignored });
|
|
689
|
+
return { resource: get(resourceId), ignored };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Export (resource card): the Client's config file as it last reported it
|
|
693
|
+
// (secrets excluded), with what's saved for it but not applied yet — so
|
|
694
|
+
// exporting right after an edit or import gives what the Client will run.
|
|
695
|
+
// A Client too old to report its file gets what the Coordinator knows.
|
|
696
|
+
function exportClientConfig(resourceId){
|
|
697
|
+
const r = get(resourceId);
|
|
698
|
+
if (!r){
|
|
699
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
700
|
+
}
|
|
701
|
+
// Re-shared: a file stored from an older Client may still say hw / sw.
|
|
702
|
+
const base = shareableClientConfigFile(r.client_config_file || { name: r.reported_name || r.name, type: r.type, labels: r.labels }),
|
|
703
|
+
section = r.type === 'hw' ? r.config_desired || r.client_config : null;
|
|
704
|
+
return { ...base, ...(pendingImport(r) || {}), type: r.type, ...(section ? { [HW_DEVICES_SECTION]: section } : {}) };
|
|
663
705
|
}
|
|
664
706
|
|
|
665
707
|
// Scheduled host reboot (resource card): a cron expression, validated
|
|
@@ -693,17 +735,37 @@ function createRegistryService(db, { bus, events }){
|
|
|
693
735
|
return { cron: r.reboot_schedule || null };
|
|
694
736
|
}
|
|
695
737
|
|
|
696
|
-
function findIdleCandidates(type, labels, groupId, resourceId
|
|
738
|
+
function findIdleCandidates(type, labels, groupId, resourceId){
|
|
697
739
|
const rows = db
|
|
698
740
|
.prepare('SELECT * FROM resources WHERE status = ? AND type = ? AND remove_requested_at IS NULL')
|
|
699
741
|
.all(RESOURCE_STATES.IDLE, type)
|
|
700
742
|
.map(rowToResource);
|
|
701
|
-
return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
|
|
743
|
+
return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Why a queued job isn't assigned yet, in words — for the Agent and the
|
|
747
|
+
// dashboard (null when an idle Client fits: the next scheduler pass takes
|
|
748
|
+
// it, or higher-priority jobs are ahead). Names each matching Client that
|
|
749
|
+
// isn't free and what it's doing.
|
|
750
|
+
function waitingReason(type, labels, groupId, resourceId){
|
|
751
|
+
const matching = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource)
|
|
752
|
+
.filter((r) => matchesTarget(r, labels, groupId, resourceId)),
|
|
753
|
+
free = (r) => r.status === RESOURCE_STATES.IDLE && !r.remove_requested_at,
|
|
754
|
+
what = (r) => (r.remove_requested_at
|
|
755
|
+
? 'being removed'
|
|
756
|
+
: { [RESOURCE_STATES.BUSY]: 'busy', [RESOURCE_STATES.OUT_OF_SERVICE]: 'offline', [RESOURCE_STATES.MAINTENANCE]: 'in maintenance',
|
|
757
|
+
[RESOURCE_STATES.REGISTERED]: 'not connected yet' }[r.status] || r.status.toLowerCase()),
|
|
758
|
+
names = (rs) => rs.map((r) => `${r.name} (${what(r)})`).join(', '),
|
|
759
|
+
kind = type.toUpperCase();
|
|
760
|
+
if (!matching.length){
|
|
761
|
+
return `no ${kind} Client matches this job's target`;
|
|
762
|
+
}
|
|
763
|
+
return matching.some(free) ? null : `every matching ${kind} Client is taken: ${names(matching)}`;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function everSatisfiable(type, labels, groupId, resourceId){
|
|
705
767
|
const rows = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource);
|
|
706
|
-
return rows.some((r) => matchesTarget(r, labels, groupId, resourceId
|
|
768
|
+
return rows.some((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
707
769
|
}
|
|
708
770
|
|
|
709
771
|
return {
|
|
@@ -728,13 +790,16 @@ function createRegistryService(db, { bus, events }){
|
|
|
728
790
|
pendingRebootSchedule,
|
|
729
791
|
setClientConfig,
|
|
730
792
|
pendingConfig,
|
|
793
|
+
importClientConfig,
|
|
794
|
+
exportClientConfig,
|
|
731
795
|
rename,
|
|
732
796
|
requestUsbScan,
|
|
733
797
|
storeUsbScan,
|
|
734
798
|
requestRemoval,
|
|
735
799
|
pendingRemovals,
|
|
736
800
|
findIdleCandidates,
|
|
737
|
-
everSatisfiable
|
|
801
|
+
everSatisfiable,
|
|
802
|
+
waitingReason
|
|
738
803
|
};
|
|
739
804
|
}
|
|
740
805
|
|
|
@@ -15,11 +15,6 @@
|
|
|
15
15
|
|
|
16
16
|
const { JOB_STATES, BUSY_SOURCES } = require('@andrian.yablonskyy/thub-common');
|
|
17
17
|
|
|
18
|
-
// What a job needs a Client to have opted in to (registry matchesTarget).
|
|
19
|
-
function jobNeeds(spec){
|
|
20
|
-
return { jobImage: Boolean(spec.image) };
|
|
21
|
-
}
|
|
22
|
-
|
|
23
18
|
function rowToJob(row){
|
|
24
19
|
return { ...row, spec: JSON.parse(row.spec) };
|
|
25
20
|
}
|
|
@@ -48,7 +43,7 @@ function createScheduler(db, { bus, events, registry, config }){
|
|
|
48
43
|
for (const job of queued){
|
|
49
44
|
const { type, labels = [], group, client } = job.spec.target,
|
|
50
45
|
candidates = registry
|
|
51
|
-
.findIdleCandidates(type, labels, group, client
|
|
46
|
+
.findIdleCandidates(type, labels, group, client)
|
|
52
47
|
.filter((r) => !claimedResourceIds.has(r.id));
|
|
53
48
|
if (candidates.length === 0){
|
|
54
49
|
continue;
|
package/src/web/routes.js
CHANGED
|
@@ -493,6 +493,42 @@ function createWebRouter({ services, config }){
|
|
|
493
493
|
res.redirect(returnTo(req, '/resources'));
|
|
494
494
|
});
|
|
495
495
|
|
|
496
|
+
// Export (resource card, admins — the file holds the join key): the
|
|
497
|
+
// Client's config file as JSON, secrets excluded.
|
|
498
|
+
router.get('/resources/:id/config/export', requireAdminRole, (req, res) => {
|
|
499
|
+
try {
|
|
500
|
+
const r = services.registry.get(req.params.id),
|
|
501
|
+
file = services.registry.exportClientConfig(req.params.id);
|
|
502
|
+
res.attachment(`${r.name.replace(/[^A-Za-z0-9._-]+/g, '_')}.json`).type('application/json').send(JSON.stringify(file, null, 2) + '\n');
|
|
503
|
+
}
|
|
504
|
+
catch (err){
|
|
505
|
+
flash(req, 'danger', err.message);
|
|
506
|
+
res.redirect('/resources');
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// Import (resource card, public/js/config-import.js): a config file, applied
|
|
511
|
+
// by the Client on its next heartbeat like a Config tab Save.
|
|
512
|
+
router.post('/resources/:id/config/import', requireAdminRole, (req, res) => {
|
|
513
|
+
try {
|
|
514
|
+
let file;
|
|
515
|
+
try {
|
|
516
|
+
file = JSON.parse(req.body.file || '');
|
|
517
|
+
}
|
|
518
|
+
catch (err){
|
|
519
|
+
throw Object.assign(new Error(`${req.body.fileName || 'The file'} isn't valid JSON: ${err.message}`), { status: 400 });
|
|
520
|
+
}
|
|
521
|
+
const { resource: r, ignored } = services.registry.importClientConfig(req.params.id, file, { by: req.session.user.username });
|
|
522
|
+
flash(req, 'success', `Config imported into ${r.name} (revision ${r.config_revision}).\n` +
|
|
523
|
+
'The Client applies it on its next heartbeat, then restarts once it has no job running.' +
|
|
524
|
+
(ignored.length ? `\nIgnored: ${ignored.join(', ')}.` : ''));
|
|
525
|
+
}
|
|
526
|
+
catch (err){
|
|
527
|
+
flash(req, 'danger', err.message);
|
|
528
|
+
}
|
|
529
|
+
res.redirect(returnTo(req, '/resources'));
|
|
530
|
+
});
|
|
531
|
+
|
|
496
532
|
// Scheduled host reboot (resource card): save a cron expression, or clear
|
|
497
533
|
// it; the Client applies it on its next heartbeat.
|
|
498
534
|
router.post('/resources/:id/reboot-schedule', requireAdminRole, (req, res) => {
|
|
@@ -714,6 +750,10 @@ function createWebRouter({ services, config }){
|
|
|
714
750
|
active: 'jobs',
|
|
715
751
|
job,
|
|
716
752
|
pinnedClient: job.spec.target.client ? services.registry.get(job.spec.target.client) : null,
|
|
753
|
+
waitingReason: job.state === JOB_STATES.QUEUED
|
|
754
|
+
? services.registry.waitingReason(job.spec.target.type, job.spec.target.labels || [], job.spec.target.group,
|
|
755
|
+
job.spec.target.client)
|
|
756
|
+
: null,
|
|
717
757
|
artifacts,
|
|
718
758
|
jobActive,
|
|
719
759
|
canCancel: req.session.user.role === 'admin' && jobActive
|
package/test/scheduler.test.js
CHANGED
|
@@ -524,26 +524,52 @@ test('a removed Client re-registers as a fresh resource under the same name', ()
|
|
|
524
524
|
assert.equal(again.name, 'lab-back');
|
|
525
525
|
});
|
|
526
526
|
|
|
527
|
-
test('a job-supplied Docker image
|
|
527
|
+
test('a job-supplied Docker image runs on any matching SW Client (no opt-in any more)', () => {
|
|
528
528
|
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
529
529
|
{ agent } = agents.create({ name: 'dev', kind: 'cli' }),
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
assert.
|
|
535
|
-
|
|
530
|
+
r = registerResource(registry, { name: 'sw-any', type: 'sw' });
|
|
531
|
+
registry.heartbeat(r.id, { state: 'idle' });
|
|
532
|
+
const job = jobs.create({ agentId: agent.id, source: 'cli', spec: { target: { type: 'sw', labels: [] }, image: 'alpine', command: './run.sh' } });
|
|
533
|
+
scheduler.runPass();
|
|
534
|
+
assert.equal(jobs.get(job.id).resource_id, r.id);
|
|
535
|
+
// An older Client still reporting its sw settings: they're dropped.
|
|
536
536
|
const { resourceId } = registry.registerAuto({
|
|
537
|
-
clientId: 'c-
|
|
537
|
+
clientId: 'c-old', name: 'sw-old', type: 'sw', labels: [], capabilities: { sw: { image: 'emu', allowJobImages: false } }
|
|
538
538
|
});
|
|
539
|
-
registry.
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
539
|
+
assert.deepEqual(registry.get(resourceId).capabilities.sw, {});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test('waiting reason: says why a queued job has no Client — every match taken, or none matching', () => {
|
|
543
|
+
const { registry } = buildTestServices(),
|
|
544
|
+
reason = () => registry.waitingReason('sw', [], undefined, undefined),
|
|
545
|
+
a = registerResource(registry, { name: 'sw-a', type: 'sw' }),
|
|
546
|
+
b = registerResource(registry, { name: 'sw-b', type: 'sw' });
|
|
547
|
+
registry.heartbeat(a.id, { state: 'idle' });
|
|
548
|
+
assert.equal(reason(), null); // sw-a can take it
|
|
549
|
+
|
|
550
|
+
registry.assignToJob(a.id, 'cli');
|
|
551
|
+
assert.equal(reason(), 'every matching SW Client is taken: sw-a (busy), sw-b (not connected yet)');
|
|
552
|
+
assert.equal(registry.waitingReason('hw', [], undefined, undefined), 'no HW Client matches this job\'s target');
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test('--env: the Client gets the values; the Agent API sees them masked; they\'re dropped when the job ends (not on a requeued LOST)', () => {
|
|
556
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
557
|
+
{ agent } = agents.create({ name: 'dev', kind: 'cli' }),
|
|
558
|
+
r = registerResource(registry, { name: 'sw-env', type: 'sw' }),
|
|
559
|
+
env = { DOCKER_REGISTRY: 'registry.lab:5000', DOCKER_USERNAME: 'ci', DOCKER_PASSWORD: 's3cret' };
|
|
560
|
+
registry.heartbeat(r.id, { state: 'idle' });
|
|
561
|
+
|
|
562
|
+
const job = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ env }) });
|
|
563
|
+
assert.deepEqual(jobs.get(job.id).spec.env, env); // what the Client is handed
|
|
564
|
+
assert.deepEqual(jobs.publicJob(jobs.get(job.id)).spec.env, { DOCKER_REGISTRY: '***', DOCKER_USERNAME: '***', DOCKER_PASSWORD: '***' });
|
|
543
565
|
|
|
544
|
-
const fileJob = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
545
566
|
scheduler.runPass();
|
|
546
|
-
|
|
567
|
+
jobs.markLost(job.id); // requeued: still needs them
|
|
568
|
+
assert.equal(jobs.get(job.id).state, JOB_STATES.QUEUED);
|
|
569
|
+
assert.deepEqual(jobs.get(job.id).spec.env, env);
|
|
570
|
+
|
|
571
|
+
jobs.cancel(job.id, { isAdmin: true });
|
|
572
|
+
assert.deepEqual(jobs.get(job.id).spec.env, { DOCKER_REGISTRY: '***', DOCKER_USERNAME: '***', DOCKER_PASSWORD: '***' });
|
|
547
573
|
});
|
|
548
574
|
|
|
549
575
|
test('every job carries its command and runs on any matching Client; an old-Agent spec is refused', () => {
|
|
@@ -618,29 +644,85 @@ test('reboot now: refused offline or without a helper; cancels a running job fir
|
|
|
618
644
|
assert.equal(after.message, 'Canceled: user reboot request');
|
|
619
645
|
});
|
|
620
646
|
|
|
621
|
-
test('
|
|
647
|
+
test('devices from the dashboard: reported hw-devices stored, saved at a new revision, resent until applied', () => {
|
|
622
648
|
const { registry } = buildTestServices(),
|
|
623
649
|
{ resourceId } = registry.registerAuto({
|
|
624
|
-
clientId: 'c-cfg', name: 'lab-cfg', type: '
|
|
625
|
-
config: {
|
|
650
|
+
clientId: 'c-cfg', name: 'lab-cfg', type: 'hw', labels: [], capabilities: { hw: { stlinks: [], uarts: [], usbs: [] } },
|
|
651
|
+
config: { stlinks: [], uarts: [{ index: 1 }], usbs: [] }
|
|
626
652
|
});
|
|
627
|
-
assert.deepEqual(registry.get(resourceId).client_config, {
|
|
653
|
+
assert.deepEqual(registry.get(resourceId).client_config, { stlinks: [], uarts: [{ index: 1 }], usbs: [] });
|
|
628
654
|
assert.equal(registry.pendingConfig(resourceId), null);
|
|
629
655
|
|
|
630
|
-
assert.throws(() => registry.setClientConfig(resourceId, {
|
|
631
|
-
const saved = registry.setClientConfig(resourceId, {
|
|
656
|
+
assert.throws(() => registry.setClientConfig(resourceId, { uarts: [{ path: '/tmp/x' }] }), /Invalid HW config: hw-devices\.uarts\.0\.path/);
|
|
657
|
+
const saved = registry.setClientConfig(resourceId, { uarts: [{ index: 2, baudRate: 9600 }] });
|
|
632
658
|
assert.equal(saved.config_revision, 1);
|
|
633
|
-
assert.deepEqual(registry.pendingConfig(resourceId), { revision: 1, type: '
|
|
659
|
+
assert.deepEqual(registry.pendingConfig(resourceId), { revision: 1, type: 'hw', config: { uarts: [{ index: 2, baudRate: 9600 }] } });
|
|
634
660
|
|
|
635
661
|
registry.heartbeat(resourceId, { state: 'idle' }); // an older Client: reports nothing
|
|
636
662
|
assert.equal(registry.pendingConfig(resourceId).revision, 1);
|
|
637
663
|
registry.heartbeat(resourceId, { state: 'idle', configRevision: 1 });
|
|
638
664
|
assert.equal(registry.pendingConfig(resourceId), null);
|
|
639
665
|
|
|
640
|
-
registry.setClientConfig(resourceId, {
|
|
666
|
+
registry.setClientConfig(resourceId, { usbs: [{ index: 1 }] });
|
|
641
667
|
registry.heartbeat(resourceId, { state: 'idle', configRevision: 2, configError: 'Config revision 2 refused: bad' });
|
|
642
668
|
assert.equal(registry.pendingConfig(resourceId), null); // refused ones aren't resent
|
|
643
669
|
assert.match(registry.get(resourceId).config_error, /refused: bad/);
|
|
670
|
+
|
|
671
|
+
// An SW Client has no settings: only an empty section is accepted.
|
|
672
|
+
const sw = registerResource(registry, { name: 'sw1', type: 'sw' });
|
|
673
|
+
assert.throws(() => registry.setClientConfig(sw.id, { image: 'emu' }), /SW Client has no settings of its own/);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test('config export/import: exported from the reported file (+ what\'s pending); an import goes out with set-config until applied', () => {
|
|
677
|
+
const { registry } = buildTestServices(),
|
|
678
|
+
{ resourceId } = registry.registerAuto({
|
|
679
|
+
clientId: 'c-io', name: 'dut1', type: 'hw', labels: [], capabilities: { hw: { stlinks: [], uarts: [], usbs: [] } },
|
|
680
|
+
config: { uarts: [{ index: 1 }] },
|
|
681
|
+
// An older Client: `hw`, plus legacy sections that never show up again.
|
|
682
|
+
configFile: { coordinatorUrl: 'https://c', name: 'dut1', joinKey: 'k', type: 'hw', labels: [], hw: { uarts: [{ index: 1 }] },
|
|
683
|
+
sw: { image: 'emu:1', registryAuth: { password: 'x' } }, artifactory: { token: 'secret', allowedArtifactPrefixes: [] } }
|
|
684
|
+
});
|
|
685
|
+
assert.deepEqual(registry.exportClientConfig(resourceId), {
|
|
686
|
+
coordinatorUrl: 'https://c', name: 'dut1', joinKey: 'k', type: 'hw', labels: [], 'hw-devices': { uarts: [{ index: 1 }] }
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const { resource, ignored } = registry.importClientConfig(resourceId, {
|
|
690
|
+
coordinatorUrl: 'https://evil', name: 'other', joinKey: 'x', type: 'hw', labels: ['board:b'], sources: { allowedPrefixes: ['*'] },
|
|
691
|
+
'hw-devices': { usbs: [{ index: 2 }] }
|
|
692
|
+
}, { by: 'admin' });
|
|
693
|
+
assert.deepEqual(ignored, ['sources', 'coordinatorUrl', 'name', 'joinKey']);
|
|
694
|
+
assert.equal(resource.config_revision, 1);
|
|
695
|
+
assert.deepEqual(registry.pendingConfig(resourceId),
|
|
696
|
+
{ revision: 1, type: 'hw', config: { usbs: [{ index: 2 }] }, file: { labels: ['board:b'] } });
|
|
697
|
+
const exported = registry.exportClientConfig(resourceId);
|
|
698
|
+
assert.deepEqual([exported.coordinatorUrl, exported.name, exported.labels, exported['hw-devices'], exported.hw],
|
|
699
|
+
['https://c', 'dut1', ['board:b'], { usbs: [{ index: 2 }] }, undefined]);
|
|
700
|
+
|
|
701
|
+
registry.setClientConfig(resourceId, { usbs: [{ index: 3 }] }); // a Config tab Save meanwhile: the import still goes with it
|
|
702
|
+
assert.deepEqual(registry.pendingConfig(resourceId).file, { labels: ['board:b'] });
|
|
703
|
+
registry.heartbeat(resourceId, { state: 'idle', configRevision: 2 });
|
|
704
|
+
assert.equal(registry.pendingConfig(resourceId), null);
|
|
705
|
+
|
|
706
|
+
assert.throws(() => registry.importClientConfig(resourceId, { type: 'sw' }), /SW Client config, but this is a HW Client/);
|
|
707
|
+
assert.throws(() => registry.importClientConfig(resourceId, { 'hw-devices': { uarts: [{ path: '/tmp/x' }] } }), /hw-devices\.uarts/);
|
|
708
|
+
|
|
709
|
+
// An SW Client: nothing of its own to import or export, the rest applies.
|
|
710
|
+
const sw = registry.registerAuto({ clientId: 'c-sw', name: 'sw1', type: 'sw', labels: [], capabilities: { sw: {} }, config: {},
|
|
711
|
+
configFile: { name: 'sw1', type: 'sw', labels: [] } }).resourceId;
|
|
712
|
+
assert.deepEqual(registry.importClientConfig(sw, { type: 'sw', labels: ['x'], sw: { image: 'emu' } }).ignored, ['sw']);
|
|
713
|
+
assert.deepEqual(registry.pendingConfig(sw), { revision: 1, type: 'sw', config: {}, file: { labels: ['x'] } });
|
|
714
|
+
assert.deepEqual(registry.exportClientConfig(sw), { name: 'sw1', type: 'sw', labels: ['x'] });
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test('HW capabilities: no power control — reported relays/power dropped, and refused on Save', () => {
|
|
718
|
+
const { registry } = buildTestServices(),
|
|
719
|
+
{ resourceId } = registry.registerAuto({
|
|
720
|
+
clientId: 'c-hw', name: 'hw1', type: 'hw', labels: [],
|
|
721
|
+
capabilities: { hw: { stlinks: [], uarts: [], usbs: [], relays: [{ channel: 0 }], power: { method: 'uhubctl', hub: '1-1', port: 2 } } }
|
|
722
|
+
});
|
|
723
|
+
assert.deepEqual(registry.get(resourceId).capabilities.hw, { stlinks: [], uarts: [], usbs: [] });
|
|
724
|
+
assert.throws(() => registry.setClientConfig(resourceId, { relays: [{ channel: 0 }] }), /must NOT have additional properties/);
|
|
725
|
+
assert.throws(() => registry.setClientConfig(resourceId, { power: null }), /must NOT have additional properties/);
|
|
644
726
|
});
|
|
645
727
|
|
|
646
728
|
test('rename from the dashboard: kept across re-registration, unique, and resettable to the Client\'s own name', () => {
|
package/views/index.pug
CHANGED
package/views/jobs/show.pug
CHANGED
|
@@ -19,6 +19,10 @@ block content
|
|
|
19
19
|
)
|
|
20
20
|
button.btn.btn-outline-danger.btn-sm(type="submit") Cancel
|
|
21
21
|
|
|
22
|
+
if waitingReason
|
|
23
|
+
.alert.alert-warning.py-2.small
|
|
24
|
+
i.bi.bi-hourglass-split.me-1
|
|
25
|
+
| Waiting: #{waitingReason}
|
|
22
26
|
dl.row
|
|
23
27
|
dt.col-sm-2 Source
|
|
24
28
|
dd.col-sm-10.text-uppercase= job.source
|
|
@@ -32,6 +36,10 @@ block content
|
|
|
32
36
|
if job.spec.target.client
|
|
33
37
|
dt.col-sm-2 Client
|
|
34
38
|
dd.col-sm-10= pinnedClient ? pinnedClient.name : job.spec.target.client
|
|
39
|
+
if job.spec.env && Object.keys(job.spec.env).length
|
|
40
|
+
dt.col-sm-2 Environment
|
|
41
|
+
dd.col-sm-10.font-monospace(data-bs-toggle="tooltip" data-bs-title="Set by --env; values are only given to the Client that runs the job")
|
|
42
|
+
= Object.keys(job.spec.env).join(', ')
|
|
35
43
|
dt.col-sm-2 Created
|
|
36
44
|
dd.col-sm-10= fmtDate(job.created_at)
|
|
37
45
|
if job.duration_sec != null
|
|
@@ -20,14 +20,6 @@ mixin deviceRow(kind, d)
|
|
|
20
20
|
button.btn.btn-sm.btn-outline-secondary(type="button" data-remove-row aria-label="Remove row")
|
|
21
21
|
i.bi.bi-x-lg
|
|
22
22
|
|
|
23
|
-
mixin relayRow(rl)
|
|
24
|
-
tr(data-row="relays")
|
|
25
|
-
td: input.form-control.form-control-sm(data-f="channel" type="number" min="0" max="7" value=rl ? rl.channel : '' required)
|
|
26
|
-
td: input.form-control.form-control-sm(data-f="baseUrl" type="url" value=rl && rl.baseUrl || '' placeholder="http://localhost:3000")
|
|
27
|
-
td.text-end
|
|
28
|
-
button.btn.btn-sm.btn-outline-secondary(type="button" data-remove-row aria-label="Remove row")
|
|
29
|
-
i.bi.bi-x-lg
|
|
30
|
-
|
|
31
23
|
mixin deviceTable(kind, title, rows, columns)
|
|
32
24
|
.d-flex.justify-content-between.align-items-center.mb-1
|
|
33
25
|
strong.small= title
|
|
@@ -42,15 +34,9 @@ mixin deviceTable(kind, title, rows, columns)
|
|
|
42
34
|
th
|
|
43
35
|
tbody
|
|
44
36
|
each d in rows
|
|
45
|
-
|
|
46
|
-
+relayRow(d)
|
|
47
|
-
else
|
|
48
|
-
+deviceRow(kind, d)
|
|
37
|
+
+deviceRow(kind, d)
|
|
49
38
|
template(data-template=kind)
|
|
50
|
-
|
|
51
|
-
+relayRow(null)
|
|
52
|
-
else
|
|
53
|
-
+deviceRow(kind, null)
|
|
39
|
+
+deviceRow(kind, null)
|
|
54
40
|
|
|
55
41
|
//- The ⚠ shown on a config (sub-)tab while its data differs from what was
|
|
56
42
|
//- loaded — public/js/client-config.js toggles it.
|
|
@@ -102,104 +88,55 @@ mixin capabilitiesStatus(r)
|
|
|
102
88
|
span.badge.text-bg-danger(data-bs-toggle="tooltip" data-bs-title=r.config_error) refused by the Client
|
|
103
89
|
|
|
104
90
|
//- The Client's config, as tabs of the resource card next to Details:
|
|
105
|
-
//- HW — ST-Link, UART, DUT USB
|
|
106
|
-
//-
|
|
107
|
-
//- share one form and one "Save capabilities"; a tab with
|
|
108
|
-
//- shows ⚠ (public/js/client-config.js).
|
|
91
|
+
//- HW — ST-Link, UART, DUT USB (its hw-devices section); both — Reboot (the
|
|
92
|
+
//- schedule, its own Save). An SW Client has no settings of its own. The
|
|
93
|
+
//- device tabs share one form and one "Save capabilities"; a tab with
|
|
94
|
+
//- unsaved changes shows ⚠ (public/js/client-config.js).
|
|
109
95
|
mixin configTabItems(r)
|
|
110
96
|
- const p = `cfg-${r.id}`
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
97
|
+
if r.type === 'hw'
|
|
98
|
+
each t in [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'DUT USB']]
|
|
99
|
+
+configTab(p, t[0], t[1])
|
|
114
100
|
+configTab(p, 'reboot', 'Reboot')
|
|
115
101
|
|
|
116
102
|
mixin configPanes(r, returnTo)
|
|
117
103
|
- const p = `cfg-${r.id}`
|
|
118
104
|
- const current = r.config_desired || r.client_config
|
|
119
105
|
- const canEdit = user.role === 'admin'
|
|
120
|
-
if
|
|
121
|
-
|
|
106
|
+
if r.type !== 'hw'
|
|
107
|
+
//- no device tabs
|
|
108
|
+
else if !current
|
|
109
|
+
each t in [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'DUT USB']]
|
|
122
110
|
+configPane(p, t[0])
|
|
123
|
-
p.small.text-body-secondary.mb-0 This Client is too old to report its config — update it to edit its
|
|
111
|
+
p.small.text-body-secondary.mb-0 This Client is too old to report its config — update it to edit its devices here.
|
|
124
112
|
else
|
|
125
113
|
form(
|
|
126
114
|
method="post"
|
|
127
115
|
action=`/resources/${r.id}/config`
|
|
128
|
-
data-client-config=
|
|
129
|
-
data-confirm=`Apply these
|
|
130
|
-
data-confirm-title="Save Client
|
|
116
|
+
data-client-config="hw"
|
|
117
|
+
data-confirm=`Apply these devices to ${r.name}? The Client writes them to its config file on its next heartbeat and restarts once it has no job running.`
|
|
118
|
+
data-confirm-title="Save Client devices?"
|
|
131
119
|
data-confirm-ok="Save"
|
|
132
120
|
data-confirm-tone="primary"
|
|
133
121
|
)
|
|
134
122
|
input(type="hidden" name="returnTo" value=returnTo)
|
|
135
123
|
input(type="hidden" name="config")
|
|
136
124
|
fieldset(disabled=!canEdit)
|
|
137
|
-
|
|
138
|
-
+
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
input.form-control.form-control-sm(data-f="memory" value=current.memory || '' placeholder="2g")
|
|
153
|
-
.col-sm-6
|
|
154
|
-
label.form-label.small.mb-0 Container command
|
|
155
|
-
input.form-control.form-control-sm.font-monospace(
|
|
156
|
-
data-f="cmd"
|
|
157
|
-
value=(current.cmd || []).join(' ')
|
|
158
|
-
placeholder="--firmware /downloads/app.bin"
|
|
159
|
-
title="For the image above (not a job's own); space-separated"
|
|
160
|
-
)
|
|
161
|
-
.col-12.d-flex.gap-4.mt-2
|
|
162
|
-
.form-check
|
|
163
|
-
input.form-check-input(type="checkbox" data-f="allowDockerHub" id=`cc-${r.id}-hub` checked=current.allowDockerHub === true)
|
|
164
|
-
label.form-check-label.small(for=`cc-${r.id}-hub`) Pull from Docker Hub
|
|
165
|
-
.form-check
|
|
166
|
-
input.form-check-input(type="checkbox" data-f="allowJobImages" id=`cc-${r.id}-jobimg` checked=current.allowJobImages === true)
|
|
167
|
-
label.form-check-label.small(for=`cc-${r.id}-jobimg`) Run jobs' own images (--docker-image)
|
|
168
|
-
+saveCapabilities(canEdit)
|
|
169
|
-
else
|
|
170
|
-
- const power = current.power || {}
|
|
171
|
-
+configPane(p, 'stlinks')
|
|
172
|
-
+capabilitiesStatus(r)
|
|
173
|
-
+deviceTable('stlinks', 'ST-Link probes', current.stlinks || [], ['Device', 'Serial', 'udev devpath'])
|
|
174
|
-
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-stlink) or a /dev path. The serial is looked up from the udev symlink when empty.
|
|
175
|
-
+saveCapabilities(canEdit)
|
|
176
|
-
+configPane(p, 'uarts')
|
|
177
|
-
+capabilitiesStatus(r)
|
|
178
|
-
+deviceTable('uarts', 'UARTs', current.uarts || [], ['Device', 'Baud rate', 'udev devpath'])
|
|
179
|
-
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-uart) or a /dev path. Baud rate: 115200 when empty.
|
|
180
|
-
+saveCapabilities(canEdit)
|
|
181
|
-
+configPane(p, 'usbs')
|
|
182
|
-
+capabilitiesStatus(r)
|
|
183
|
-
+deviceTable('usbs', 'DUT USB devices', current.usbs || [], ['Device', 'udev devpath'])
|
|
184
|
-
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-usb) or a /dev path. udev devpath: the USB port it's plugged into.
|
|
185
|
-
+saveCapabilities(canEdit)
|
|
186
|
-
+configPane(p, 'power')
|
|
187
|
-
+capabilitiesStatus(r)
|
|
188
|
-
+deviceTable('relays', 'Relay channels', current.relays || [], ['Channel (0-7)', 'Board URL'])
|
|
189
|
-
strong.small.d-block.mt-3.mb-1 Power control
|
|
190
|
-
.row.g-2(data-power)
|
|
191
|
-
.col-sm-3
|
|
192
|
-
select.form-select.form-select-sm(data-f="method" aria-label="Power method")
|
|
193
|
-
option(value="" selected=!power.method) none
|
|
194
|
-
option(value="uhubctl" selected=power.method === 'uhubctl') uhubctl
|
|
195
|
-
option(value="relay" selected=power.method === 'relay') relay
|
|
196
|
-
.col-sm-3
|
|
197
|
-
input.form-control.form-control-sm(data-f="hub" value=power.hub || '' placeholder="hub (uhubctl)")
|
|
198
|
-
.col-sm-2
|
|
199
|
-
input.form-control.form-control-sm(data-f="port" type="number" min="1" value=power.port != null ? power.port : '' placeholder="port")
|
|
200
|
-
.col-sm-4
|
|
201
|
-
input.form-control.form-control-sm(data-f="baseUrl" type="url" value=power.baseUrl || '' placeholder="relay URL")
|
|
202
|
-
+saveCapabilities(canEdit)
|
|
125
|
+
+configPane(p, 'stlinks')
|
|
126
|
+
+capabilitiesStatus(r)
|
|
127
|
+
+deviceTable('stlinks', 'ST-Link probes', current.stlinks || [], ['Device', 'Serial', 'udev devpath'])
|
|
128
|
+
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-stlink) or a /dev path. The serial is looked up from the udev symlink when empty.
|
|
129
|
+
+saveCapabilities(canEdit)
|
|
130
|
+
+configPane(p, 'uarts')
|
|
131
|
+
+capabilitiesStatus(r)
|
|
132
|
+
+deviceTable('uarts', 'UARTs', current.uarts || [], ['Device', 'Baud rate', 'udev devpath'])
|
|
133
|
+
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-uart) or a /dev path. Baud rate: 115200 when empty.
|
|
134
|
+
+saveCapabilities(canEdit)
|
|
135
|
+
+configPane(p, 'usbs')
|
|
136
|
+
+capabilitiesStatus(r)
|
|
137
|
+
+deviceTable('usbs', 'DUT USB devices', current.usbs || [], ['Device', 'udev devpath'])
|
|
138
|
+
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-usb) or a /dev path. udev devpath: the USB port it's plugged into.
|
|
139
|
+
+saveCapabilities(canEdit)
|
|
203
140
|
+configPane(p, 'reboot')
|
|
204
141
|
dl.row.mb-0
|
|
205
142
|
+rebootScheduleRow(r, returnTo)
|
|
@@ -133,47 +133,23 @@ mixin capDevice(d, detail)
|
|
|
133
133
|
) missing
|
|
134
134
|
|
|
135
135
|
//- What the Client reported it can drive at registration (HW: udev
|
|
136
|
-
//- devices
|
|
136
|
+
//- devices; SW: nothing of its own — it runs each job's DUT image).
|
|
137
137
|
mixin capabilitiesList(caps)
|
|
138
138
|
if !caps
|
|
139
139
|
span.text-body-secondary —
|
|
140
140
|
else if caps.sw
|
|
141
|
-
|
|
142
|
-
li
|
|
143
|
-
span.text-body-secondary.small.me-2 Image
|
|
144
|
-
code= caps.sw.image || '—'
|
|
145
|
-
li
|
|
146
|
-
span.text-body-secondary.small.me-2 Source
|
|
147
|
-
= caps.sw.registry ? caps.sw.registry : 'no local registry'
|
|
148
|
-
= caps.sw.allowDockerHub ? ', then Docker Hub' : ' (Docker Hub off)'
|
|
149
|
-
li
|
|
150
|
-
span.text-body-secondary.small.me-2 Limits
|
|
151
|
-
= `${caps.sw.cpus || '—'} CPU, ${caps.sw.memory || '—'}`
|
|
141
|
+
span.text-body-secondary Runs each job's own DUT image (--docker-image), 2 CPUs / 2 GB
|
|
152
142
|
else if caps.hw
|
|
153
143
|
- const kinds = [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'USB']]
|
|
154
|
-
- const empty = kinds.every(([k]) => !caps.hw[k]
|
|
144
|
+
- const empty = kinds.every(([k]) => !(caps.hw[k] || []).length)
|
|
155
145
|
if empty
|
|
156
146
|
span.text-body-secondary no devices configured
|
|
157
147
|
each pair in kinds
|
|
158
|
-
if caps.hw[pair[0]].length
|
|
148
|
+
if (caps.hw[pair[0]] || []).length
|
|
159
149
|
.small.text-body-secondary.mt-1= pair[1]
|
|
160
150
|
ul.list-unstyled.mb-0.ms-2
|
|
161
151
|
each d in caps.hw[pair[0]]
|
|
162
152
|
+capDevice(d, pair[0] === 'uarts' && d.baudRate ? `${d.baudRate} baud` : null)
|
|
163
|
-
if caps.hw.relays.length
|
|
164
|
-
.small.text-body-secondary.mt-1 Relays
|
|
165
|
-
ul.list-unstyled.mb-0.ms-2
|
|
166
|
-
each rl in caps.hw.relays
|
|
167
|
-
li
|
|
168
|
-
= `channel ${rl.channel}`
|
|
169
|
-
if rl.baseUrl
|
|
170
|
-
code.ms-2= rl.baseUrl
|
|
171
|
-
if caps.hw.power
|
|
172
|
-
.small.text-body-secondary.mt-1 Power
|
|
173
|
-
.ms-2
|
|
174
|
-
= caps.hw.power.method
|
|
175
|
-
if caps.hw.power.method === 'uhubctl' && caps.hw.power.hub
|
|
176
|
-
span.text-body-secondary.small.ms-2= `hub ${caps.hw.power.hub} port ${caps.hw.power.port}`
|
|
177
153
|
|
|
178
154
|
//- Host uptime and the Client's current task/status with its duration,
|
|
179
155
|
//- both from the last heartbeat. Measured up to now — or, for a Client
|
|
@@ -417,6 +393,47 @@ mixin rebootButton(r, returnTo)
|
|
|
417
393
|
)
|
|
418
394
|
i.bi.bi-power
|
|
419
395
|
|
|
396
|
+
//- Export / Import the Client's config file as JSON (card's Actions row,
|
|
397
|
+
//- icons only). Import: public/js/config-import.js reads the chosen file
|
|
398
|
+
//- into the form and submits it (confirmed first); the Client applies it on
|
|
399
|
+
//- its next heartbeat. A Client that doesn't report its config file is too
|
|
400
|
+
//- old to apply more than the hw/sw section — Import is off for it.
|
|
401
|
+
mixin configTransferButtons(r, returnTo)
|
|
402
|
+
a.btn.btn-sm.btn-outline-secondary.ms-1(
|
|
403
|
+
href=`/resources/${r.id}/config/export`
|
|
404
|
+
download
|
|
405
|
+
aria-label=`Export ${r.name}'s config`
|
|
406
|
+
data-bs-toggle="tooltip"
|
|
407
|
+
data-bs-title="Export the Client's config file (JSON; its secrets left out)"
|
|
408
|
+
)
|
|
409
|
+
i.bi.bi-download
|
|
410
|
+
if !r.client_config_file
|
|
411
|
+
span.d-inline-block.ms-1(tabindex="0" data-bs-toggle="tooltip" data-bs-title="Import unavailable: this Client is too old to report its config file — update it")
|
|
412
|
+
button.btn.btn-sm.btn-outline-secondary(type="button" disabled style="pointer-events: none;" aria-label="Import config")
|
|
413
|
+
i.bi.bi-upload
|
|
414
|
+
else
|
|
415
|
+
form.d-inline.ms-1(
|
|
416
|
+
method="post"
|
|
417
|
+
action=`/resources/${r.id}/config/import`
|
|
418
|
+
data-config-import
|
|
419
|
+
data-resource-name=r.name
|
|
420
|
+
data-confirm-title=`Import config into ${r.name}?`
|
|
421
|
+
data-confirm-ok="Import"
|
|
422
|
+
data-confirm-tone="primary"
|
|
423
|
+
)
|
|
424
|
+
input(type="hidden" name="returnTo" value=returnTo)
|
|
425
|
+
input(type="hidden" name="file")
|
|
426
|
+
input(type="hidden" name="fileName")
|
|
427
|
+
input.d-none(type="file" accept=".json,application/json" data-config-import-file)
|
|
428
|
+
button.btn.btn-sm.btn-outline-secondary(
|
|
429
|
+
type="button"
|
|
430
|
+
aria-label=`Import a config into ${r.name}`
|
|
431
|
+
data-config-import-pick
|
|
432
|
+
data-bs-toggle="tooltip"
|
|
433
|
+
data-bs-title="Import a config file (JSON) — joinKey, coordinatorUrl and name in it are ignored"
|
|
434
|
+
)
|
|
435
|
+
i.bi.bi-upload
|
|
436
|
+
|
|
420
437
|
//- The resource card's Details tab.
|
|
421
438
|
mixin resourceDetails(r, groupsById, returnTo, local, external)
|
|
422
439
|
dl.row.mb-0
|
|
@@ -500,3 +517,4 @@ mixin resourceDetails(r, groupsById, returnTo, local, external)
|
|
|
500
517
|
dd.col-sm-8.mb-0
|
|
501
518
|
+resourceActions(r, returnTo)
|
|
502
519
|
+rebootButton(r, returnTo)
|
|
520
|
+
+configTransferButtons(r, returnTo)
|
package/views/resources/list.pug
CHANGED