@andrian.yablonskyy/thub-coordinator 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/public/css/thub.css +35 -18
- package/public/js/client-config.js +0 -1
- package/src/services/registry.js +14 -3
- package/src/web/routes.js +5 -2
- package/systemd/thub-coordinator.service +4 -0
- package/test/groups-tab.test.js +45 -0
- package/test/systemd-unit.test.js +25 -0
- package/test/users.test.js +20 -1
- package/views/admin/users.pug +4 -12
- package/views/help/_client-setup.pug +4 -1
- package/views/help/_coordinator.pug +5 -0
- package/views/help/_troubleshooting.pug +1 -0
- package/views/layout.pug +23 -9
- package/views/mixins/client-config.pug +12 -7
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ This gives you two global commands: `thub-coordinator` (the server itself) and `
|
|
|
14
14
|
|
|
15
15
|
- `~/.config/thub/coordinator.json` with every default option (see Configuration below), if it doesn't exist yet.
|
|
16
16
|
- `~/var/lib/thub` (the `dataDir`: SQLite DB, uploaded artifacts, avatars) with its `artifacts/`, `avatars/` and `work/` subdirectories.
|
|
17
|
-
- On Linux as root: `/etc/systemd/system/thub-coordinator.service`, enabled and started, so the Coordinator runs now and on every boot. A re-install/upgrade restarts it on the new code.
|
|
17
|
+
- On Linux as root: `/etc/systemd/system/thub-coordinator.service` (`ProtectSystem=strict` with a private writable `/tmp`, which SQLite needs), enabled and started, so the Coordinator runs now and on every boot. A re-install/upgrade restarts it on the new code.
|
|
18
18
|
|
|
19
19
|
Under `sudo`, `~` means the home of the user who ran `sudo` (`SUDO_USER`), not `/root`. That user owns the config and data and the service runs as them. Without root the config and directories are still created; the systemd step is skipped.
|
|
20
20
|
|
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.2",
|
|
4
4
|
"description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"engines": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"lint:fix": "eslint . --fix"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@andrian.yablonskyy/thub-common": "^1.1.
|
|
22
|
+
"@andrian.yablonskyy/thub-common": "^1.1.1",
|
|
23
23
|
"better-sqlite3": "^12.4.1",
|
|
24
24
|
"express": "^5.1.0",
|
|
25
25
|
"express-session": "^1.18.2",
|
package/public/css/thub.css
CHANGED
|
@@ -86,17 +86,33 @@
|
|
|
86
86
|
padding: 0.45rem 0.65rem;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
/*
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
89
|
+
/* The client details modal keeps one size whichever tab is open — the
|
|
90
|
+
tallest tab's — instead of growing and shrinking (and, centered, jumping)
|
|
91
|
+
on every switch. Every pane sits in the same grid cell, so the tab area is
|
|
92
|
+
as tall as the tallest; inactive ones are hidden with `visibility`, which
|
|
93
|
+
keeps their height (and still takes them out of the tab order and of what
|
|
94
|
+
screen readers read). The device tabs sit inside their shared
|
|
95
|
+
<form>/<fieldset> (one Save for all of them), so that fieldset stacks its
|
|
96
|
+
panes the same way. The USB tab's output scrolls in its own box (360 px),
|
|
97
|
+
so a long lsusb doesn't make every tab that tall. */
|
|
98
|
+
.thub-resource-card .tab-content,
|
|
99
|
+
.thub-resource-card .tab-content > form > fieldset {
|
|
100
|
+
display: grid;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.thub-resource-card .tab-content > *,
|
|
104
|
+
.thub-resource-card .tab-content > form > fieldset > * {
|
|
105
|
+
grid-area: 1 / 1;
|
|
106
|
+
min-width: 0;
|
|
96
107
|
}
|
|
97
108
|
|
|
98
|
-
.tab-content .tab-pane
|
|
109
|
+
.thub-resource-card .tab-content .tab-pane {
|
|
99
110
|
display: block;
|
|
111
|
+
visibility: hidden;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.thub-resource-card .tab-content .tab-pane.active {
|
|
115
|
+
visibility: visible;
|
|
100
116
|
}
|
|
101
117
|
|
|
102
118
|
/* Help page (views/help/): sticky table of contents, code blocks with a
|
|
@@ -233,9 +249,6 @@
|
|
|
233
249
|
itself on short screens, and is laid out for thumbs: pages as a two-column
|
|
234
250
|
grid, the tools in one row, the account in the next. public/js/actions.js
|
|
235
251
|
closes it on a tap outside, Esc, or choosing a page. */
|
|
236
|
-
.thub-nav-break {
|
|
237
|
-
display: none;
|
|
238
|
-
}
|
|
239
252
|
|
|
240
253
|
@media (max-width: 991.98px) {
|
|
241
254
|
.navbar .thub-nav {
|
|
@@ -295,15 +308,19 @@
|
|
|
295
308
|
min-height: 2.5rem;
|
|
296
309
|
}
|
|
297
310
|
|
|
298
|
-
/* The account row
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
311
|
+
/* The account row, first in the menu: you, your role, your profile, and
|
|
312
|
+
sign-out at its end. */
|
|
313
|
+
.thub-nav-me {
|
|
314
|
+
display: flex;
|
|
315
|
+
align-items: center;
|
|
316
|
+
gap: 0.75rem;
|
|
317
|
+
padding-bottom: 0.75rem;
|
|
318
|
+
margin-bottom: 0.75rem;
|
|
319
|
+
border-bottom: 1px solid var(--bs-border-color);
|
|
303
320
|
}
|
|
304
321
|
|
|
305
|
-
.thub-nav-
|
|
306
|
-
|
|
322
|
+
.thub-nav-me .btn {
|
|
323
|
+
min-width: 2.5rem;
|
|
307
324
|
min-height: 2.5rem;
|
|
308
325
|
}
|
|
309
326
|
}
|
package/src/services/registry.js
CHANGED
|
@@ -655,6 +655,17 @@ function createRegistryService(db, { bus, events }){
|
|
|
655
655
|
return get(resourceId);
|
|
656
656
|
}
|
|
657
657
|
|
|
658
|
+
// The Client's hw/sw section as it stands: what it reported at its last
|
|
659
|
+
// start, unless a saved revision hasn't reached it yet — then that one.
|
|
660
|
+
// The base for a revision that only changes something else (groups, an
|
|
661
|
+
// Import without a section) and for Export: the saved one alone could be
|
|
662
|
+
// older than the Client's file, and sending it would undo what changed
|
|
663
|
+
// there since.
|
|
664
|
+
function currentSection(r){
|
|
665
|
+
const pending = r.config_desired && r.config_applied_revision < r.config_revision;
|
|
666
|
+
return pending ? r.config_desired : (r.client_config || r.config_desired);
|
|
667
|
+
}
|
|
668
|
+
|
|
658
669
|
// { revision, type, config[, file] } while the Client hasn't applied the
|
|
659
670
|
// saved one; `file`: an import's other fields, until it's applied.
|
|
660
671
|
function pendingConfig(resourceId){
|
|
@@ -684,7 +695,7 @@ function createRegistryService(db, { bus, events }){
|
|
|
684
695
|
if (!valid){
|
|
685
696
|
throw Object.assign(new Error(`Can't import this config into ${r.name}: ${errors.join('; ')}`), { status: 400 });
|
|
686
697
|
}
|
|
687
|
-
const desired = section || r
|
|
698
|
+
const desired = section || currentSection(r);
|
|
688
699
|
if (!desired){
|
|
689
700
|
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`),
|
|
690
701
|
{ status: 400 });
|
|
@@ -714,7 +725,7 @@ function createRegistryService(db, { bus, events }){
|
|
|
714
725
|
}
|
|
715
726
|
// Writing top-level fields needs a Client that reports its config file
|
|
716
727
|
// (the same that Import needs); an older one would drop them.
|
|
717
|
-
const desired = r
|
|
728
|
+
const desired = currentSection(r);
|
|
718
729
|
if (!desired || !r.client_config_file){
|
|
719
730
|
throw Object.assign(new Error(`${r.name} is too old to take its groups from the dashboard — update its Client first`), { status: 409 });
|
|
720
731
|
}
|
|
@@ -740,7 +751,7 @@ function createRegistryService(db, { bus, events }){
|
|
|
740
751
|
}
|
|
741
752
|
// Re-shared: a file stored from an older Client may still say hw / sw.
|
|
742
753
|
const base = shareableClientConfigFile(r.client_config_file || { name: r.reported_name || r.name, type: r.type, labels: r.labels }),
|
|
743
|
-
section = r.type === 'hw' ? r
|
|
754
|
+
section = r.type === 'hw' ? currentSection(r) : null;
|
|
744
755
|
return { ...base, ...(pendingImport(r) || {}), type: r.type, ...(section ? { [HW_DEVICES_SECTION]: section } : {}) };
|
|
745
756
|
}
|
|
746
757
|
|
package/src/web/routes.js
CHANGED
|
@@ -1189,10 +1189,12 @@ function createWebRouter({ services, config }){
|
|
|
1189
1189
|
// the page the action returns — never stored anywhere.
|
|
1190
1190
|
// Searched (?q=), filtered by role (?role=user|maintainer|admin|blocked),
|
|
1191
1191
|
// sorted and paged like the other lists (§10.1).
|
|
1192
|
-
const USER_ROLE_FILTERS = ['
|
|
1192
|
+
const USER_ROLE_FILTERS = ['admin', 'maintainer', 'user', 'blocked'],
|
|
1193
1193
|
ROLE_ORDER = { user: 0, maintainer: 1, admin: 2 },
|
|
1194
|
+
// What the User column shows: first and last name, else the username.
|
|
1195
|
+
userDisplayName = (u) => `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.username,
|
|
1194
1196
|
USER_SORT_VALUE = {
|
|
1195
|
-
username: (u) => u.
|
|
1197
|
+
username: (u) => userDisplayName(u).toLowerCase(),
|
|
1196
1198
|
role: (u) => ROLE_ORDER[u.role],
|
|
1197
1199
|
status: (u) => (u.blocked ? 1 : 0),
|
|
1198
1200
|
used: (u) => u.key?.lastUsedAt || null
|
|
@@ -1230,6 +1232,7 @@ function createWebRouter({ services, config }){
|
|
|
1230
1232
|
total: searched.length,
|
|
1231
1233
|
roleFilter: role,
|
|
1232
1234
|
roleFilters: USER_ROLE_FILTERS,
|
|
1235
|
+
userDisplayName,
|
|
1233
1236
|
roleCounts,
|
|
1234
1237
|
list: { ...view, pagination, pageUrl, q },
|
|
1235
1238
|
q,
|
|
@@ -16,6 +16,10 @@ Restart=always
|
|
|
16
16
|
RestartSec=2
|
|
17
17
|
NoNewPrivileges=yes
|
|
18
18
|
ProtectSystem=strict
|
|
19
|
+
# A private, writable /tmp: ProtectSystem=strict makes the shared one
|
|
20
|
+
# read-only, and SQLite needs temp files for big sorts (a migration that
|
|
21
|
+
# rebuilds a table and its index) — without them: SQLITE_IOERR_GETTEMPPATH.
|
|
22
|
+
PrivateTmp=yes
|
|
19
23
|
ReadWritePaths=/var/lib/thub/var/lib/thub
|
|
20
24
|
|
|
21
25
|
[Install]
|
package/test/groups-tab.test.js
CHANGED
|
@@ -95,3 +95,48 @@ test('Groups tab: keeps other pending changes; refuses unknown groups and Client
|
|
|
95
95
|
const old = register(services, 'lab-old', { configFile: null });
|
|
96
96
|
assert.throws(() => services.registry.setClientGroups(old, [g.id], { knownGroupIds: [g.id] }), /too old to take its groups/);
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
test('device tabs show what the Client runs — not an old saved revision; groups/import/export build on it', async (t) => {
|
|
100
|
+
const { base, services, admin } = await start(t),
|
|
101
|
+
reg = services.registry,
|
|
102
|
+
register = (devpath) => reg.registerAuto({
|
|
103
|
+
clientId: 'hw1', name: 'lab-hw-01', type: 'hw', labels: [],
|
|
104
|
+
config: { stlinks: [{ index: 1, devpath }] }, configFile: { name: 'lab-hw-01', type: 'hw' }
|
|
105
|
+
}).resourceId,
|
|
106
|
+
id = register('1.1'),
|
|
107
|
+
devpaths = async () => {
|
|
108
|
+
const html = await (await fetch(`${base}/resources`, { headers: { cookie: admin } })).text(),
|
|
109
|
+
table = html.slice(html.indexOf(`id="cfg-${id}-stlinks"`), html.indexOf(`id="cfg-${id}-uarts"`));
|
|
110
|
+
return [...table.matchAll(/data-f="devpath" value="([^"]*)"/g)].map((m) => m[1]).filter(Boolean); // not the blank add-row template
|
|
111
|
+
};
|
|
112
|
+
assert.deepEqual(await devpaths(), ['1.1']); // as the Client reported it
|
|
113
|
+
|
|
114
|
+
// Saved on the dashboard, not applied yet: the tab shows the saved one.
|
|
115
|
+
reg.setClientConfig(id, { stlinks: [{ index: 1, devpath: '2.2' }] }, { by: 'admin' });
|
|
116
|
+
assert.deepEqual(await devpaths(), ['2.2']);
|
|
117
|
+
|
|
118
|
+
// Applied — and later the file was edited on the host; the Client restarts
|
|
119
|
+
// and reports 3.3. That's what the tab shows now, not the old 2.2.
|
|
120
|
+
services.db.prepare('UPDATE resources SET config_applied_revision = config_revision WHERE id = ?').run(id);
|
|
121
|
+
register('3.3');
|
|
122
|
+
assert.deepEqual(await devpaths(), ['3.3']);
|
|
123
|
+
|
|
124
|
+
// …and what a groups save, an export and an Import without a section send.
|
|
125
|
+
const g = services.groups.create({ name: 'g' });
|
|
126
|
+
reg.setClientGroups(id, [g.id], { by: 'admin', knownGroupIds: [g.id] });
|
|
127
|
+
assert.deepEqual(reg.pendingConfig(id).config, { stlinks: [{ index: 1, devpath: '3.3' }] }); // not 2.2 again
|
|
128
|
+
assert.deepEqual(reg.exportClientConfig(id)['hw-devices'], { stlinks: [{ index: 1, devpath: '3.3' }] });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('ST-Link tab has no Serial field; a serial set in the config file is kept on Save', async (t) => {
|
|
132
|
+
const { base, services, admin } = await start(t),
|
|
133
|
+
id = services.registry.registerAuto({
|
|
134
|
+
clientId: 'hw2', name: 'lab-hw-02', type: 'hw', labels: [],
|
|
135
|
+
config: { stlinks: [{ index: 1, serial: '066DFF48', devpath: '1.2' }] }, configFile: { name: 'lab-hw-02', type: 'hw' }
|
|
136
|
+
}).resourceId,
|
|
137
|
+
html = await (await fetch(`${base}/resources`, { headers: { cookie: admin } })).text(),
|
|
138
|
+
table = html.slice(html.indexOf(`id="cfg-${id}-stlinks"`), html.indexOf(`id="cfg-${id}-uarts"`));
|
|
139
|
+
assert.doesNotMatch(table, /data-f="serial"|>Serial</);
|
|
140
|
+
// Rides along in data-extra, which public/js/client-config.js spreads back into the row.
|
|
141
|
+
assert.match(table, /data-extra="\{"serial":"066DFF48"\}"/);
|
|
142
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/systemd-unit.test.js
|
|
3
|
+
* @description Tests: the shipped systemd unit lets SQLite write its temp files
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const test = require('node:test'),
|
|
17
|
+
assert = require('node:assert/strict'),
|
|
18
|
+
fs = require('node:fs'),
|
|
19
|
+
path = require('node:path');
|
|
20
|
+
|
|
21
|
+
test('systemd unit: ProtectSystem=strict comes with a private writable /tmp (SQLITE_IOERR_GETTEMPPATH)', () => {
|
|
22
|
+
const unit = fs.readFileSync(path.join(__dirname, '../systemd/thub-coordinator.service'), 'utf8');
|
|
23
|
+
assert.match(unit, /^ProtectSystem=strict$/m);
|
|
24
|
+
assert.match(unit, /^PrivateTmp=yes$/m);
|
|
25
|
+
});
|
package/test/users.test.js
CHANGED
|
@@ -245,7 +245,7 @@ test('Users page: quick filter by role with counts, sorting and pages; role desc
|
|
|
245
245
|
method: 'POST', body: new URLSearchParams({ username: 'root', password: 'pw' }), redirect: 'manual'
|
|
246
246
|
})).headers.get('set-cookie').split(';')[0],
|
|
247
247
|
page = (q) => fetch(`${base}/admin/users${q}`, { headers: { cookie } }).then((r) => r.text()),
|
|
248
|
-
names = (html) => [...html.matchAll(/<
|
|
248
|
+
names = (html) => [...html.matchAll(/<a class="fw-medium" href="#editUser-[^"]+"[^>]*>([^<]+)/g)].map((m) => m[1]),
|
|
249
249
|
|
|
250
250
|
users = await page('?role=user&size=10');
|
|
251
251
|
assert.match(users, /Showing 1–10 of 12 users/); // before the two below are added
|
|
@@ -259,6 +259,9 @@ test('Users page: quick filter by role with counts, sorting and pages; role desc
|
|
|
259
259
|
assert.match(users, /href="\/admin\/users\?role=user&size=10&sort=username&dir=asc&page=2"/); // pages keep the filter
|
|
260
260
|
assert.deepEqual(names(await page('?role=blocked')), ['mo']);
|
|
261
261
|
assert.match(users, />Maintainer<span class="badge/); // filter buttons
|
|
262
|
+
const filterNav = users.slice(users.indexOf('aria-label="Filter by role"'), users.indexOf('</nav>', users.indexOf('aria-label="Filter by role"')));
|
|
263
|
+
assert.deepEqual([...filterNav.matchAll(/<a [^>]*>([^<]+)</g)].map((m) => m[1].trim()), ['All', 'Admin', 'Maintainer', 'User', 'Blocked']);
|
|
264
|
+
assert.doesNotMatch(users, /d1@example\.com<\/div>|>Edit<\/button>/); // the name only; it opens Edit
|
|
262
265
|
assert.match(users, /href="[^"]*role=maintainer/); // …while values stay lowercase
|
|
263
266
|
assert.deepEqual(names(await page('?role=admin&size=all')), ['root']);
|
|
264
267
|
assert.deepEqual(names(await page('?role=&sort=role&dir=desc&size=all')).slice(0, 2), ['root', 'mo']); // admin, maintainer, users…
|
|
@@ -267,3 +270,19 @@ test('Users page: quick filter by role with counts, sorting and pages; role desc
|
|
|
267
270
|
assert.match(users, /<option value="user" selected="selected" data-help="Agent only[^"]*">User<\/option>/);
|
|
268
271
|
assert.match(users, /<div class="form-text" id="new-role-help" aria-live="polite">Agent only/);
|
|
269
272
|
});
|
|
273
|
+
|
|
274
|
+
test('Users page: the User column shows first and last name, sorts by it, and opens Edit', async (t) => {
|
|
275
|
+
const { base, services } = await start(t),
|
|
276
|
+
u = services.adminUsers;
|
|
277
|
+
u.create({ username: 'root', email: 'r@example.com', role: 'admin', password: 'pw' });
|
|
278
|
+
const zed = u.create({ username: 'aaa', email: 'a@example.com', role: 'user', firstName: 'Zed', lastName: 'Young' });
|
|
279
|
+
u.create({ username: 'zzz', email: 'z@example.com', role: 'user', firstName: 'Ann' });
|
|
280
|
+
const cookie = (await fetch(`${base}/login`, {
|
|
281
|
+
method: 'POST', body: new URLSearchParams({ username: 'root', password: 'pw' }), redirect: 'manual'
|
|
282
|
+
})).headers.get('set-cookie').split(';')[0],
|
|
283
|
+
html = await (await fetch(`${base}/admin/users?sort=username&dir=asc&size=all`, { headers: { cookie } })).text(),
|
|
284
|
+
cells = [...html.matchAll(/<a class="fw-medium" href="#editUser-([^"]+)"[^>]*>([^<]+)/g)];
|
|
285
|
+
assert.deepEqual(cells.map((m) => m[2]), ['Ann', 'root', 'Zed Young']); // no name → the username
|
|
286
|
+
assert.equal(cells[2][1], zed.id);
|
|
287
|
+
assert.match(html, new RegExp(`id="editUser-${zed.id}"`)); // the modal it opens
|
|
288
|
+
});
|
package/views/admin/users.pug
CHANGED
|
@@ -68,17 +68,10 @@ block content
|
|
|
68
68
|
tbody
|
|
69
69
|
each u in users
|
|
70
70
|
tr(class=u.blocked ? 'opacity-50' : '')
|
|
71
|
+
//- Just the name (the username until one is set); it opens Edit,
|
|
72
|
+
//- where the username, email and the rest are.
|
|
71
73
|
td
|
|
72
|
-
.fw-medium
|
|
73
|
-
= u.username
|
|
74
|
-
if u.id === me.id
|
|
75
|
-
span.badge.text-bg-light.border.ms-1 you
|
|
76
|
-
if u.firstName || u.lastName
|
|
77
|
-
.small= `${u.firstName || ''} ${u.lastName || ''}`.trim()
|
|
78
|
-
if u.email
|
|
79
|
-
.small.text-body-secondary= u.email
|
|
80
|
-
else
|
|
81
|
-
span.badge.text-bg-warning(data-bs-toggle="tooltip" data-bs-title="Every user needs an email — add it with Edit") no email
|
|
74
|
+
a.fw-medium(href=`#editUser-${u.id}` data-bs-toggle="modal" role="button")= userDisplayName(u)
|
|
82
75
|
td
|
|
83
76
|
span.badge(class=`text-bg-${roleTone[u.role]}` data-bs-toggle="tooltip" data-bs-title=roleHelp[u.role])= roleLabel(u.role)
|
|
84
77
|
td
|
|
@@ -98,8 +91,7 @@ block content
|
|
|
98
91
|
else
|
|
99
92
|
span.text-body-secondary none
|
|
100
93
|
td.text-end.text-nowrap
|
|
101
|
-
|
|
102
|
-
form.d-inline.ms-1(
|
|
94
|
+
form.d-inline(
|
|
103
95
|
method="post"
|
|
104
96
|
action=`/admin/users/${u.id}/key`
|
|
105
97
|
data-confirm=u.key ? `Issue a new access key for ${u.username}? Their current key stops working at once.` : `Create an access key for ${u.username}?`
|
|
@@ -103,7 +103,10 @@
|
|
|
103
103
|
p.small.
|
|
104
104
|
SW Clients have no settings of their own: they run whatever DUT image a job brings (#[code --docker-image]).
|
|
105
105
|
Admins can also edit an HW Client's devices from its resource card (#[strong ST-Link | UART | DUT USB]
|
|
106
|
-
tabs).
|
|
106
|
+
tabs). The tabs show the config the Client runs, as it reported it at its last start, or a saved
|
|
107
|
+
change it hasn't applied yet. #[strong Connected USB devices → Import to config] fills them from the host's #[code lsusb].
|
|
108
|
+
There's no ST-Link serial field: the job's #[code --command] picks the probe
|
|
109
|
+
(#[code $THUB_DUT_STLINK_<n>]), and the Client reads each serial from its udev symlink.
|
|
107
110
|
|
|
108
111
|
h3.h6 Several DUT slots on one machine
|
|
109
112
|
+code('One instance per slot').
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
#[code ~/.config/thub/coordinator.json] (with a random #[code sessionSecret]) and
|
|
14
14
|
#[code ~/var/lib/thub] (database, avatars). It installs, enables and starts
|
|
15
15
|
#[code thub-coordinator.service], plus the root helper behind the navbar's #[strong Update app] button.
|
|
16
|
+
The unit uses #[code ProtectSystem=strict] with #[code PrivateTmp=yes], so the data directory and a
|
|
17
|
+
private #[code /tmp] (SQLite's temp files) are the only places the service can write.
|
|
16
18
|
|
|
17
19
|
h3.h6 2. Configure
|
|
18
20
|
p.small.
|
|
@@ -123,6 +125,9 @@
|
|
|
123
125
|
thub-admin resource maintenance <resourceId> --on
|
|
124
126
|
thub-admin jobs reset --yes # cancel every active job
|
|
125
127
|
thub-admin jobs clean --yes --before "01/09/2026 00:00:00"
|
|
128
|
+
p.small.
|
|
129
|
+
On #[a(href="/admin/users") Users] (admins), click a name to edit that user. The quick filter is
|
|
130
|
+
#[strong All | Admin | Maintainer | User | Blocked]. The search box also matches usernames and emails.
|
|
126
131
|
+note('warning').
|
|
127
132
|
Forgot the admin password? Start the Coordinator once with #[code THUB_BOOTSTRAP_ADMIN_PASSWORD=…]
|
|
128
133
|
(and optionally #[code THUB_BOOTSTRAP_ADMIN_USER], default #[code admin]). The password is reset on
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
['429 Too Many Requests', 'A caller went over `rateLimit.requestsPerMinute` (per agent or resource token, per dashboard user, else per IP; default 500). The Agent and Client wait for `Retry-After` and retry by themselves; a script calling the API should do the same. Raise the limit in the config file or on Settings if your normal traffic needs more (a Client streaming logs makes up to ~240 requests a minute). Too many sign-in attempts from one address: wait a minute (`rateLimit.loginPerMinute`).'],
|
|
22
22
|
['Sign-in says “Too many failed sign-ins”', 'After repeated wrong passwords the Coordinator makes each further attempt wait, doubling each time: per address from the 5th failure (up to 15 min), per username from the 10th from any address (up to 5 min). Wait the time shown, or sign in from another address; a successful sign-in clears it, and an hour without failures forgets it. The Coordinator never tells whether a username exists.'],
|
|
23
23
|
['Agent says “Invalid or revoked access key” or “… is blocked”', 'The key was replaced (`thub key rotate`, an admin, your profile) or revoked, or your user is blocked. Ask an admin for a new key (Users), or create one on your profile if you can sign in. Then `thub config set key <key>`; `thub whoami` checks it.'],
|
|
24
|
+
['The Coordinator won\'t start after an upgrade: `SqliteError: disk I/O error` (`SQLITE_IOERR_GETTEMPPATH`)', 'Its systemd unit is older than 1.1.1: it has `ProtectSystem=strict` without `PrivateTmp=yes`, so SQLite has no writable temp directory for a large migration. The migration rolls back, so no data is lost. Install the latest version with `sudo npm i -g @andrian.yablonskyy/thub-coordinator` (the dashboard can\'t update a Coordinator that\'s down), or run `sudo systemctl edit thub-coordinator`, add `[Service]` and `PrivateTmp=yes`, then `sudo systemctl restart thub-coordinator`.'],
|
|
24
25
|
['Locked out of the dashboard', 'Restart the Coordinator once with `THUB_BOOTSTRAP_ADMIN_PASSWORD=<new>` (resets the `admin` user), then unset it.']
|
|
25
26
|
]
|
|
26
27
|
const withCode = (text) => text
|
package/views/layout.pug
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
//- The signed-in user's avatar, or their initials in a circle.
|
|
2
|
+
mixin navAvatar(px)
|
|
3
|
+
if user.avatarPath
|
|
4
|
+
img.rounded-circle.me-2(src=user.avatarPath width=px height=px style="object-fit: cover;")
|
|
5
|
+
else
|
|
6
|
+
span.rounded-circle.bg-secondary.text-white.d-inline-flex.align-items-center.justify-content-center.me-2.flex-shrink-0(style=`width: ${px}px; height: ${px}px; font-size: ${Math.round(px / 2)}px;`)
|
|
7
|
+
= ((user.firstName ? user.firstName[0] : user.username[0]) + (user.lastName ? user.lastName[0] : '')).toUpperCase()
|
|
8
|
+
|
|
1
9
|
doctype html
|
|
2
10
|
html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
3
11
|
head
|
|
@@ -30,6 +38,18 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
30
38
|
button.navbar-toggler(type="button" data-bs-toggle="collapse" data-bs-target="#nav")
|
|
31
39
|
span.navbar-toggler-icon
|
|
32
40
|
#nav.collapse.navbar-collapse.thub-nav
|
|
41
|
+
//- Phones/tablets: who you are comes first — name, role, profile,
|
|
42
|
+
//- sign-out (thub.css .thub-nav-me). Desktop keeps it on the right.
|
|
43
|
+
if user
|
|
44
|
+
.thub-nav-me.d-lg-none
|
|
45
|
+
a.d-flex.align-items-center.text-reset.text-decoration-none.flex-grow-1(href="/profile")
|
|
46
|
+
+navAvatar(36)
|
|
47
|
+
span.d-flex.flex-column.lh-sm
|
|
48
|
+
span.fw-medium= user.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : user.username
|
|
49
|
+
span.small.text-body-secondary= `${roleLabel(user.role)} · profile`
|
|
50
|
+
form(action="/logout" method="post")
|
|
51
|
+
button.btn.btn-outline-danger(type="submit" aria-label="Log out")
|
|
52
|
+
i.bi.bi-box-arrow-right
|
|
33
53
|
//- Below lg the menu drops down over the page (thub.css .thub-nav,
|
|
34
54
|
//- closed by public/js/actions.js): pages as a two-column grid with
|
|
35
55
|
//- icons, then the tools in a row, then the account row.
|
|
@@ -112,22 +132,16 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
112
132
|
)
|
|
113
133
|
i.bi.bi-circle-half
|
|
114
134
|
if user
|
|
115
|
-
li.
|
|
116
|
-
li.nav-item.d-flex.align-items-center.me-3.thub-nav-account
|
|
135
|
+
li.nav-item.d-none.d-lg-flex.align-items-center.me-3
|
|
117
136
|
a.nav-link.d-flex.align-items-center.p-0(
|
|
118
137
|
href="/profile"
|
|
119
138
|
data-bs-toggle="tooltip"
|
|
120
139
|
data-bs-placement="bottom"
|
|
121
140
|
data-bs-title=`Profile settings (${roleLabel(user.role)})`
|
|
122
141
|
)
|
|
123
|
-
|
|
124
|
-
img.rounded-circle.me-2(src=user.avatarPath width="24" height="24" style="object-fit: cover;")
|
|
125
|
-
else
|
|
126
|
-
span.rounded-circle.bg-secondary.text-white.d-inline-flex.align-items-center.justify-content-center.me-2(style="width: 24px; height: 24px; font-size: 12px;")
|
|
127
|
-
= ((user.firstName ? user.firstName[0] : user.username[0]) + (user.lastName ? user.lastName[0] : '')).toUpperCase()
|
|
142
|
+
+navAvatar(24)
|
|
128
143
|
span= user.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : user.username
|
|
129
|
-
|
|
130
|
-
li.nav-item.d-flex.align-items-center
|
|
144
|
+
li.nav-item.d-none.d-lg-flex.align-items-center
|
|
131
145
|
form(action="/logout" method="post")
|
|
132
146
|
button.btn.btn-sm.btn-outline-danger(
|
|
133
147
|
type="submit"
|
|
@@ -5,14 +5,13 @@
|
|
|
5
5
|
//- what the Client reported at registration.
|
|
6
6
|
|
|
7
7
|
//- One device row: "Device" is a udev index (1-8) or a /dev path; fields the
|
|
8
|
-
//- form doesn't show (vendorId/productId/subsystem
|
|
8
|
+
//- form doesn't show (vendorId/productId/subsystem, an ST-Link serial set in
|
|
9
|
+
//- the config file) ride along in data-extra.
|
|
9
10
|
mixin deviceRow(kind, d)
|
|
10
|
-
- const known = ['index', 'path', '
|
|
11
|
+
- const known = ['index', 'path', 'baudRate', 'devpath']
|
|
11
12
|
- const extra = Object.fromEntries(Object.entries(d || {}).filter(([k]) => !known.includes(k)))
|
|
12
13
|
tr(data-row=kind data-extra=JSON.stringify(extra))
|
|
13
14
|
td: input.form-control.form-control-sm.font-monospace(data-f="device" value=d ? (d.path || d.index || '') : '' placeholder="1 or /dev/…" required)
|
|
14
|
-
if kind === 'stlinks'
|
|
15
|
-
td: input.form-control.form-control-sm.font-monospace(data-f="serial" value=d && d.serial || '' placeholder="(from udev)")
|
|
16
15
|
if kind === 'uarts'
|
|
17
16
|
td: input.form-control.form-control-sm(data-f="baudRate" type="number" min="50" value=d && d.baudRate || '' placeholder="115200")
|
|
18
17
|
td: input.form-control.form-control-sm.font-monospace(data-f="devpath" value=d && d.devpath || '' placeholder="3.3.4.1")
|
|
@@ -162,7 +161,13 @@ mixin groupsPane(r, returnTo, groupsById)
|
|
|
162
161
|
|
|
163
162
|
mixin configPanes(r, returnTo, groupsById)
|
|
164
163
|
- const p = `cfg-${r.id}`
|
|
165
|
-
|
|
164
|
+
//- What the Client runs — its config as it reported it at its last start
|
|
165
|
+
//- — unless a saved revision is still on its way to it: then that one, so
|
|
166
|
+
//- it can be checked or changed before it's applied. (Always preferring the
|
|
167
|
+
//- saved revision showed an old one forever, after the file changed on the
|
|
168
|
+
//- host or an Import.)
|
|
169
|
+
- const pendingRevision = r.config_desired && r.config_applied_revision < r.config_revision
|
|
170
|
+
- const current = pendingRevision ? r.config_desired : (r.client_config || r.config_desired)
|
|
166
171
|
- const canEdit = can.operate
|
|
167
172
|
if r.type !== 'hw'
|
|
168
173
|
//- no device tabs
|
|
@@ -185,8 +190,8 @@ mixin configPanes(r, returnTo, groupsById)
|
|
|
185
190
|
fieldset(disabled=!canEdit)
|
|
186
191
|
+configPane(p, 'stlinks')
|
|
187
192
|
+capabilitiesStatus(r)
|
|
188
|
-
+deviceTable('stlinks', 'ST-Link probes', current.stlinks || [], ['Device', '
|
|
189
|
-
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-stlink) or a /dev path.
|
|
193
|
+
+deviceTable('stlinks', 'ST-Link probes', current.stlinks || [], ['Device', 'udev devpath'])
|
|
194
|
+
.form-text Device: a udev index 1–8 (→ /dev/thub/dut<N>-stlink) or a /dev path. Which probe a job uses is up to its #[code --command] (#[code $THUB_DUT_STLINK_<n>]).
|
|
190
195
|
+saveCapabilities(canEdit)
|
|
191
196
|
+configPane(p, 'uarts')
|
|
192
197
|
+capabilitiesStatus(r)
|