@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 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, relay channels and power control; 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).
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.31",
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.26",
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
- * the Client's hw/sw config section (JSON) on Save
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);
@@ -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
- const onLog = (entry) => {
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;
@@ -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 "config file": the hw/sw section, as a real Client reports it.
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
@@ -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
- // A job-supplied Docker image needs an SW Client that opted in — say so,
153
- // rather than the generic "no resource can satisfy".
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,
@@ -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 } = require('@andrian.yablonskyy/thub-common'),
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
- const sw = caps.sw;
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
- function registerAuto({ config, ...registration }){
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
- // `needs`: what a job brings that a Client must have opted in to run —
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 saved one.
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
- return { revision: r.config_revision, type: r.type, config: r.config_desired };
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, needs){
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, needs));
702
- }
703
-
704
- function everSatisfiable(type, labels, groupId, resourceId, needs){
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, needs));
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, jobNeeds(job.spec))
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
@@ -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 only goes to SW Clients that allow it; otherwise 422', () => {
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
- imageJob = { target: { type: 'sw', labels: [] }, image: 'alpine', command: './run.sh' },
531
- strict = registerResource(registry, { name: 'sw-strict', type: 'sw' });
532
- registry.heartbeat(strict.id, { state: 'idle' });
533
-
534
- assert.throws(() => jobs.create({ agentId: agent.id, source: 'cli', spec: imageJob }), /allowJobImages/);
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-open', name: 'sw-open', type: 'sw', labels: [], capabilities: { sw: { image: 'emu', allowJobImages: true } }
537
+ clientId: 'c-old', name: 'sw-old', type: 'sw', labels: [], capabilities: { sw: { image: 'emu', allowJobImages: false } }
538
538
  });
539
- registry.heartbeat(resourceId, { state: 'idle' });
540
- const job = jobs.create({ agentId: agent.id, source: 'cli', spec: imageJob });
541
- scheduler.runPass();
542
- assert.equal(jobs.get(job.id).resource_id, resourceId); // not sw-strict, though it's idle too
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
- assert.equal(jobs.get(fileJob.id).resource_id, strict.id); // jobs without an image still go anywhere
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('capabilities from the dashboard: reported config stored, saved at a new revision, resent until applied', () => {
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: 'sw', labels: [], capabilities: { sw: { image: 'emu' } },
625
- config: { image: 'emu:1', cpus: 2, memory: '2g' }
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, { image: 'emu:1', cpus: 2, memory: '2g' });
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, { memory: 'lots' }), /Invalid SW config: sw\.memory/);
631
- const saved = registry.setClientConfig(resourceId, { image: 'emu:2', cpus: 1, memory: '1g', allowJobImages: true });
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: 'sw', config: { image: 'emu:2', cpus: 1, memory: '1g', allowJobImages: true } });
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, { image: 'emu:3' });
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
@@ -51,3 +51,4 @@ block scripts
51
51
  script(src="/js/usb-scan.js")
52
52
  script(src="/js/usb-parse.js")
53
53
  script(src="/js/usb-import.js")
54
+ script(src="/js/config-import.js")
@@ -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
- if kind === 'relays'
46
- +relayRow(d)
47
- else
48
- +deviceRow(kind, d)
37
+ +deviceRow(kind, d)
49
38
  template(data-template=kind)
50
- if kind === 'relays'
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, Power (relays + power control); SW —
106
- //- Emulator; both — Reboot (the schedule, its own Save). The capability tabs
107
- //- share one form and one "Save capabilities"; a tab with unsaved changes
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
- - const tabs = r.type === 'hw' ? [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'DUT USB'], ['power', 'Power']] : [['emulator', 'Emulator']]
112
- each t in tabs
113
- +configTab(p, t[0], t[1])
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 !current
121
- each t in (r.type === 'hw' ? [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'DUT USB'], ['power', 'Power']] : [['emulator', 'Emulator']])
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 capabilities here.
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=r.type
129
- data-confirm=`Apply these capabilities to ${r.name}? The Client writes them to its config file on its next heartbeat and restarts once it has no job running.`
130
- data-confirm-title="Save Client capabilities?"
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
- if r.type === 'sw'
138
- +configPane(p, 'emulator')
139
- +capabilitiesStatus(r)
140
- .row.g-2
141
- .col-sm-6
142
- label.form-label.small.mb-0 Image
143
- input.form-control.form-control-sm.font-monospace(data-f="image" value=current.image || '' placeholder="dut-emulator:2026.08")
144
- .col-sm-6
145
- label.form-label.small.mb-0 Registry
146
- input.form-control.form-control-sm.font-monospace(data-f="registry" value=current.registry || '' placeholder="registry.lab:5000")
147
- .col-sm-3
148
- label.form-label.small.mb-0 CPUs
149
- input.form-control.form-control-sm(data-f="cpus" type="number" step="0.1" min="0.1" value=current.cpus != null ? current.cpus : '' placeholder="2")
150
- .col-sm-3
151
- label.form-label.small.mb-0 Memory
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&lt;N&gt;-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&lt;N&gt;-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&lt;N&gt;-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&lt;N&gt;-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&lt;N&gt;-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&lt;N&gt;-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, relays, power; SW: emulator image and limits).
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
- ul.list-unstyled.mb-0
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].length) && !caps.hw.relays.length && !caps.hw.power
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)
@@ -64,3 +64,4 @@ block scripts
64
64
  script(src="/js/usb-scan.js")
65
65
  script(src="/js/usb-parse.js")
66
66
  script(src="/js/usb-import.js")
67
+ script(src="/js/config-import.js")