@andrian.yablonskyy/thub-coordinator 1.0.10 → 1.0.12

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
@@ -102,7 +102,7 @@ thub-admin group add ci-nightly --comment "shared CI pool"
102
102
  thub-admin group list
103
103
  thub-admin group remove <groupId>
104
104
 
105
- # The Coordinator is only ever updated by hand (never from the dashboard).
105
+ # Update the Coordinator by hand (the dashboard's "Update app" button does the same through a root helper).
106
106
  thub-admin check-update # installed vs latest published version
107
107
  thub-admin self-update [--to <x.y.z>] # sudo npm i -g; the postinstall restarts the service
108
108
  ```
@@ -157,14 +157,14 @@ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla J
157
157
  | `/admin/agents` | Register/revoke CI and developer agents; token shown once. Shows each agent's last reported version. |
158
158
  | `/profile` | Every logged-in user's own account settings: display name, avatar, timezone (renders every dashboard timestamp), theme, idle session timeout, password change. |
159
159
 
160
- **Resource card.** Clicking a resource on `/` or `/resources` opens a card with its name, type, status, Client version, IP addresses, labels, groups, busy source/reason, last heartbeat and (admins only) the maintenance/rotate-token actions. IP addresses are the Client host's interface addresses except loopback (reported at registration and on every heartbeat) plus the external address — the one the Coordinator last saw the Client connect from, taken from `X-Forwarded-For` when the request came through a proxy `trustProxy` trusts.
160
+ **Resource card.** Clicking a resource on `/` or `/resources` opens a card with its name, type, status, Client version, IP addresses, capabilities (reported at registration: HW udev devices with a **missing** flag for any whose device node doesn't exist, relay channels and power control; SW image, source and limits), labels, groups, busy source/reason, last heartbeat and (admins only) the maintenance/rotate-token actions. IP addresses are the Client host's interface addresses except loopback (reported at registration and on every heartbeat) plus the external address — the one the Coordinator last saw the Client connect from, taken from `X-Forwarded-For` when the request came through a proxy `trustProxy` trusts.
161
161
 
162
162
  The Coordinator's own version (`ver. X.Y.Z`) is shown under the TestHub logo, top left of every page.
163
163
 
164
- **Versions and self-update.** The Coordinator checks the npm registry for the latest Coordinator, Agent and Client every `updates.checkIntervalHours` (default 6, `0` disables; `updates.registry` for a private mirror) and on **Check for updates**. A newer Coordinator shows as a badge under the logo; Agents and Resources show each one's version with an **update available** or pending **→ vX.Y.Z** badge. Admins request self-updates per agent/resource (**Update**, which becomes **Cancel update**) or with **Update all agents** / **Update all clients**, always to the latest version:
164
+ **Versions and self-update.** The Coordinator checks the npm registry for the latest Coordinator, Agent and Client every `updates.checkIntervalHours` (default 6, `0` disables; `updates.registry` for a private mirror) and on **Check for updates**. A newer Coordinator shows as an **Update app to X.Y.Z** button in the navbar for admins (a badge under the logo for viewers); Agents and Resources show each one's version with an **update available** or pending **→ vX.Y.Z** badge. Admins request self-updates per agent/resource (**Update**, which becomes **Cancel update**) or with **Update all agents** / **Update all clients**, always to the latest version:
165
165
  - Clients get a `self-update` command on their next heartbeat and install it through their root `thub-client-update` helper, once none of the host's instances is busy.
166
166
  - Agents install it at the start of their next run (`GET /api/v1/agents/me/update`), then re-run the command on the new version.
167
- - The Coordinator itself is updated by hand only: `thub-admin self-update`.
167
+ - The Coordinator itself: the **Update app** button writes `<dataDir>/update-request.json`; the root `thub-coordinator-update.path` unit (installed by `sudo npm i -g`) runs `npm i -g` for it, and the postinstall restarts the service (the dashboard and API are briefly down). Logs: `journalctl -u thub-coordinator-update`. Without that unit, use `thub-admin self-update` on the host.
168
168
 
169
169
  A request is cleared when the Agent/Client reports the new version.
170
170
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
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": {
@@ -24,7 +24,12 @@ const fs = require('node:fs'),
24
24
  UNIT_SRC = path.join(__dirname, '..', 'systemd', UNIT_NAME),
25
25
  SERVER_PATH = path.join(__dirname, '..', 'src', 'server.js'),
26
26
  UNIT_DEST = `/etc/systemd/system/${UNIT_NAME}`,
27
- MANUAL_HINT = 'sudo npm i -g @andrian.yablonskyy/thub-coordinator';
27
+ MANUAL_HINT = 'sudo npm i -g @andrian.yablonskyy/thub-coordinator',
28
+ // Root-side self-update (README §10.2): the path unit watches the update
29
+ // request the dashboard writes, the service installs it.
30
+ UPDATE_PATH_UNIT = 'thub-coordinator-update.path',
31
+ UPDATE_SERVICE_UNIT = 'thub-coordinator-update.service',
32
+ UPDATE_HELPER = path.join(__dirname, 'self-update-helper.js');
28
33
 
29
34
  // The checked-in unit only has placeholders — fill in the target user and
30
35
  // wherever *this* install's node and server.js actually are, so it works
@@ -38,6 +43,19 @@ function renderUnit(user, paths){
38
43
  .replace(/^ReadWritePaths=.*$/m, `ReadWritePaths=${paths.dataDir}`);
39
44
  }
40
45
 
46
+ function renderUpdateUnits(user, paths){
47
+ const nodeDir = path.dirname(process.execPath),
48
+ unitPath = (name) => path.join(__dirname, '..', 'systemd', name);
49
+ return {
50
+ [UPDATE_PATH_UNIT]: fs.readFileSync(unitPath(UPDATE_PATH_UNIT), 'utf8')
51
+ .replace(/^PathModified=.*$/m, `PathModified=${paths.updateRequestFile}`),
52
+ // npm is a `#!/usr/bin/env node` script, so this node goes first on PATH.
53
+ [UPDATE_SERVICE_UNIT]: fs.readFileSync(unitPath(UPDATE_SERVICE_UNIT), 'utf8')
54
+ .replace(/^Environment=PATH=.*$/m, `Environment=PATH=${nodeDir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`)
55
+ .replace(/^ExecStart=.*$/m, `ExecStart=${process.execPath} ${UPDATE_HELPER} ${paths.updateRequestFile} ${user.name}`)
56
+ };
57
+ }
58
+
41
59
  // Best-effort, never fails the `npm install` itself. Runs after
