@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 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.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 = ['user', 'maintainer', 'admin', 'blocked'],
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.username.toLowerCase(),
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
+ });
@@ -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(/<div class="fw-medium">([^<]+)/g)].map((m) => m[1]),
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&amp;size=10&amp;sort=username&amp;dir=asc&amp;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
+ });
@@ -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
- button.btn.btn-sm.btn-outline-secondary(type="button" data-bs-toggle="modal" data-bs-target=`#editUser-${u.id}` aria-label=`Edit ${u.username}`) Edit
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). #[strong Connected USB devices → Import to config] fills them from the host's #[code lsusb].
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_&lt;n&gt;]), 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 &lt;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