@andrian.yablonskyy/thub-coordinator 1.1.3 → 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/bin/thub-admin.js CHANGED
@@ -37,8 +37,21 @@ const { loadConfig } = require('../src/config'),
37
37
  { roleLabel, normalizeRole } = require('../src/services/admin-users'),
38
38
  { PACKAGES, fetchLatestVersion, isNewer, isValidVersion, npmBin, formatDateTime, parseDateTime } = require('@andrian.yablonskyy/thub-common'),
39
39
  { spawnSync } = require('node:child_process'),
40
+ os = require('node:os'),
40
41
  { version: installedVersion } = require('../package.json');
41
42
 
43
+ // Who the audit log names for a change made here: the OS user running it.
44
+ function cliActor(){
45
+ let name = process.env.SUDO_USER || '';
46
+ try {
47
+ name ||= os.userInfo().username;
48
+ }
49
+ catch {
50
+ // no passwd entry (a container's random uid)
51
+ }
52
+ return name ? `thub-admin (${name})` : 'thub-admin';
53
+ }
54
+
42
55
  function usage(){
43
56
  console.log(`Usage:
44
57
  thub-admin user add <username> --email <email> [--role user|maintainer|admin] [--password <p>] [--key]
@@ -174,7 +187,7 @@ function main(){
174
187
  if (cmd === 'user'){
175
188
  const { positional, flags } = parseFlags(rest),
176
189
  [username, arg] = positional,
177
- by = 'thub-admin',
190
+ by = cliActor(),
178
191
  need = (u) => {
179
192
  const row = adminUsers.getByUsername(u || '');
180
193
  if (!row){
@@ -221,7 +234,7 @@ function main(){
221
234
  if (!arg){
222
235
  return usage(), process.exit(4);
223
236
  }
224
- adminUsers.resetPassword({ username: target.username, password: arg });
237
+ adminUsers.resetPassword({ username: target.username, password: arg }, { by });
225
238
  console.log(`Password of "${target.username}" set`);
226
239
  }
227
240
  else if (sub === 'key'){
@@ -252,7 +265,7 @@ function main(){
252
265
  if (!username || !password){
253
266
  return usage(), process.exit(4);
254
267
  }
255
- adminUsers.create({ username, password, role: flags.role || 'admin' });
268
+ adminUsers.create({ username, password, role: flags.role || 'admin' }, { by: cliActor() });
256
269
  console.log(`Created ${roleLabel(normalizeRole(flags.role || 'admin'))} user "${username}"`);
257
270
  return;
258
271
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.1.3",
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;
package/src/server.js CHANGED
@@ -247,7 +247,7 @@ function ensureBootstrapAdmin(services){
247
247
  return;
248
248
  }
249
249
  const username = process.env.THUB_BOOTSTRAP_ADMIN_USER || 'admin';
250
- services.adminUsers.resetPassword({ username, password });
250
+ services.adminUsers.resetPassword({ username, password }, { by: 'system (THUB_BOOTSTRAP_ADMIN_PASSWORD)' });
251
251
  console.log(`Reset password for admin user "${username}" from THUB_BOOTSTRAP_ADMIN_PASSWORD`);
252
252
  }
253
253
 
@@ -123,7 +123,10 @@ function parseListPrefs(json){
123
123
  // access key for the Agent for anyone. `events` (the audit log) is optional
124
124
  // so the profile/password functions can be used on their own (tests).
125
125
  function createAdminUsersService(db, { events } = {}){
126
- const audit = (id, type, data) => events?.record('user', id, type, data);
126
+ // Every user event names who did it (`by`): a username, `thub-admin
127
+ // (<os user>)`, or `system` for what no person did (the bootstrap reset,
128
+ // internal callers that pass none).
129
+ const audit = (id, type, data) => events?.record('user', id, type, { ...data, by: data.by || 'system' });
127
130
 
128
131
  function getByUsername(username){
129
132
  return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
@@ -228,13 +231,14 @@ function createAdminUsersService(db, { events } = {}){
228
231
  // silently promote anyone to admin), or creates one as admin if the
229
232
  // named account doesn't exist yet — the original first-run bootstrap
230
233
  // case, now handled by the same path.
231
- function resetPassword({ username, password, role = 'admin' }){
234
+ function resetPassword({ username, password, role = 'admin' }, { by } = {}){
232
235
  const existing = getByUsername(username);
233
236
  if (existing){
234
237
  db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?').run(hashPassword(password), existing.id);
238
+ audit(existing.id, 'user.password_set', { by });
235
239
  return { id: existing.id, username, role: existing.role };
236
240
  }
237
- return create({ username, password, role });
241
+ return create({ username, password, role }, { by });
238
242
  }
239
243
 
240
244
  // Users page: a new temporary password for someone who lost theirs.
@@ -266,7 +270,7 @@ function createAdminUsersService(db, { events } = {}){
266
270
  throw bad('Choose a password different from the current one');
267
271
  }
268
272
  db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?').run(await hashPasswordAsync(newPassword), id);
269
- audit(id, 'user.password_changed', {});
273
+ audit(id, 'user.password_changed', { by: row.username });
270
274
  }
271
275
 
272
276
  // Async (thread pool); the same work whether or not the user exists, or
@@ -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)
@@ -1205,7 +1208,58 @@ function createWebRouter({ services, config }){
1205
1208
  // dashboard role.
1206
1209
  isAdmin = (actor) => actor.role === 'admin',
1207
1210
  managesUser = (actor, u) => Boolean(u) && (isAdmin(actor) || u.role === 'user' || u.id === actor.id),
1208
- userFiltersFor = (actor) => (isAdmin(actor) ? USER_ROLE_FILTERS : ['user', 'blocked']);
1211
+ userFiltersFor = (actor) => (isAdmin(actor) ? USER_ROLE_FILTERS : ['user', 'blocked']),
1212
+ // The audit log's Action column names changed fields like this.
1213
+ FIELD_LABEL = { username: 'username', email: 'email', role: 'role', first_name: 'first name', last_name: 'last name' };
1214
+
1215
+ // The audit log's Action column: what was done, to whom. `nameOf` gives
1216
+ // the account's current username (else the one its events recorded).
1217
+ function describeUserEvent(e, nameOf){
1218
+ const name = nameOf(e.entity_id),
1219
+ whose = e.data.by === name ? 'their own' : `${name}'s`;
1220
+ switch (e.type){
1221
+ case 'user.created': return `Created ${name} (${roleLabel(e.data.role)})`;
1222
+ case 'user.updated': {
1223
+ const parts = [];
1224
+ if (e.data.role?.from){
1225
+ parts.push(`role ${roleLabel(e.data.role.from)} → ${roleLabel(e.data.role.to)}`);
1226
+ }
1227
+ const fields = (e.data.changed || []).filter((f) => f !== 'role').map((f) => FIELD_LABEL[f] || f);
1228
+ if (fields.length){
1229
+ parts.push(fields.join(', '));
1230
+ }
1231
+ return `Edited ${name}${parts.length ? `: ${parts.join('; ')}` : ''}`;
1232
+ }
1233
+ case 'user.blocked': return `Blocked ${name}`;
1234
+ case 'user.unblocked': return `Unblocked ${name}`;
1235
+ case 'user.deleted': return `Deleted ${name}`;
1236
+ case 'user.password_reset': return `Issued a temporary password for ${name}`;
1237
+ case 'user.password_set': return `Set ${whose} password`;
1238
+ case 'user.password_changed': return `Changed ${whose} password`;
1239
+ case 'user.key_created': return `Created ${whose} access key`;
1240
+ case 'user.key_rotated': return `Replaced ${whose} access key`;
1241
+ case 'user.key_revoked': return `Revoked ${whose} access key`;
1242
+ default: return `${e.type.replace('user.', '').replace(/_/g, ' ')} ${name}`;
1243
+ }
1244
+ }
1245
+
1246
+ // Rows for the audit log: Timestamp, User (who did it — always shown;
1247
+ // events from before it was recorded for everything say `system`, or the
1248
+ // account itself for its own password change), Action.
1249
+ function auditRows(events){
1250
+ const names = new Map(services.adminUsers.list().map((u) => [u.id, u.username]));
1251
+ for (const e of services.adminUsers.recentEvents(1000)){
1252
+ if (!names.has(e.entity_id) && e.data.username){
1253
+ names.set(e.entity_id, e.data.username);
1254
+ }
1255
+ }
1256
+ const nameOf = (id) => names.get(id) || id;
1257
+ return events.map((e) => ({
1258
+ ts: e.ts,
1259
+ by: e.data.by || (e.type === 'user.password_changed' ? nameOf(e.entity_id) : 'system'),
1260
+ action: describeUserEvent(e, nameOf)
1261
+ }));
1262
+ }
1209
1263
 
1210
1264
  // A maintainer's Recent activity: about the users they see, and deleted
1211
1265
  // ones that were Agent-only users when created — nothing about admins or
@@ -1266,7 +1320,7 @@ function createWebRouter({ services, config }){
1266
1320
  admin: 'Everything'
1267
1321
  },
1268
1322
  roleTone: { user: 'secondary', maintainer: 'info', admin: 'primary' },
1269
- events: isAdmin(me) ? services.adminUsers.recentEvents(30) : visibleEvents(me, visibleIds),
1323
+ audit: auditRows(isAdmin(me) ? services.adminUsers.recentEvents(30) : visibleEvents(me, visibleIds)),
1270
1324
  ...extra
1271
1325
  });
1272
1326
  }
@@ -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
+ });
@@ -384,3 +384,57 @@ test('maintainers manage Agent-only users, their own account and CI tokens — n
384
384
  assert.match(await r.text(), new RegExp(`<title>${job.id} `));
385
385
  }
