@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 +16 -3
- package/package.json +1 -1
- package/public/js/usb-import.js +5 -3
- package/public/js/usb-scan.js +88 -63
- package/src/api/resource.js +12 -0
- package/src/db/migrations/027_scrub_config_file_secrets.sql +4 -0
- package/src/server.js +1 -1
- package/src/services/admin-users.js +8 -4
- package/src/services/registry.js +69 -12
- package/src/web/routes.js +56 -2
- package/test/groups-tab.test.js +58 -0
- package/test/scheduler.test.js +40 -2
- package/test/users.test.js +54 -0
- package/views/admin/users.pug +15 -14
- package/views/help/_client-setup.pug +2 -0
- package/views/help/_coordinator.pug +2 -0
- package/views/mixins/client-config.pug +9 -9
- package/views/mixins/resource.pug +23 -29
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 =
|
|
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
|
+
"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": {
|
package/public/js/usb-import.js
CHANGED
|
@@ -144,8 +144,10 @@
|
|
|
144
144
|
close.focus();
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
document.
|
|
148
|
-
|
|
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
|
})();
|
package/public/js/usb-scan.js
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
78
|
-
|
|
74
|
+
function poll(pane, requestedAt){
|
|
75
|
+
clearTimeout(timers.get(pane));
|
|
76
|
+
timers.set(pane, setTimeout(async () => {
|
|
79
77
|
try {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
88
|
-
|
|
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
|
-
|
|
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
|
})();
|
package/src/api/resource.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/services/registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
241
|
+
db.prepare('UPDATE resources SET client_config = ? WHERE id = ?').run(json, resourceId);
|
|
205
242
|
}
|
|
206
243
|
}
|
|
207
|
-
if (
|
|
208
|
-
const json = JSON.stringify(
|
|
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,
|
|
247
|
+
db.prepare('UPDATE resources SET client_config_file = ? WHERE id = ?').run(json, resourceId);
|
|
211
248
|
}
|
|
212
249
|
}
|
|
213
|
-
|
|
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
|
-
|
|
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,
|
|
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 ||
|
|
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 =
|
|
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
|
-
|
|
1323
|
+
audit: auditRows(isAdmin(me) ? services.adminUsers.recentEvents(30) : visibleEvents(me, visibleIds)),
|
|
1270
1324
|
...extra
|
|
1271
1325
|
});
|
|
1272
1326
|
}
|
package/test/groups-tab.test.js
CHANGED
|
@@ -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="\{"serial":"066DFF48"\}"/);
|
|
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('|.)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
|
+
});
|
package/test/scheduler.test.js
CHANGED
|
@@ -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',
|
|
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
|
+
});
|
package/test/users.test.js
CHANGED
|
@@ -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(/'/g, '\'').replace(/>/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
|
+
});
|
package/views/admin/users.pug
CHANGED
|
@@ -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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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_<n>]), 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 (<OS user>)], 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
|
|
123
|
-
p.small.text-body-secondary
|
|
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 ||
|
|
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 <id>] is scheduled only on Clients in that group; one that asks for no group can run on any Client.
|
|
158
|
-
if canEdit && r.
|
|
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
|
|
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) ?
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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.
|
|
410
|
-
//-
|
|
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.
|
|
421
|
-
span.d-inline-block.ms-1(tabindex="0" data-bs-toggle="tooltip" data-bs-title="Import unavailable: this Client is too old to
|
|
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=
|
|
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
|