@andrian.yablonskyy/thub-coordinator 1.1.4 → 1.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
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": {
@@ -144,8 +144,10 @@
144
144
  close.focus();
145
145
  }
146
146
 
147
- document.querySelectorAll('[data-usb-import]').forEach((button) => {
148
- button.addEventListener('click', () => {
147
+ // At the document: the button is swapped by live updates (public/js/live.js).
148
+ document.addEventListener('click', (e) => {
149
+ const button = e.target.closest('[data-usb-import]');
150
+ if (button && !button.disabled){
149
151
  const pane = button.closest('[data-usb-scan]'),
150
152
  modal = button.closest('.modal'),
151
153
  form = modal.querySelector('form[data-client-config="hw"]');
@@ -164,6 +166,6 @@
164
166
  bootstrap.Tab.getOrCreateInstance(root.querySelector(`[data-config-tab="${changed}"]`)).show();
165
167
  }
166
168
  overlay(modal, { total, skipped, changes });
167
- });
169
+ }
168
170
  });
169
171
  })();
@@ -18,82 +18,107 @@
18
18
  const POLL_MS = 2000,
19
19
  // The Client answers on its next heartbeat (10 s by default); no answer
20
20
  // by then means it's too old to know scan-usb, or went away.
21
- GIVE_UP_MS = 60_000;
21
+ GIVE_UP_MS = 60_000,
22
+ REFRESH_TIP = 'Run lsusb -tvv on the Client now — it answers on its next heartbeat',
23
+ WAITING_TIP = 'Waiting for the Client to run lsusb -tvv (its next heartbeat)',
24
+ // The buttons are swapped by live updates (public/js/live.js), so
25
+ // they're looked up when needed and clicks are handled at the document.
26
+ timers = new WeakMap(),
27
+ refreshButton = (pane) => pane.querySelector('[data-usb-refresh-wrap] button');
22
28
 