386
386
  });
387
+
388
+ test('audit log: Timestamp, User (who did it — always), Action — from the dashboard, the profile, the Agent and thub-admin', async (t) => {
389
+ const { base, services, file } = await start(t),
390
+ u = services.adminUsers;
391
+ u.create({ username: 'root', email: 'r@example.com', role: 'admin', password: 'pw' }, { by: 'root' });
392
+ const login = async (name, pw) => (await fetch(`${base}/login`, {
393
+ method: 'POST', body: new URLSearchParams({ username: name, password: pw }), redirect: 'manual'
394
+ })).headers.get('set-cookie').split(';')[0],
395
+ root = await login('root', 'pw'),
396
+ post = (cookie, p, body = {}) => fetch(`${base}${p}`, { method: 'POST', headers: { cookie }, body: new URLSearchParams(body), redirect: 'manual' });
397
+
398
+ // Dashboard (an admin), then the profile (the user themselves).
399
+ await post(root, '/admin/users', { username: 'dev', email: 'd@example.com', role: 'user', issueKey: '1' });
400
+ const dev = u.getByUsername('dev');
401
+ await post(root, '/admin/users', { username: 'ops', email: 'o@example.com', role: 'maintainer' });
402
+ const ops = u.getByUsername('ops');
403
+ await post(root, `/admin/users/${ops.id}`, { username: 'ops', email: 'ops@example.com', role: 'admin', firstName: 'Olga' });
404
+ const temp = u.adminResetPassword(ops.id, { by: 'root' }),
405
+ opsCookie = await login('ops', temp);
406
+ await post(opsCookie, '/profile/password', { currentPassword: temp, newPassword: 'Ops-own-1', confirmPassword: 'Ops-own-1' });
407
+
408
+ // The Agent rotating its own key.
409
+ const devKey = u.issueKey(dev.id, { by: 'root' });
410
+ assert.equal((await fetch(`${base}/api/v1/me/key/rotate`, { method: 'POST', headers: { authorization: `Bearer ${devKey}` } })).status, 200);
411
+
412
+ // thub-admin on the Coordinator host, and an internal call naming no one.
413
+ const cli = spawnSync(process.execPath, [path.join(__dirname, '..', 'bin', 'thub-admin.js'), 'user', 'block', 'dev'], {
414
+ env: { ...process.env, THUB_COORDINATOR_CONFIG: file, SUDO_USER: 'alice' }, encoding: 'utf8'
415
+ });
416
+ assert.match(cli.stdout, /Blocked "dev"/);
417
+ u.setBlocked(dev.id, false);
418
+
419
+ // Every recorded event names who did it.
420
+ assert.ok(u.recentEvents(100).every((e) => e.data.by), 'every user event has `by`');
421
+
422
+ const html = await (await fetch(`${base}/admin/users`, { headers: { cookie: root } })).text(),
423
+ log = html.slice(html.indexOf('Recent activity'), html.indexOf('id="addUserModal"')),
424
+ rows = [...log.matchAll(/<tr><td[^>]*>[^<]*<\/td><td[^>]*>([^<]*)<\/td><td>([^<]*)<\/td><\/tr>/g)]
425
+ .map((m) => [m[1], m[2].replace(/&#39;/g, '\'').replace(/&gt;/g, '>')]).reverse(); // oldest first
426
+ assert.match(log, /<th class="text-nowrap">Timestamp<\/th><th>User<\/th><th>Action<\/th>/);
427
+ assert.deepEqual(rows, [
428
+ ['root', 'Created root (Admin)'],
429
+ ['root', 'Created dev (User)'],
430
+ ['root', 'Created dev\'s access key'],
431
+ ['root', 'Created ops (Maintainer)'],
432
+ ['root', 'Edited ops: role Maintainer → Admin; email, first name'],
433
+ ['root', 'Issued a temporary password for ops'],
434
+ ['ops', 'Changed their own password'],
435
+ ['root', 'Replaced dev\'s access key'],
436
+ ['dev', 'Replaced their own access key'],
437
+ ['thub-admin (alice)', 'Blocked dev'],
438
+ ['system', 'Unblocked dev']
439
+ ]);
440
+ });
@@ -195,21 +195,22 @@ block content
195
195
  tr
196
196
  td.text-body-secondary(colspan="5")= q || roleFilter ? 'No user matches.' : 'No users yet.'
197
197
 
198
+ //- The audit log: who did what to which account (web/routes.js auditRows).
198
199
  h2.h6.mt-4 Recent activity
199
- if events.length
200
- ul.list-group.list-group-flush.small.mb-4
201
- each e in events
202
- li.list-group-item.px-0
203
- span.text-body-secondary.me-2= fmtDate(e.ts)
204
- strong= e.type.replace('user.', '').replace(/_/g, ' ')
205
- if e.data.username
206
- = ` ${e.data.username}`
207
- if e.data.role && e.data.role.from
208
- = ` — ${roleLabel(e.data.role.from)} → ${roleLabel(e.data.role.to)}`
209
- else if e.data.role
210
- = ` (${roleLabel(e.data.role)})`
211
- if e.data.by
212
- span.text-body-secondary= ` by ${e.data.by}`
200
+ if audit.length
201
+ .table-responsive.mb-4
202
+ table.table.table-sm.small.align-middle
203
+ thead
204
+ tr
205
+ th.text-nowrap Timestamp
206
+ th User
207
+ th Action
208
+ tbody
209
+ each e in audit
210
+ tr
211
+ td.text-nowrap.text-body-secondary= fmtDate(e.ts)
212
+ td.text-nowrap= e.by
213
+ td= e.action
213
214
  else
214
215
  p.small.text-body-secondary Nothing yet.
215
216
 
@@ -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
 
@@ -131,6 +131,8 @@
131
131
  Maintainers use Users too, for Agent-only users and their own account only. They can create, edit, block
132
132
  and delete those users and issue or revoke their keys, but can't create or promote a maintainer or admin.
133
133
  They don't see admins or other maintainers. They also manage #[a(href="/admin/agents") CI tokens].
134
+ #[strong Recent activity] at the bottom of Users is the audit log: #[strong Timestamp | User | Action], where
135
+ User is who made the change (a username, #[code thub-admin (&lt;OS user&gt;)], or #[code system]).
134
136
  +note('warning').
135
137
  Forgot the admin password? Start the Coordinator once with #[code THUB_BOOTSTRAP_ADMIN_PASSWORD=…]
136
138
  (and optionally #[code THUB_BOOTSTRAP_ADMIN_USER], default #[code admin]). The password is reset on
@@ -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