@andrian.yablonskyy/thub-coordinator 1.1.1 → 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 +1 -1
- package/src/web/routes.js +5 -2
- package/systemd/thub-coordinator.service +4 -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/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": {
|
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]
|
|
@@ -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
|