23
- document.querySelectorAll('[data-usb-scan]').forEach((pane) => {
24
- const url = pane.dataset.usbScan,
25
- button = pane.querySelector('[data-usb-refresh]'),
26
- status = pane.querySelector('[data-usb-status]'),
27
- errorBox = pane.querySelector('[data-usb-error]'),
28
- output = pane.querySelector('[data-usb-output]');
29
- let timer = null;
30
-
31
- function show(view){
32
- if (view.scan){
33
- status.textContent = `lsusb -tvv at ${view.scan.atText}`;
34
- errorBox.textContent = view.scan.error || '';
35
- errorBox.classList.toggle('d-none', !view.scan.error);
36
- output.textContent = view.scan.output || '';
37
- output.classList.toggle('d-none', !view.scan.output);
38
- const importButton = pane.querySelector('[data-usb-import]');
39
- if (importButton){
40
- importButton.disabled = !view.scan.output;
41
- }
42
- }
29
+ // Enable or disable a button in its tooltip wrapper (which says why it's
30
+ // disabled, or what it does).
31
+ function setEnabled(wrap, on, tip){
32
+ const button = wrap?.querySelector('button');
33
+ if (!button){
34
+ return;
35
+ }
36
+ button.disabled = !on;
37
+ button.style.pointerEvents = on ? '' : 'none';
38
+ wrap.tabIndex = on ? -1 : 0;
39
+ if (tip && wrap.dataset.bsTitle !== tip){
40
+ bootstrap.Tooltip.getInstance(wrap)?.dispose();
41
+ wrap.dataset.bsTitle = tip;
43
42
  }
43
+ }
44
44
 
45
- function waiting(on){
46
- if (button){
47
- button.disabled = on;
48
- button.querySelector('i').className = on ? 'spinner-border spinner-border-sm me-1' : 'bi bi-arrow-clockwise me-1';
49
- }
50
- if (on){
51
- status.textContent = 'Waiting for the Client to run lsusb -tvv (its next heartbeat)…';
52
- }
45
+ function show(pane, view){
46
+ if (!view.scan){
47
+ return;
53
48
  }
49
+ const status = pane.querySelector('[data-usb-status]'),
50
+ errorBox = pane.querySelector('[data-usb-error]'),
51
+ output = pane.querySelector('[data-usb-output]'),
52
+ importWrap = pane.querySelector('[data-usb-import-wrap]');
53
+ status.textContent = `lsusb -tvv at ${view.scan.atText}`;
54
+ errorBox.textContent = view.scan.error || '';
55
+ errorBox.classList.toggle('d-none', !view.scan.error);
56
+ output.textContent = view.scan.output || '';
57
+ output.classList.toggle('d-none', !view.scan.output);
58
+ if (importWrap?.dataset.tipEnabled){ // an HW Client that reports its config
59
+ setEnabled(importWrap, Boolean(view.scan.output), view.scan.output ? importWrap.dataset.tipEnabled : null);
60
+ }
61
+ }
54
62
 
55
- function poll(requestedAt){
56
- clearTimeout(timer);
57
- timer = setTimeout(async () => {
58
- try {
59
- const view = await (await fetch(url, { cache: 'no-store', headers: { Accept: 'application/json' } })).json();
60
- if (!view.pending && view.scan && view.scan.at >= requestedAt){
61
- waiting(false);
62
- return show(view);
63
- }
64
- }
65
- catch {
66
- // network blip: keep polling
67
- }
68
- if (Date.now() - Date.parse(requestedAt) > GIVE_UP_MS){
69
- waiting(false);
70
- status.textContent = 'No answer from the Client — it may be too old to scan USB devices, or offline. Try again later.';
71
- return;
72
- }
73
- poll(requestedAt);
74
- }, POLL_MS);
63
+ function waiting(pane, on){
64
+ const button = refreshButton(pane);
65
+ if (button){
66
+ setEnabled(button.parentElement, !on, on ? WAITING_TIP : REFRESH_TIP);
67
+ button.querySelector('i').className = on ? 'spinner-border spinner-border-sm me-1' : 'bi bi-arrow-clockwise me-1';
68
+ }
69
+ if (on){
70
+ pane.querySelector('[data-usb-status]').textContent = `${WAITING_TIP}…`;
75
71
  }
72
+ }
76
73
 
77
- button?.addEventListener('click', async () => {
78
- waiting(true);
74
+ function poll(pane, requestedAt){
75
+ clearTimeout(timers.get(pane));
76
+ timers.set(pane, setTimeout(async () => {
79
77
  try {
80
- const res = await fetch(url, { method: 'POST', headers: { Accept: 'application/json' } }),
81
- view = await res.json();
82
- if (!res.ok){
83
- throw new Error(view.error || `HTTP ${res.status}`);
78
+ const view = await (await fetch(pane.dataset.usbScan, { cache: 'no-store', headers: { Accept: 'application/json' } })).json();
79
+ if (!view.pending && view.scan && view.scan.at >= requestedAt){
80
+ waiting(pane, false);
81
+ return show(pane, view);
84
82
  }
85
- poll(view.requestedAt);
86
83
  }
87
- catch (err){
88
- waiting(false);
89
- status.textContent = `Couldn't ask the Client: ${err.message}`;
84
+ catch {
85
+ // network blip: keep polling
90
86
  }
91
- });
87
+ if (Date.now() - Date.parse(requestedAt) > GIVE_UP_MS){
88
+ waiting(pane, false);
89
+ pane.querySelector('[data-usb-status]').textContent = 'No answer from the Client — it may be too old to scan USB devices, or offline. Try again later.';
90
+ return;
91
+ }
92
+ poll(pane, requestedAt);
93
+ }, POLL_MS));
94
+ }
92
95
 
93
- // A request still pending when the page loaded (e.g. after a reload).
96
+ document.addEventListener('click', async (e) => {
97
+ const button = e.target.closest('[data-usb-refresh]'),
98
+ pane = button?.closest('[data-usb-scan]');
99
+ if (!pane || button.disabled){
100
+ return;
101
+ }
102
+ waiting(pane, true);
103
+ try {
104
+ const res = await fetch(pane.dataset.usbScan, { method: 'POST', headers: { Accept: 'application/json' } }),
105
+ view = await res.json();
106
+ if (!res.ok){
107
+ throw new Error(view.error || `HTTP ${res.status}`);
108
+ }
109
+ poll(pane, view.requestedAt);
110
+ }
111
+ catch (err){
112
+ waiting(pane, false);
113
+ pane.querySelector('[data-usb-status]').textContent = `Couldn't ask the Client: ${err.message}`;
114
+ }
115
+ });
116
+
117
+ // A request still pending when the page loaded (e.g. after a reload).
118
+ document.querySelectorAll('[data-usb-scan]').forEach((pane) => {
94
119
  if (pane.dataset.pending && pane.dataset.requestedAt){
95
- waiting(true);
96
- poll(pane.dataset.requestedAt);
120
+ waiting(pane, true);
121
+ poll(pane, pane.dataset.requestedAt);
97
122
  }
98
123
  });
99
124
  })();
@@ -87,6 +87,18 @@ function createResourceRouter({ services, config }){
87
87
  res.status(stored ? 204 : 409).end();
88
88
  });
89
89
 
90
+ // A Client that doesn't re-register on start (no joinKey) reports its
91
+ // config here instead, so Export, Import and the config tabs work for it.
92
+ router.post('/resources/:id/config-report', auth, requireOwnResource, (req, res, next) => {
93
+ try {
94
+ services.registry.reportClientConfig(req.params.id, { config: req.body?.config, configFile: req.body?.configFile });
95
+ res.status(204).end();
96
+ }
97
+ catch (err){
98
+ next(err);
99
+ }
100
+ });
101
+
90
102
  router.post('/resources/:id/heartbeat', auth, requireOwnResource, (req, res, next) => {
91
103
  try {
92
104
  const { state, activeJobId, localLock, metrics, addresses, hostUptimeSec, activity, rebootSchedule, configRevision, configError } = req.body;
@@ -0,0 +1,4 @@
1
+ -- A Client's reported config file is kept for Export (README §10), but
2
+ -- never its credentials: drop the joinKey copies stored before that.
3
+ UPDATE resources SET client_config_file = json_remove(client_config_file, '$.joinKey')
4
+ WHERE client_config_file IS NOT NULL AND json_extract(client_config_file, '$.joinKey') IS NOT NULL;
@@ -18,11 +18,36 @@ const { v4: uuid } = require('uuid'),
18
18
  shareableClientConfigFile, HW_DEVICES_SECTION } = require('@andrian.yablonskyy/thub-common'),
19
19
  { generateToken, hashToken } = require('./tokens');
20
20
 
21
+ // Never kept from or put in a Client's config file: its credentials.
22
+ const CONFIG_FILE_SECRETS = ['joinKey'],
23
+ // The first Client that applies a whole config file (an Import's other
24
+ // fields, the Groups tab), not only its hw-devices section.
25
+ CONFIG_FILE_CLIENT = '1.0.30';
26
+
27
+ function withoutSecrets(file){
28
+ const out = shareableClientConfigFile(file);
29
+ for (const key of CONFIG_FILE_SECRETS){
30
+ delete out[key];
31
+ }
32
+ return out;
33
+ }
34
+
35
+ // What a Client can take from the dashboard's Import: 'file' — a whole
36
+ // config file (it reports its file, or is new enough to apply one even if
37
+ // it never has, e.g. without a joinKey it doesn't re-register on start);
38
+ // 'section' — an older HW Client's devices only; null — nothing.
39
+ function configSupport(r){
40
+ if (r.client_config_file || (r.client_version && compareVersions(r.client_version, CONFIG_FILE_CLIENT) >= 0)){
41
+ return 'file';
42
+ }
43
+ return r.type === 'hw' && (r.client_config || r.config_desired) ? 'section' : null;
44
+ }
45
+
21
46
  function rowToResource(row){
22
47
  if (!row){
23
48
  return row;
24
49
  }
25
- return {
50
+ const r = {
26
51
  ...row,
27
52
  labels: JSON.parse(row.labels || '[]'),
28
53
  group_ids: JSON.parse(row.group_ids || '[]'),
@@ -35,6 +60,8 @@ function rowToResource(row){
35
60
  usb_scan: row.usb_scan ? JSON.parse(row.usb_scan) : null,
36
61
  activity: row.activity ? JSON.parse(row.activity) : null
37
62
  };
63
+ r.config_support = configSupport(r);
64
+ return r;
38
65
  }
39
66
 
40
67
  // host_info.addresses comes straight from the Client, so keep only
@@ -198,19 +225,36 @@ function createRegistryService(db, { bus, events }){
198
225
  const known = registration.clientId ? getByClientId(registration.clientId) : null,
199
226
  result = registerAutoInner({ ...registration, name: known?.name_override || registration.name });
200
227
  db.prepare('UPDATE resources SET reported_name = ? WHERE id = ?').run(registration.name, result.resourceId);
201
- if (config && typeof config === 'object' && !Array.isArray(config)){
228
+ storeConfigReport(result.resourceId, { config, configFile });
229
+ return result;
230
+ }
231
+
232
+ // The Client's config as it runs it: its hw-devices section (`config`)
233
+ // and its whole file (`configFile`, credentials dropped). Sent with every
234
+ // registration, and on start by a Client that doesn't re-register (no
235
+ // joinKey) — POST /resources/:id/config-report.
236
+ function storeConfigReport(resourceId, { config, configFile }){
237
+ const isObject = (v) => v && typeof v === 'object' && !Array.isArray(v);
238
+ if (isObject(config)){
202
239
  const json = JSON.stringify(config);
203
240
  if (json.length <= 64 * 1024){
204
- db.prepare('UPDATE resources SET client_config = ? WHERE id = ?').run(json, result.resourceId);
241
+ db.prepare('UPDATE resources SET client_config = ? WHERE id = ?').run(json, resourceId);
205
242
  }
206
243
  }
207
- if (configFile && typeof configFile === 'object' && !Array.isArray(configFile)){
208
- const json = JSON.stringify(shareableClientConfigFile(configFile));
244
+ if (isObject(configFile)){
245
+ const json = JSON.stringify(withoutSecrets(configFile));
209
246
  if (json.length <= 64 * 1024){
210
- db.prepare('UPDATE resources SET client_config_file = ? WHERE id = ?').run(json, result.resourceId);
247
+ db.prepare('UPDATE resources SET client_config_file = ? WHERE id = ?').run(json, resourceId);
211
248
  }
212
249
  }
213
- return result;
250
+ }
251
+
252
+ function reportClientConfig(resourceId, report){
253
+ if (!get(resourceId)){
254
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
255
+ }
256
+ storeConfigReport(resourceId, report || {});
257
+ return get(resourceId);
214
258
  }
215
259
 
216
260
  function registerAutoInner({ clientId, name, type, labels = [], groups = [], hostInfo, capabilities, remoteAddr, clientVersion = null }){
@@ -691,18 +735,30 @@ function createRegistryService(db, { bus, events }){
691
735
  if (!r){
692
736
  throw Object.assign(new Error('Unknown resource'), { status: 404 });
693
737
  }
694
- const { valid, errors, section, fields, ignored } = importClientConfigFile(r.type, file);
738
+ if (!r.config_support){
739
+ throw Object.assign(new Error(`Can't import a config into ${r.name}: its Client is too old to apply one — update it first`), { status: 409 });
740
+ }
741
+ const { valid, errors, section, fields: fileFields, ignored } = importClientConfigFile(r.type, file);
695
742
  if (!valid){
696
743
  throw Object.assign(new Error(`Can't import this config into ${r.name}: ${errors.join('; ')}`), { status: 400 });
697
744
  }
745
+ // An older HW Client applies its devices only: the rest is listed as
746
+ // ignored rather than sent to be dropped.
747
+ let fields = fileFields;
748
+ if (r.config_support === 'section'){
749
+ ignored.push(...Object.keys(fileFields).map((k) => `${k} (this Client applies only its devices — update it for the rest)`));
750
+ fields = {};
751
+ }
698
752
  const desired = section || currentSection(r);
699
753
  if (!desired){
700
754
  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`),
701
755
  { status: 400 });
702
756
  }
703
- const revision = r.config_revision + 1;
757
+ const revision = r.config_revision + 1,
758
+ // An older Client gets no `file` at all: it wouldn't apply one.
759
+ importJson = r.config_support === 'file' ? JSON.stringify({ revision, fields }) : null;
704
760
  db.prepare(`UPDATE resources SET config_desired = ?, config_revision = ?, config_error = NULL, config_import = ?
705
- WHERE id = ?`).run(JSON.stringify(desired), revision, JSON.stringify({ revision, fields }), resourceId);
761
+ WHERE id = ?`).run(JSON.stringify(desired), revision, importJson, resourceId);
706
762
  events.record('resource', resourceId, 'resource.config_imported', { by, revision, fields: Object.keys(fields), ignored });
707
763
  return { resource: get(resourceId), ignored };
708
764
  }
@@ -726,7 +782,7 @@ function createRegistryService(db, { bus, events }){
726
782
  // Writing top-level fields needs a Client that reports its config file
727
783
  // (the same that Import needs); an older one would drop them.
728
784
  const desired = currentSection(r);
729
- if (!desired || !r.client_config_file){
785
+ if (!desired || r.config_support !== 'file'){
730
786
  throw Object.assign(new Error(`${r.name} is too old to take its groups from the dashboard — update its Client first`), { status: 409 });
731
787
  }
732
788
  const revision = r.config_revision + 1,
@@ -750,7 +806,7 @@ function createRegistryService(db, { bus, events }){
750
806
  throw Object.assign(new Error('Unknown resource'), { status: 404 });
751
807
  }
752
808
  // Re-shared: a file stored from an older Client may still say hw / sw.
753
- const base = shareableClientConfigFile(r.client_config_file || { name: r.reported_name || r.name, type: r.type, labels: r.labels }),
809
+ const base = withoutSecrets(r.client_config_file || { name: r.reported_name || r.name, type: r.type, labels: r.labels, groups: r.group_ids }),
754
810
  section = r.type === 'hw' ? currentSection(r) : null;
755
811
  return { ...base, ...(pendingImport(r) || {}), type: r.type, ...(section ? { [HW_DEVICES_SECTION]: section } : {}) };
756
812
  }
@@ -821,6 +877,7 @@ function createRegistryService(db, { bus, events }){
821
877
 
822
878
  return {
823
879
  get,
880
+ reportClientConfig,
824
881
  getByName,
825
882
  getByClientId,
826
883
  getByTokenHash,
package/src/web/routes.js CHANGED
@@ -205,6 +205,9 @@ function createWebRouter({ services, config }){
205
205
  // dd/mm/yyyy HH:MM:SS, 24-hour (thub-common formatDateTime) — the one
206
206
  // format for every date/time label.
207
207
  res.locals.fmtDate = (iso, fallback = '—') => formatDateTime(iso, { timeZone: tz, fallback });
208
+ // Why a Client's config can't be edited or imported on its card
209
+ // (registry.js configSupport, README §10).
210
+ res.locals.noConfigReport = 'this Client is too old to report or apply its config from the dashboard — update it.';
208
211
  res.locals.userTimeZone = tz;
209
212
 
210
213
  // Idle timeout (§10.1): re-applied on every request (not just login)
@@ -140,3 +140,61 @@ test('ST-Link tab has no Serial field; a serial set in the config file is kept o
140
140
  // Rides along in data-extra, which public/js/client-config.js spreads back into the row.
141
141
  assert.match(table, /data-extra="\{&quot;serial&quot;:&quot;066DFF48&quot;\}"/);
142
142
  });
143
+
144
+ test('resource card: USB actions update live and every disabled button says why', async (t) => {
145
+ const { base, services, admin } = await start(t),
146
+ reg = services.registry.registerAuto({
147
+ clientId: 'hw3', name: 'lab-hw-03', type: 'hw', labels: [], config: { stlinks: [{ index: 1 }] }, configFile: { name: 'lab-hw-03', type: 'hw' }
148
+ }),
149
+ card = async () => {
150
+ const html = await (await fetch(`${base}/resources`, { headers: { cookie: admin } })).text();
151
+ return html.slice(html.indexOf(`id="resource-card-${reg.resourceId}"`));
152
+ };
153
+ let html = await card();
154
+ // A live region, so a Client coming back online gets Refresh back without a reload.
155
+ assert.match(html, new RegExp(`data-live="rc-usb-actions-${reg.resourceId}"`));
156
+ assert.match(html, /"Offline — a Client can only be scanned while it(&#39;|.)s connected" data-usb-refresh-wrap(="[^"]*")?><button[^>]*disabled/);
157
+ assert.match(html, /data-bs-title="Nothing to import yet — press Refresh to scan the Client first"[^>]*><button[^>]*disabled/);
158
+
159
+ services.registry.heartbeat(reg.resourceId, { state: 'IDLE' });
160
+ html = await card();
161
+ assert.match(html, /data-bs-title="Run lsusb -tvv on the Client now[^"]*" data-usb-refresh-wrap(="[^"]*")?><button [^>]*data-usb-refresh="">/);
162
+ assert.doesNotMatch(html, /data-usb-refresh-wrap(="[^"]*")?><button[^>]*disabled/);
163
+ // Every disabled button in the card sits in a tooltip wrapper that says why.
164
+ const disabled = [...html.matchAll(/(<span[^>]*data-bs-title="([^"]*)"[^>]*>)?<button[^>]*\sdisabled[^>]*>/g)];
165
+ assert.ok(disabled.every((m) => m[2]), disabled.filter((m) => !m[2]).map((m) => m[0]).join('\n'));
166
+ });
167
+
168
+ test('Import and Export work for a Client that doesn\'t re-register (no joinKey): it reports its config on start', async (t) => {
169
+ const { base, services, admin } = await start(t),
170
+ { resourceId, resourceToken } = services.registry.registerAuto({ clientId: 'hw4', name: 'lab-hw-04', type: 'hw', labels: [] }),
171
+ card = async () => {
172
+ const html = await (await fetch(`${base}/resources`, { headers: { cookie: admin } })).text();
173
+ return html.slice(html.indexOf(`id="resource-card-${resourceId}"`));
174
+ };
175
+ assert.match(await card(), /Import unavailable: this Client is too old/); // nothing reported, no version known
176
+
177
+ const res = await fetch(`${base}/api/v1/resources/${resourceId}/config-report`, {
178
+ method: 'POST',
179
+ headers: { authorization: `Bearer ${resourceToken}`, 'content-type': 'application/json', 'x-thub-client-version': '1.1.1' },
180
+ body: JSON.stringify({
181
+ config: { stlinks: [{ index: 1, devpath: '1.1' }] },
182
+ configFile: {
183
+ coordinatorUrl: 'https://c', name: 'lab-hw-04', type: 'hw', joinKey: 'secret', labels: [], 'hw-devices': { stlinks: [{ index: 1, devpath: '1.1' }] }
184
+ }
185
+ })
186
+ });
187
+ assert.equal(res.status, 204);
188
+ const html = await card();
189
+ assert.match(html, /data-config-import-pick/); // Import enabled
190
+ assert.doesNotMatch(html, /Import unavailable/);
191
+ const exported = await (await fetch(`${base}/resources/${resourceId}/config/export`, { headers: { cookie: admin } })).json();
192
+ assert.equal(exported.joinKey, undefined);
193
+ assert.deepEqual(exported['hw-devices'], { stlinks: [{ index: 1, devpath: '1.1' }] });
194
+
195
+ // Another resource's token can't report for it.
196
+ const other = services.registry.registerAuto({ clientId: 'hw5', name: 'lab-hw-05', type: 'hw', labels: [] });
197
+ assert.equal((await fetch(`${base}/api/v1/resources/${resourceId}/config-report`, {
198
+ method: 'POST', headers: { authorization: `Bearer ${other.resourceToken}`, 'content-type': 'application/json' }, body: '{}'
199
+ })).status, 403);
200
+ });
@@ -680,8 +680,8 @@ test('config export/import: exported from the reported file (+ what\'s pending);
680
680
  configFile: { coordinatorUrl: 'https://c', name: 'dut1', joinKey: 'k', type: 'hw', labels: [], hw: { uarts: [{ index: 1 }] },
681
681
  sw: { image: 'emu:1', registryAuth: { password: 'x' } }, artifactory: { token: 'secret', allowedArtifactPrefixes: [] } }
682
682
  });
683
- assert.deepEqual(registry.exportClientConfig(resourceId), {
684
- coordinatorUrl: 'https://c', name: 'dut1', joinKey: 'k', type: 'hw', labels: [], 'hw-devices': { uarts: [{ index: 1 }] }
683
+ assert.deepEqual(registry.exportClientConfig(resourceId), { // never the joinKey
684
+ coordinatorUrl: 'https://c', name: 'dut1', type: 'hw', labels: [], 'hw-devices': { uarts: [{ index: 1 }] }
685
685
  });
686
686
 
687
687
  const { resource, ignored } = registry.importClientConfig(resourceId, {
@@ -797,3 +797,41 @@ test('deleting a group strips it from every resource that listed it, without tou
797
797
  assert.equal(groups.get(g1.id), undefined);
798
798
  assert.ok(groups.get(g2.id));
799
799
  });
800
+
801
+ test('config import: what each Client can take — a whole file, an older HW Client\'s devices only, or nothing', () => {
802
+ const { registry } = buildTestServices(),
803
+ reg = (clientId, type, clientVersion, extra = {}) =>
804
+ registry.registerAuto({ clientId, name: clientId, type, labels: [], clientVersion, ...extra }).resourceId,
805
+ // Never reported its file (no joinKey: it doesn't re-register), but new enough to apply one.
806
+ fresh = reg('fresh', 'hw', '1.1.1', { config: { uarts: [{ index: 1 }] } }),
807
+ oldHw = reg('old-hw', 'hw', '1.0.20', { config: { uarts: [{ index: 1 }] } }),
808
+ oldSw = reg('old-sw', 'sw', '1.0.20');
809
+ assert.deepEqual([fresh, oldHw, oldSw].map((id) => registry.get(id).config_support), ['file', 'section', null]);
810
+
811
+ const file = { type: 'hw', labels: ['board:b'], heartbeatIntervalSec: 20, 'hw-devices': { usbs: [{ index: 2 }] } };
812
+ assert.deepEqual(registry.importClientConfig(fresh, file, { by: 'a' }).ignored, []);
813
+ assert.deepEqual(registry.pendingConfig(fresh).file, { labels: ['board:b'], heartbeatIntervalSec: 20 });
814
+
815
+ const { ignored } = registry.importClientConfig(oldHw, file, { by: 'a' }),
816
+ why = '(this Client applies only its devices — update it for the rest)';
817
+ assert.deepEqual(ignored, [`labels ${why}`, `heartbeatIntervalSec ${why}`]);
818
+ assert.deepEqual(registry.pendingConfig(oldHw), { revision: 1, type: 'hw', config: { usbs: [{ index: 2 }] } }); // no `file` for it
819
+ assert.throws(() => registry.importClientConfig(oldSw, { type: 'sw' }, { by: 'a' }), /too old to apply one/);
820
+
821
+ // Reported later (POST /resources/:id/config-report): kept without the joinKey.
822
+ registry.reportClientConfig(oldSw, { configFile: { coordinatorUrl: 'https://c', name: 'old-sw', type: 'sw', joinKey: 'secret', labels: [] } });
823
+ assert.equal(registry.get(oldSw).client_config_file.joinKey, undefined);
824
+ assert.equal(registry.get(oldSw).config_support, 'file');
825
+ assert.equal(registry.exportClientConfig(oldSw).joinKey, undefined);
826
+ });
827
+
828
+ test('migration 027 drops joinKey copies from stored config files', () => {
829
+ const Database = require('better-sqlite3'),
830
+ db = new Database(':memory:');
831
+ db.exec('CREATE TABLE resources (id TEXT, client_config_file TEXT)');
832
+ db.prepare('INSERT INTO resources VALUES (?, ?)').run('a', JSON.stringify({ name: 'a', joinKey: 'k', labels: [] }));
833
+ db.prepare('INSERT INTO resources VALUES (?, ?)').run('b', null);
834
+ db.exec(require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/db/migrations/027_scrub_config_file_secrets.sql'), 'utf8'));
835
+ assert.deepEqual(JSON.parse(db.prepare('SELECT client_config_file FROM resources WHERE id = ?').get('a').client_config_file), { name: 'a', labels: [] });
836
+ assert.equal(db.prepare('SELECT client_config_file FROM resources WHERE id = ?').get('b').client_config_file, null);
837
+ });
@@ -105,6 +105,8 @@
105
105
  Admins can also edit an HW Client's devices from its resource card (#[strong ST-Link | UART | DUT USB]
106
106
  tabs). The tabs show the config the Client runs, as it reported it at its last start, or a saved
107
107
  change it hasn't applied yet. #[strong Connected USB devices → Import to config] fills them from the host's #[code lsusb].
108
+ The card's #[strong Export] and #[strong Import] icons download a Client's config file (never its
109
+ #[code joinKey]) and apply one to it. An older HW Client takes only the file's #[code hw-devices].
108
110
  There's no ST-Link serial field: the job's #[code --command] picks the probe
109
111
  (#[code $THUB_DUT_STLINK_&lt;n&gt;]), and the Client reads each serial from its udev symlink.
110
112
 
@@ -16,13 +16,13 @@ mixin deviceRow(kind, d)
16
16
  td: input.form-control.form-control-sm(data-f="baudRate" type="number" min="50" value=d && d.baudRate || '' placeholder="115200")
17
17
  td: input.form-control.form-control-sm.font-monospace(data-f="devpath" value=d && d.devpath || '' placeholder="3.3.4.1")
18
18
  td.text-end
19
- button.btn.btn-sm.btn-outline-secondary(type="button" data-remove-row aria-label="Remove row")
19
+ button.btn.btn-sm.btn-outline-secondary(type="button" data-remove-row aria-label="Remove row" data-bs-toggle="tooltip" data-bs-title="Remove this device (saved with Save capabilities)")
20
20
  i.bi.bi-x-lg
21
21
 
22
22
  mixin deviceTable(kind, title, rows, columns)
23
23
  .d-flex.justify-content-between.align-items-center.mb-1
24
24
  strong.small= title
25
- button.btn.btn-sm.btn-outline-primary(type="button" data-add-row=kind)
25
+ button.btn.btn-sm.btn-outline-primary(type="button" data-add-row=kind data-bs-toggle="tooltip" data-bs-title="Add a device row (saved with Save capabilities)")
26
26
  i.bi.bi-plus-lg.me-1
27
27
  | Add
28
28
  table.table.table-sm.align-middle.mb-0(data-table=kind)
@@ -69,7 +69,7 @@ mixin configPane(p, key)
69
69
  mixin saveCapabilities(canEdit)
70
70
  if canEdit
71
71
  .d-flex.justify-content-end.mt-3
72
- button.btn.btn-sm.btn-primary(type="submit") Save capabilities
72
+ button.btn.btn-sm.btn-primary(type="submit" data-bs-toggle="tooltip" data-bs-title="Save the ST-Link, UART and DUT USB tabs — the Client applies them on its next heartbeat and restarts once idle") Save capabilities
73
73
 
74
74
  //- Where the saved capabilities stand, at the top of each capability tab.
75
75
  mixin capabilitiesStatus(r)
@@ -119,8 +119,8 @@ mixin groupsPane(r, returnTo, groupsById)
119
119
  ) not written to the Client yet
120
120
  if r.config_error
121
121
  span.badge.text-bg-danger(data-bs-toggle="tooltip" data-bs-title=r.config_error) refused by the Client
122
- if !r.client_config_file
123
- p.small.text-body-secondary This Client is too old to take its groups from the dashboard — update it, or set #[code groups] in its config file.
122
+ if r.config_support !== 'file'
123
+ p.small.text-body-secondary Groups can't be set here: #{noConfigReport} Or set #[code groups] in its config file.
124
124
  if !all.length && !unknown.length
125
125
  p.small.text-body-secondary.mb-0
126
126
  | No groups exist yet.
@@ -136,7 +136,7 @@ mixin groupsPane(r, returnTo, groupsById)
136
136
  data-confirm-tone="primary"
137
137
  )
138
138
  input(type="hidden" name="returnTo" value=returnTo)
139
- fieldset(disabled=!canEdit || !r.client_config_file)
139
+ fieldset(disabled=!canEdit || r.config_support !== 'file')
140
140
  .list-group.mb-2
141
141
  each g in all
142
142
  label.list-group-item.d-flex.gap-2.align-items-start
@@ -155,9 +155,9 @@ mixin groupsPane(r, returnTo, groupsById)
155
155
  br
156
156
  code.small.text-body-secondary= id
157
157
  .form-text A job run with #[code thub run --group &lt;id&gt;] is scheduled only on Clients in that group; one that asks for no group can run on any Client.
158
- if canEdit && r.client_config_file
158
+ if canEdit && r.config_support === 'file'
159
159
  .d-flex.justify-content-end.mt-3
160
- button.btn.btn-sm.btn-primary(type="submit") Save groups
160
+ button.btn.btn-sm.btn-primary(type="submit" data-bs-toggle="tooltip" data-bs-title="Save the groups — in effect at once; the Client writes them to its config file") Save groups
161
161
 
162
162
  mixin configPanes(r, returnTo, groupsById)
163
163
  - const p = `cfg-${r.id}`
@@ -174,7 +174,7 @@ mixin configPanes(r, returnTo, groupsById)
174
174
  else if !current
175
175
  each t in [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'DUT USB']]
176
176
  +configPane(p, t[0])
177
- p.small.text-body-secondary.mb-0 This Client is too old to report its config — update it to edit its devices here.
177
+ p.small.text-body-secondary.mb-0 Devices can't be edited here: #{noConfigReport}
178
178
  else
179
179
  form(
180
180
  method="post"
@@ -246,12 +246,12 @@ mixin rebootScheduleRow(r, returnTo)
246
246
  aria-label=`Reboot schedule for ${r.name} (cron)`
247
247
  required
248
248
  )
249
- button.btn.btn-sm.btn-outline-primary(type="submit") Save
249
+ button.btn.btn-sm.btn-outline-primary(type="submit" data-bs-toggle="tooltip" data-bs-title="Save this schedule — the Client applies it on its next heartbeat") Save
250
250
  if r.reboot_schedule
251
251
  form.d-inline(method="post" action=`/resources/${r.id}/reboot-schedule`)
252
252
  input(type="hidden" name="returnTo" value=returnTo)
253
253
  input(type="hidden" name="clear" value="1")
254
- button.btn.btn-sm.btn-link.p-0.small(type="submit") Clear schedule
254
+ button.btn.btn-sm.btn-link.p-0.small(type="submit" data-bs-toggle="tooltip" data-bs-title="Remove the schedule — the host isn't rebooted on a schedule any more") Clear schedule
255
255
  .form-text
256
256
  | cron: minute hour day month weekday, in the host's local time#{tz ? ` (${tz})` : ''} — e.g.
257
257
  |
@@ -337,33 +337,27 @@ mixin usbScanPane(r)
337
337
  - const scan = r.usb_scan
338
338
  //- Import to config (public/js/usb-import.js): HW Clients that report
339
339
  //- their config only; enabled once there's a scan to import from.
340
- - const importWhy = r.type !== 'hw' ? 'Only HW Clients have USB device lists to import into' : !(r.config_desired || r.client_config) ? 'This Client is too old to report its config — update it first' : null
340
+ - const importWhy = r.type !== 'hw' ? 'Only HW Clients have USB device lists to import into' : !(r.config_desired || r.client_config) ? noConfigReport[0].toUpperCase() + noConfigReport.slice(1) : null
341
341
  div(data-usb-scan=`/resources/${r.id}/usb-scan` data-pending=r.usb_scan_request_id ? '1' : null data-requested-at=r.usb_scan_requested_at)
342
342
  .d-flex.align-items-center.gap-2.mb-2
343
343
  if can.operate
344
- if offline
345
- span.d-inline-block(tabindex="0" data-bs-toggle="tooltip" data-bs-title="Offline — a Client can only be scanned while it's connected")
346
- button.btn.btn-sm.btn-outline-primary(type="button" disabled style="pointer-events: none;")
347
- i.bi.bi-arrow-clockwise.me-1
344
+ //- Live (public/js/live.js): a Client that comes back online gets its
345
+ //- Refresh back without a reload. Each button sits in a tooltip
346
+ //- wrapper, so a disabled one still says why (usb-scan.js and
347
+ //- usb-import.js handle clicks at the document, as these get replaced).
348
+ - const pending = Boolean(r.usb_scan_request_id)
349
+ - const refreshWhy = pending ? 'Waiting for the Client to run lsusb -tvv (its next heartbeat)' : offline ? 'Offline — a Client can only be scanned while it\'s connected' : null
350
+ - const importTip = 'Fill the ST-Link, UART and DUT USB tabs from this scan, for you to review and save'
351
+ - const importOff = importWhy || (!(scan && scan.output) ? 'Nothing to import yet — press Refresh to scan the Client first' : null)
352
+ span.d-inline-flex.gap-2(data-live=`rc-usb-actions-${r.id}`)
353
+ span.d-inline-block(tabindex=refreshWhy ? '0' : null data-bs-toggle="tooltip" data-bs-title=refreshWhy || 'Run lsusb -tvv on the Client now — it answers on its next heartbeat' data-usb-refresh-wrap)
354
+ button.btn.btn-sm.btn-outline-primary(type="button" data-usb-refresh=offline ? null : '' disabled=Boolean(refreshWhy) style=refreshWhy ? 'pointer-events: none;' : null)
355
+ i.me-1(class=pending ? 'spinner-border spinner-border-sm' : 'bi bi-arrow-clockwise')
348
356
  | Refresh
349
- else
350
- button.btn.btn-sm.btn-outline-primary(type="button" data-usb-refresh)
351
- i.bi.bi-arrow-clockwise.me-1
352
- | Refresh
353
- if importWhy
354
- span.d-inline-block(tabindex="0" data-bs-toggle="tooltip" data-bs-title=importWhy)
355
- button.btn.btn-sm.btn-outline-secondary(type="button" disabled style="pointer-events: none;")
357
+ span.d-inline-block(tabindex=importOff ? '0' : null data-bs-toggle="tooltip" data-bs-title=importOff || importTip data-usb-import-wrap data-tip-enabled=importWhy ? null : importTip)
358
+ button.btn.btn-sm.btn-outline-secondary(type="button" data-usb-import=importWhy ? null : '' disabled=Boolean(importOff) style=importOff ? 'pointer-events: none;' : null)
356
359
  i.bi.bi-box-arrow-in-down.me-1
357
360
  | Import to config
358
- else
359
- button.btn.btn-sm.btn-outline-secondary(
360
- type="button"
361
- data-usb-import
362
- disabled=!(scan && scan.output)
363
- title="Fill the ST-Link, UART and DUT USB tabs from this scan, for you to review and save"
364
- )
365
- i.bi.bi-box-arrow-in-down.me-1
366
- | Import to config
367
361
  span.small.text-body-secondary(data-usb-status)
368
362
  if scan
369
363
  = `lsusb -tvv at ${fmtDate(scan.at)}`
@@ -406,8 +400,8 @@ mixin rebootButton(r, returnTo)
406
400
  //- Export / Import the Client's config file as JSON (card's Actions row,
407
401
  //- icons only). Import: public/js/config-import.js reads the chosen file
408
402
  //- into the form and submits it (confirmed first); the Client applies it on
409
- //- its next heartbeat. A Client that doesn't report its config file is too
410
- //- old to apply more than the hw/sw section — Import is off for it.
403
+ //- its next heartbeat. What it can apply: r.config_support (registry.js) —
404
+ //- a whole file, or an older HW Client's devices only.
411
405
  mixin configTransferButtons(r, returnTo)
412
406
  a.btn.btn-sm.btn-outline-secondary.ms-1(
413
407
  href=`/resources/${r.id}/config/export`
@@ -417,8 +411,8 @@ mixin configTransferButtons(r, returnTo)
417
411
  data-bs-title="Export the Client's config file (JSON; its secrets left out)"
418
412
  )
419
413
  i.bi.bi-download
420
- if !r.client_config_file
421
- 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")
414
+ if !r.config_support
415
+ span.d-inline-block.ms-1(tabindex="0" data-bs-toggle="tooltip" data-bs-title="Import unavailable: this Client is too old to apply a config from the dashboard — update it")
422
416
  button.btn.btn-sm.btn-outline-secondary(type="button" disabled style="pointer-events: none;" aria-label="Import config")
423
417
  i.bi.bi-upload
424
418
  else
@@ -440,7 +434,7 @@ mixin configTransferButtons(r, returnTo)
440
434
  aria-label=`Import a config into ${r.name}`
441
435
  data-config-import-pick
442
436
  data-bs-toggle="tooltip"
443
- data-bs-title="Import a config file (JSON) — joinKey, coordinatorUrl and name in it are ignored"
437
+ data-bs-title=r.config_support === 'file' ? 'Import a config file (JSON) — joinKey, coordinatorUrl and name in it are ignored' : 'Import a config file (JSON) — this Client is older: only its hw-devices are applied (update it for the rest)'
444
438
  )
445
439
  i.bi.bi-upload
446
440
 
@@ -476,7 +470,7 @@ mixin resourceDetails(r, groupsById, returnTo, local, external)
476
470
  placeholder=r.reported_name || ''
477
471
  aria-label=`New name for ${r.name}`
478
472
  )
479
- button.btn.btn-sm.btn-primary(type="submit") Rename
473
+ button.btn.btn-sm.btn-primary(type="submit" data-bs-toggle="tooltip" data-bs-title="Save the new name — kept across the Client's restarts") Rename
480
474
  .form-text Kept across the Client's restarts. Empty = back to the Client's own name#{r.reported_name ? ` (${r.reported_name})` : ''}.
481
475
  dt.col-sm-4 Type
482
476
  dd.col-sm-8.text-uppercase= r.type