42
60
  // install-default-config.js, so the config and data dirs the unit points
43
61
  // at already exist. `restart` (not `start`) so an upgrade picks up the
@@ -51,12 +69,18 @@ function main(){
51
69
  return;
52
70
  }
53
71
 
54
- const user = resolveTargetUser();
72
+ const user = resolveTargetUser(),
73
+ paths = targetPaths(user);
55
74
  let step = 'write';
56
75
  try {
57
- fs.writeFileSync(UNIT_DEST, renderUnit(user, targetPaths(user)));
76
+ fs.writeFileSync(UNIT_DEST, renderUnit(user, paths));
77
+ for (const [name, content]of Object.entries(renderUpdateUnits(user, paths))){
78
+ fs.writeFileSync(`/etc/systemd/system/${name}`, content);
79
+ }
58
80
  step = 'daemon-reload';
59
81
  execFileSync('systemctl', ['daemon-reload'], { stdio: 'ignore' });
82
+ step = `enable ${UPDATE_PATH_UNIT}`;
83
+ execFileSync('systemctl', ['enable', '--now', UPDATE_PATH_UNIT], { stdio: 'ignore' });
60
84
  step = 'enable';
61
85
  execFileSync('systemctl', ['enable', UNIT_NAME], { stdio: 'ignore' });
62
86
  step = 'restart';
@@ -66,6 +66,9 @@ function targetPaths(user){
66
66
  configPath,
67
67
  defaultDataDir,
68
68
  dataDir,
69
+ // Written by the dashboard's "Update app" button, watched by
70
+ // thub-coordinator-update.path (README §10.2).
71
+ updateRequestFile: path.join(dataDir, 'update-request.json'),
69
72
  dataSubdirs: ['artifacts', 'avatars', 'work'].map((d) => path.join(dataDir, d))
70
73
  };
71
74
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @file scripts/self-update-helper.js
3
+ * @description Root side of the Coordinator's self-update (README §10.2): run by thub-coordinator-update.service
4
+ * when the dashboard writes an update request, installs the requested version with npm i -g
5
+ *
6
+ * @author Andrian Yablonskyy
7
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
8
+ *
9
+ * This file is part of TestHub and is proprietary and confidential.
10
+ * Unauthorized copying, modification, distribution, or use of this file,
11
+ * via any medium, is strictly prohibited without prior written permission
12
+ * from AdSystem.PRO.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('node:fs'),
18
+ { PACKAGES, isValidVersion, compareVersions, npmInstallGlobal } = require('@andrian.yablonskyy/thub-common'),
19
+ { version: installedVersion } = require('../package.json');
20
+
21
+ // argv: <request file> <user the Coordinator runs as>. The request file
22
+ // is written by the (unprivileged) Coordinator, so only its `version` is
23
+ // used, and only if it's a plain semver — the package is fixed.
24
+ function main(){
25
+ const [requestFile, user] = process.argv.slice(2);
26
+ if (!requestFile || !user){
27
+ console.error('usage: self-update-helper.js <request file> <user>');
28
+ return 2;
29
+ }
30
+
31
+ let request;
32
+ try {
33
+ request = JSON.parse(fs.readFileSync(requestFile, 'utf8'));
34
+ }
35
+ catch (err){
36
+ // Deleted by a previous run (PathModified= also fires on removal).
37
+ if (err.code === 'ENOENT'){
38
+ return 0;
39
+ }
40
+ console.error(`thub-coordinator-update: unreadable request ${requestFile}: ${err.message}`);
41
+ fs.rmSync(requestFile, { force: true });
42
+ return 1;
43
+ }
44
+ fs.rmSync(requestFile, { force: true });
45
+
46
+ const target = request?.version;
47
+ if (!isValidVersion(target)){
48
+ console.error('thub-coordinator-update: ignoring request with an invalid version');
49
+ return 1;
50
+ }
51
+ if (compareVersions(installedVersion, target) >= 0){
52
+ console.log(`thub-coordinator-update: already on v${installedVersion} (requested v${target})`);
53
+ return 0;
54
+ }
55
+
56
+ console.log(`thub-coordinator-update: v${installedVersion} -> v${target} (requested by ${request.requestedBy || 'the dashboard'})`);
57
+ // SUDO_USER makes the postinstall scripts target the Coordinator's user,
58
+ // as a `sudo npm i -g` by that user would (install-target.js); the
59
+ // postinstall then restarts thub-coordinator on the new version.
60
+ const status = npmInstallGlobal(PACKAGES.coordinator, target, { env: { ...process.env, SUDO_USER: user } });
61
+ if (status !== 0){
62
+ console.error(`thub-coordinator-update: npm i -g failed (exit ${status})`);
63
+ }
64
+ return status;
65
+ }
66
+
67
+ process.exit(main());
@@ -65,6 +65,7 @@ function createResourceRouter({ services, config }){
65
65
  labels: labels || capabilities?.labels || [],
66
66
  groups: groups || [],
67
67
  hostInfo,
68
+ capabilities,
68
69
  remoteAddr: req.ip,
69
70
  clientVersion: appVersion(req, 'client')
70
71
  });
