@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 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.0",
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.0",
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",
@@ -86,17 +86,33 @@
86
86
  padding: 0.45rem 0.65rem;
87
87
  }
88
88
 
89
- /* Bootstrap hides inactive tab panes only as direct children of
90
- .tab-content (`.tab-content > .tab-pane`). The client details modal's
91
- capability tabs sit inside their shared <form>/<fieldset> (one Save for
92
- all of them), so without this the inactive ones merely fade out and still
93
- take up space, pushing the active tab's fields down. */
94
- .tab-content .tab-pane[data-config-pane] {
95
- display: none;
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[data-config-pane].active {
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: the profile link takes the line, sign-out at its end. */
299
- .thub-nav-break {
300
- display: block;
301
- flex-basis: 100%;
302
- height: 0;
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-account {
306
- flex: 1 1 auto;
322
+ .thub-nav-me .btn {
323
+ min-width: 2.5rem;
307
324
  min-height: 2.5rem;
308
325
  }
309
326
  }
@@ -34,7 +34,6 @@
34
34
  return compact({
35
35
  ...extra,
36
36
  ...device(text(tr, 'device')),
37
- serial: text(tr, 'serial'),
38
37
  baudRate: num(tr, 'baudRate'),
39
38
  devpath: text(tr, 'devpath')
40
39
  });
@@ -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.config_desired || r.client_config;
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.config_desired || r.client_config;
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.config_desired || r.client_config : null;
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 = ['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]
@@ -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="\{&quot;serial&quot;:&quot;066DFF48&quot;\}"/);
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
+ });
@@ -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
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.thub-nav-break(aria-hidden="true")
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
- if user.avatarPath
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
- span.small.text-body-secondary.ms-2.d-lg-none= `${roleLabel(user.role)} · profile`
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) ride along in data-extra.
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', 'serial', 'baudRate', 'devpath']
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
- - const current = r.config_desired || r.client_config
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', 'Serial', 'udev devpath'])
189
- .form-text Device: a udev index 1–8 (→ /dev/thub/dut&lt;N&gt;-stlink) or a /dev path. The serial is looked up from the udev symlink when empty.
193
+ +deviceTable('stlinks', 'ST-Link probes', current.stlinks || [], ['Device', 'udev devpath'])
194
+ .form-text Device: a udev index 1–8 (→ /dev/thub/dut&lt;N&gt;-stlink) or a /dev path. Which probe a job uses is up to its #[code --command] (#[code $THUB_DUT_STLINK_&lt;n&gt;]).
190
195
  +saveCapabilities(canEdit)
191
196
  +configPane(p, 'uarts')
192
197
  +capabilitiesStatus(r)