@@ -0,0 +1,5 @@
1
+ -- What the Client reported it can drive at registration (README §5.1,
2
+ -- §10): its udev devices (with whether each is present), relays and power
3
+ -- control for HW, or emulator image and limits for SW. JSON, NULL for
4
+ -- Clients too old to report it.
5
+ ALTER TABLE resources ADD COLUMN capabilities TEXT;
@@ -25,7 +25,8 @@ function rowToResource(row){
25
25
  ...row,
26
26
  labels: JSON.parse(row.labels || '[]'),
27
27
  group_ids: JSON.parse(row.group_ids || '[]'),
28
- host_info: row.host_info ? JSON.parse(row.host_info) : null
28
+ host_info: row.host_info ? JSON.parse(row.host_info) : null,
29
+ capabilities: row.capabilities ? JSON.parse(row.capabilities) : null
29
30
  };
30
31
  }
31
32
 
@@ -51,6 +52,52 @@ function normalizeRemoteAddr(addr){
51
52
  return typeof addr === 'string' && addr ? addr.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/, '$1') : null;
52
53
  }
53
54
 
55
+ // `capabilities` comes straight from the Client and is rendered on the
56
+ // dashboard, so keep only the known shape, as plain strings/numbers/bools.
57
+ const MAX_DEVICES = 8,
58
+ str = (v, max = 256) => (typeof v === 'string' && v ? v.slice(0, max) : null),
59
+ num = (v) => (Number.isFinite(v) ? v : null),
60
+ capList = (v) => (Array.isArray(v) ? v.slice(0, MAX_DEVICES) : []),
61
+ device = (d) => ({
62
+ path: str(d?.path),
63
+ index: num(d?.index),
64
+ serial: str(d?.serial, 64),
65
+ baudRate: num(d?.baudRate),
66
+ present: typeof d?.present === 'boolean' ? d.present : null
67
+ });
68
+
69
+ function sanitizeCapabilities(caps){
70
+ if (!caps || typeof caps !== 'object'){
71
+ return null;
72
+ }
73
+ if (caps.sw && typeof caps.sw === 'object'){
74
+ const sw = caps.sw;
75
+ return {
76
+ sw: {
77
+ image: str(sw.image),
78
+ registry: str(sw.registry),
79
+ allowDockerHub: sw.allowDockerHub === true,
80
+ cpus: num(sw.cpus),
81
+ memory: str(sw.memory, 16)
82
+ }
83
+ };
84
+ }
85
+ if (caps.hw && typeof caps.hw === 'object'){
86
+ const hw = caps.hw,
87
+ power = hw.power && typeof hw.power === 'object' ? hw.power : null;
88
+ return {
89
+ hw: {
90
+ stlinks: capList(hw.stlinks).map(device),
91
+ uarts: capList(hw.uarts).map(device),
92
+ usbs: capList(hw.usbs).map(device),
93
+ relays: capList(hw.relays).map((r) => ({ channel: num(r?.channel), baseUrl: str(r?.baseUrl) })),
94
+ power: power ? { method: str(power.method, 32), hub: str(power.hub, 64), port: num(power.port) } : null
95
+ }
96
+ };
97
+ }
98
+ return null;
99
+ }
100
+
54
101
  function createRegistryService(db, { bus, events }){
55
102
  function get(id){
56
103
  return rowToResource(db.prepare('SELECT * FROM resources WHERE id = ?').get(id));
@@ -106,10 +153,12 @@ function createRegistryService(db, { bus, events }){
106
153
  // this registration is allowed to reclaim it. Anything else (IDLE, BUSY,
107
154
  // MAINTENANCE, REGISTERED-but-just-created) means a process might still
108
155
  // be actively using that identity, so it stays a hard conflict.
109
- function registerAuto({ clientId, name, type, labels = [], groups = [], hostInfo, remoteAddr, clientVersion = null }){
156
+ function registerAuto({ clientId, name, type, labels = [], groups = [], hostInfo, capabilities, remoteAddr, clientVersion = null }){
110
157
  const resourceToken = generateToken('res'),
111
158
  remote = normalizeRemoteAddr(remoteAddr);
112
159
  hostInfo = { ...(hostInfo || {}), addresses: sanitizeAddresses(hostInfo?.addresses) || [] };
160
+ const caps = sanitizeCapabilities(capabilities),
161
+ capsJson = caps ? JSON.stringify(caps) : null;
113
162
  let existing = getByClientId(clientId);
114
163
 
115
164
  if (!existing){
@@ -157,7 +206,7 @@ function createRegistryService(db, { bus, events }){
157
206
  db.prepare(
158
207
  `UPDATE resources
159
208
  SET token_hash = ?, client_id = ?, name = ?, type = ?, labels = ?, group_ids = ?, host_info = ?,
160
- remote_addr = ?, client_version = ?, status = ?, busy_source = NULL, busy_reason = NULL
209
+ remote_addr = ?, client_version = ?, capabilities = ?, status = ?, busy_source = NULL, busy_reason = NULL
161
210
  WHERE id = ?`
162
211
  ).run(
163
212
  hashToken(resourceToken),
@@ -169,6 +218,7 @@ function createRegistryService(db, { bus, events }){
169
218
  JSON.stringify(hostInfo),
170
219
  remote,
171
220
  clientVersion,
221
+ capsJson,
172
222
  nextStatus,
173
223
  existing.id
174
224
  );
@@ -187,8 +237,9 @@ function createRegistryService(db, { bus, events }){
187
237
 
188
238
  const id = `res_${uuid()}`;
189
239
  db.prepare(
190
- `INSERT INTO resources (id, name, type, status, labels, group_ids, host_info, remote_addr, client_version, token_hash, client_id, created_at)
191
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
240
+ `INSERT INTO resources (id, name, type, status, labels, group_ids, host_info, remote_addr, client_version, capabilities,
241
+ token_hash, client_id, created_at)
242
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
192
243
  ).run(
193
244
  id,
194
245
  name,
@@ -199,6 +250,7 @@ function createRegistryService(db, { bus, events }){
199
250
  JSON.stringify(hostInfo),
200
251
  remote,
201
252
  clientVersion,
253
+ capsJson,
202
254
  hashToken(resourceToken),
203
255
  clientId,
204
256
  new Date().toISOString()
@@ -13,15 +13,21 @@
13
13
 
14
14
  'use strict';
15
15
 
16
- const { PACKAGES, fetchLatestVersion, defaultRegistry, isNewer } = require('@andrian.yablonskyy/thub-common'),
16
+ const fs = require('node:fs'),
17
+ path = require('node:path'),
18
+ { PACKAGES, fetchLatestVersion, defaultRegistry, isNewer } = require('@andrian.yablonskyy/thub-common'),
17
19
  { version: coordinatorVersion } = require('../../package.json');
18
20
 
21
+ // Installed by scripts/install-systemd-unit.js; runs the actual `npm i -g`
22
+ // as root when the dashboard writes an update request (README §10.2).
23
+ const UPDATE_PATH_UNIT = '/etc/systemd/system/thub-coordinator-update.path';
24
+
19
25
  // Latest published versions, refreshed every `updates.checkIntervalHours`
20
26
  // and on demand from the dashboard. Kept in memory only — a restart just
21
27
  // checks again.
22
28
  function createUpdatesService({ config }){
23
29
  const registry = config.updates.registry || defaultRegistry(),
24
- state = { latest: {}, checkedAt: null, error: null };
30
+ state = { latest: {}, checkedAt: null, error: null, coordinatorPending: null };
25
31
  let inFlight = null;
26
32
 
27
33
  async function checkNow(){
@@ -55,6 +61,28 @@ function createUpdatesService({ config }){
55
61
  };
56
62
  }
57
63
 
64
+ // Navbar "Update app" button: hand the latest version to the root
65
+ // thub-coordinator-update helper, whose install restarts this process
66
+ // (so `coordinatorPending` only lasts until then).
67
+ function requestCoordinatorUpdate(requestedBy){
68
+ const target = status().coordinatorUpdate;
69
+ if (!target){
70
+ throw Object.assign(new Error(`Already on the latest version (v${coordinatorVersion}).`), { status: 409 });
71
+ }
72
+ if (!fs.existsSync(UPDATE_PATH_UNIT)){
73
+ throw Object.assign(
74
+ new Error(`Self-update isn't installed on this host (${UPDATE_PATH_UNIT}) — update by hand: thub-admin self-update`),
75
+ { status: 501 }
76
+ );
77
+ }
78
+ fs.writeFileSync(
79
+ path.join(config.dataDir, 'update-request.json'),
80
+ JSON.stringify({ version: target, requestedBy, requestedAt: new Date().toISOString() }) + '\n'
81
+ );
82
+ state.coordinatorPending = target;
83
+ return target;
84
+ }
85
+
58
86
  // The version a self-update request targets: the latest known one,
59
87
  // checking first if nothing has been fetched yet.
60
88
  async function targetVersion(app){
@@ -77,7 +105,7 @@ function createUpdatesService({ config }){
77
105
  timer.unref?.();
78
106
  }
79
107
 
80
- return { checkNow, status, targetVersion, start };
108
+ return { checkNow, status, targetVersion, requestCoordinatorUpdate, start };
81
109
  }
82
110
 
83
111
  module.exports = { createUpdatesService };
package/src/web/routes.js CHANGED
@@ -60,6 +60,7 @@ function createWebRouter({ services, config }){
60
60
  // Latest published versions (README §10.2) — navbar, Agents, Resources.
61
61
  res.locals.updates = services.updates.status();
62
62
  res.locals.isNewer = isNewer;
63
+ res.locals.currentPath = req.originalUrl;
63
64
  if (req.session){
64
65
  req.session.flash = [];
65
66
  }
@@ -259,6 +260,12 @@ function createWebRouter({ services, config }){
259
260
  return s.error ? `Version check: ${found || 'nothing found'} (errors: ${s.error})` : `Latest versions: ${found}.`;
260
261
  }));
261
262
 
263
+ router.post('/updates/coordinator', requireAdminRole, updateAction(async (req) => {
264
+ const version = services.updates.requestCoordinatorUpdate(req.session.user.username);
265
+ console.log(`Coordinator self-update to v${version} requested by ${req.session.user.username}`);
266
+ return `Updating the Coordinator to v${version} — the dashboard restarts in a minute or two; reload the page then.`;
267
+ }));
268
+
262
269
  router.post('/resources/update-all', requireAdminRole, updateAction(async () => {
263
270
  const version = await services.updates.targetVersion('client'),
264
271
  n = services.registry.requestUpdateAll(version);
@@ -0,0 +1,13 @@
1
+ [Unit]
2
+ Description=TestHub Coordinator self-update request watcher
3
+
4
+ # PathModified= is rewritten by scripts/install-systemd-unit.js to the
5
+ # update-request.json in the Coordinator's dataDir. The dashboard's
6
+ # "Update app" button writes it; this starts thub-coordinator-update.service
7
+ # (as root) to install the requested version.
8
+ [Path]
9
+ PathModified=/var/lib/thub/.thub/update-request.json
10
+ Unit=thub-coordinator-update.service
11
+
12
+ [Install]
13
+ WantedBy=multi-user.target
@@ -0,0 +1,16 @@
1
+ [Unit]
2
+ Description=TestHub Coordinator self-update
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ # ExecStart and Environment= are rewritten by scripts/install-systemd-unit.js
7
+ # to this install's real node and helper paths, the request file, and the
8
+ # user the Coordinator runs as. Runs as root: `npm i -g` writes the global
9
+ # node_modules, and its postinstall re-renders these units and restarts
10
+ # thub-coordinator. The helper only ever installs
11
+ # @andrian.yablonskyy/thub-coordinator, at a strictly validated version.
12
+ [Service]
13
+ Type=oneshot
14
+ Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
15
+ ExecStart=/usr/bin/node /usr/lib/node_modules/@andrian.yablonskyy/thub-coordinator/scripts/self-update-helper.js /var/lib/thub/.thub/update-request.json thub
16
+ TimeoutStartSec=15min
package/views/layout.pug CHANGED
@@ -14,11 +14,11 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
14
14
  span TestHub
15
15
  small.text-body-secondary.fw-normal.mt-1(style="font-size: .7rem;")
16
16
  | ver. #{coordinatorVersion}
17
- if user && updates.coordinatorUpdate
17
+ if user && user.role !== 'admin' && updates.coordinatorUpdate
18
18
  span.badge.text-bg-warning.ms-1(
19
19
  data-bs-toggle="tooltip"
20
20
  data-bs-placement="bottom"
21
- data-bs-title="Update the Coordinator by hand on its host: thub-admin self-update"
21
+ data-bs-title="An admin can update the Coordinator from the navbar"
22
22
  )= `v${updates.coordinatorUpdate} available`
23
23
  button.navbar-toggler(type="button" data-bs-toggle="collapse" data-bs-target="#nav")
24
24
  span.navbar-toggler-icon
@@ -37,6 +37,27 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
37
37
  li.nav-item
38
38
  a.nav-link(href="/admin/agents" class=active === 'agents' ? 'active' : '') Agents
39
39
  ul.navbar-nav
40
+ if user && user.role === 'admin' && updates.coordinatorUpdate
41
+ li.nav-item.d-flex.align-items-center.me-3
42
+ if updates.coordinatorPending
43
+ button.btn.btn-sm.btn-warning(type="button" disabled)
44
+ span.spinner-border.spinner-border-sm.me-1(aria-hidden="true")
45
+ = `Updating app to ${updates.coordinatorPending}…`
46
+ else
47
+ form(
48
+ method="post"
49
+ action="/updates/coordinator"
50
+ onsubmit=`return confirm('Update the Coordinator to v${updates.coordinatorUpdate}? It restarts, and the dashboard and API are briefly unavailable.')`
51
+ )
52
+ input(type="hidden" name="returnTo" value=currentPath)
53
+ button.btn.btn-sm.btn-warning(
54
+ type="submit"
55
+ data-bs-toggle="tooltip"
56
+ data-bs-placement="bottom"
57
+ data-bs-title=`Installed v${coordinatorVersion} — installs v${updates.coordinatorUpdate} and restarts the Coordinator`
58
+ )
59
+ i.bi.bi-cloud-download.me-1
60
+ = `Update app to ${updates.coordinatorUpdate}`
40
61
  li.nav-item.d-flex.align-items-center.me-3
41
62
  button#theme-toggle.btn.btn-sm.btn-outline-secondary(
42
63
  type="button"
@@ -33,6 +33,62 @@ mixin resourceActions(r, returnTo)
33
33
  i.bi.bi-arrow-repeat
34
34
  +updateButton(`/resources/${r.id}/update`, r.update_to, returnTo)
35
35
 
36
+ //- One udev device line: path (or udev index / ST-Link serial), extra
37
+ //- detail, and whether the node was present when the Client registered.
38
+ mixin capDevice(d, detail)
39
+ li
40
+ code= d.path || (d.serial ? `serial ${d.serial}` : `index ${d.index}`)
41
+ if detail
42
+ span.text-body-secondary.small.ms-2= detail
43
+ if d.present === false
44
+ span.badge.text-bg-danger.ms-2(
45
+ data-bs-toggle="tooltip"
46
+ data-bs-title="The device node didn't exist when the Client registered — check the adapter and udev rules"
47
+ ) missing
48
+
49
+ //- What the Client reported it can drive at registration (HW: udev
50
+ //- devices, relays, power; SW: emulator image and limits).
51
+ mixin capabilitiesList(caps)
52
+ if !caps
53
+ span.text-body-secondary —
54
+ else if caps.sw
55
+ ul.list-unstyled.mb-0
56
+ li
57
+ span.text-body-secondary.small.me-2 Image
58
+ code= caps.sw.image || '—'
59
+ li
60
+ span.text-body-secondary.small.me-2 Source
61
+ = caps.sw.registry ? caps.sw.registry : 'no local registry'
62
+ = caps.sw.allowDockerHub ? ', then Docker Hub' : ' (Docker Hub off)'
63
+ li
64
+ span.text-body-secondary.small.me-2 Limits
65
+ = `${caps.sw.cpus || '—'} CPU, ${caps.sw.memory || '—'}`
66
+ else if caps.hw
67
+ - const kinds = [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'USB']]
68
+ - const empty = kinds.every(([k]) => !caps.hw[k].length) && !caps.hw.relays.length && !caps.hw.power
69
+ if empty
70
+ span.text-body-secondary no devices configured
71
+ each pair in kinds
72
+ if caps.hw[pair[0]].length
73
+ .small.text-body-secondary.mt-1= pair[1]
74
+ ul.list-unstyled.mb-0.ms-2
75
+ each d in caps.hw[pair[0]]
76
+ +capDevice(d, pair[0] === 'uarts' && d.baudRate ? `${d.baudRate} baud` : null)
77
+ if caps.hw.relays.length
78
+ .small.text-body-secondary.mt-1 Relays
79
+ ul.list-unstyled.mb-0.ms-2
80
+ each rl in caps.hw.relays
81
+ li
82
+ = `channel ${rl.channel}`
83
+ if rl.baseUrl
84
+ code.ms-2= rl.baseUrl
85
+ if caps.hw.power
86
+ .small.text-body-secondary.mt-1 Power
87
+ .ms-2
88
+ = caps.hw.power.method
89
+ if caps.hw.power.method === 'uhubctl' && caps.hw.power.hub
90
+ span.text-body-secondary.small.ms-2= `hub ${caps.hw.power.hub} port ${caps.hw.power.port}`
91
+
36
92
  mixin resourceCard(r, groupsById, returnTo)
37
93
  - const isLoopback = (a) => /^127\./.test(a) || a === '::1' || a === 'localhost'
38
94
  - const local = ((r.host_info && r.host_info.addresses) || []).filter((a) => !isLoopback(a.address))
@@ -69,6 +125,9 @@ mixin resourceCard(r, groupsById, returnTo)
69
125
  data-bs-toggle="tooltip"
70
126
  data-bs-title="The address the Coordinator last saw this Client connect from"
71
127
  ) external
128
+ dt.col-sm-4 Capabilities
129
+ dd.col-sm-8
130
+ +capabilitiesList(r.capabilities)
72
131
  dt.col-sm-4 Labels
73
132
  dd.col-sm-8
74
133
  if r.labels.length