@andrian.yablonskyy/thub-coordinator 1.0.8 → 1.0.10

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
@@ -35,6 +35,7 @@ The Coordinator loads a plain **JSON** config file (no YAML support). Resolution
35
35
  {
36
36
  "listen": "127.0.0.1:8080",
37
37
  "publicUrl": "https://thub.example.com",
38
+ "trustProxy": "loopback",
38
39
  "dataDir": "/var/lib/thub",
39
40
  "sessionSecret": "change-me-to-a-long-random-string",
40
41
  "clientJoinKey": "change-me-to-a-long-random-string",
@@ -42,11 +43,12 @@ The Coordinator loads a plain **JSON** config file (no YAML support). Resolution
42
43
  "scheduler": { "assignAckTimeoutSec": 15, "requeueOnLost": true, "maxQueuedPerAgent": 20, "tickIntervalSec": 10 },
43
44
  "jobs": { "defaultTimeoutSec": 1800, "maxTimeoutSec": 14400 },
44
45
  "retention": { "logRetentionDays": 14, "artifactRetentionDays": 30 },
45
- "artifacts": { "maxUploadMb": 512, "linkTtlHours": 168 }
46
+ "artifacts": { "maxUploadMb": 512, "linkTtlHours": 168 },
47
+ "updates": { "checkIntervalHours": 6, "registry": null }
46
48
  }
47
49
  ```
48
50
 
49
- `sessionSecret` signs the dashboard's session cookie and the HMAC on artifact download links; `clientJoinKey` is the shared secret Clients self-register with — omit or leave `null` to disable auto-registration entirely. Individual `THUB_LISTEN` / `THUB_PUBLIC_URL` / `THUB_DATA_DIR` / `THUB_SESSION_SECRET` / `THUB_CLIENT_JOIN_KEY` env vars override whatever the file set.
51
+ `sessionSecret` signs the dashboard's session cookie and the HMAC on artifact download links; `clientJoinKey` is the shared secret Clients self-register with — omit or leave `null` to disable auto-registration entirely. `trustProxy` is Express's [`trust proxy`](https://expressjs.com/en/guide/behind-proxies.html) setting (default `loopback`): which reverse proxies' `X-Forwarded-For` to believe for a Client's external address on the resource card — e.g. `"10.0.0.0/8"` or `1` for a proxy on another host, `false` to always use the socket address. Individual `THUB_LISTEN` / `THUB_PUBLIC_URL` / `THUB_DATA_DIR` / `THUB_SESSION_SECRET` / `THUB_CLIENT_JOIN_KEY` env vars override whatever the file set.
50
52
 
51
53
  `npm install -g` creates `~/.config/thub/coordinator.json` for you if it doesn't already exist, with all the options above, `dataDir` set to `~/var/lib/thub` and a freshly generated random `sessionSecret` (not the placeholder above), readable only by its owner — a re-install never overwrites it or regenerates the secret. Set `publicUrl` and `clientJoinKey` yourself before relying on auto-registration.
52
54
 
@@ -99,13 +101,17 @@ thub-admin jobs clean --yes
99
101
  thub-admin group add ci-nightly --comment "shared CI pool"
100
102
  thub-admin group list
101
103
  thub-admin group remove <groupId>
104
+
105
+ # The Coordinator is only ever updated by hand (never from the dashboard).
106
+ thub-admin check-update # installed vs latest published version
107
+ thub-admin self-update [--to <x.y.z>] # sudo npm i -g; the postinstall restarts the service
102
108
  ```
103
109
 
104
110
  Both `jobs reset` and `jobs clean` refuse to run without `--yes` — there's no undo for either, especially `clean`.
105
111
 
106
112
  ## API
107
113
 
108
- All endpoints are under `/api/v1`, JSON, and require `Authorization: Bearer <token>` (the dashboard is a separate session-based auth path).
114
+ All endpoints are under `/api/v1`, JSON, and require `Authorization: Bearer <token>` (the dashboard is a separate session-based auth path). The Agent and Client send `User-Agent: thub-agent/<version>` / `thub-client/<version>`, and the Coordinator records that version for the dashboard.
109
115
 
110
116
  **Agent endpoints** (used by [thub-agent](https://github.com/andrianyablonskyy/thub-agent)):
111
117
 
@@ -119,13 +125,14 @@ All endpoints are under `/api/v1`, JSON, and require `Authorization: Bearer <tok
119
125
  | `GET` | `/jobs/:id/logs/stream` | Server-Sent Events: `log`, `state`, `end`. Honors `Last-Event-ID` for resume. |
120
126
  | `GET` | `/jobs/:id/artifacts` | Artifact list with signed download URLs. |
121
127
  | `GET` | `/resources` | Resources with status (read-only). |
128
+ | `GET` | `/agents/me/update` | `{updateTo, latest}` — a self-update requested for the calling agent. |
122
129
 
123
130
  **Resource (Client) endpoints** (used by [thub-client](https://github.com/andrianyablonskyy/thub-client)):
124
131
 
125
132
  | Method | Path | Description |
126
133
  |---|---|---|
127
- | `POST` | `/resources/register` | Self-register (every start/restart) using the shared `clientJoinKey`; upserts by `clientId`. |
128
- | `POST` | `/resources/:id/heartbeat` | Heartbeat and self-reported state; response may carry `commands[]`. |
134
+ | `POST` | `/resources/register` | Self-register (every start/restart) using the shared `clientJoinKey`; upserts by `clientId`. `hostInfo.addresses` (`[{iface, address, family}]`) lists the host's non-loopback interface addresses; the request's source address is stored as the external one. |
135
+ | `POST` | `/resources/:id/heartbeat` | Heartbeat and self-reported state, plus the current `addresses`; refreshes the external address; response may carry `commands[]` (`cancel-job`, `self-update`). |
129
136
  | `POST` | `/resources/:id/status` | Explicit status change, e.g. a local lock. |
130
137
  | `GET` | `/resources/:id/jobs/next?wait=30` | Long-poll for an assigned job; `204` when nothing arrived within `wait` seconds. |
131
138
  | `POST` | `/jobs/:id/accept` | Acknowledge assignment. |
@@ -142,14 +149,25 @@ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla J
142
149
 
143
150
  | Page | Content |
144
151
  |---|---|
145
- | `/` | Resource cards by status, queue length, jobs in the last 24 h. |
146
- | `/resources` | Type, labels, groups, status, busy source/reason, last heartbeat age; admin maintenance/rotate-token actions. |
152
+ | `/` | Resource cards by status, queue length, jobs in the last 24 h. Click a card for its resource card (below). |
153
+ | `/resources` | Type, Client version, labels, groups, status, busy source/reason, last heartbeat age; admin maintenance/rotate-token actions. Click a row for its resource card (below). |
147
154
  | `/groups` | Resource groups — id/name/comment, member count; admin create/rename/delete. Membership itself is set per-resource in the Client's own config. |
148
155
  | `/jobs` | Filterable job list (state, source, resource), with the job's `user` label if set; admin **Reset queue** / **Clean history**. |
149
156
  | `/jobs/:id` | Spec, timeline, live log viewer with stream filter, artifact downloads, cancel button. |
150
- | `/admin/agents` | Register/revoke CI and developer agents; token shown once. |
157
+ | `/admin/agents` | Register/revoke CI and developer agents; token shown once. Shows each agent's last reported version. |
151
158
  | `/profile` | Every logged-in user's own account settings: display name, avatar, timezone (renders every dashboard timestamp), theme, idle session timeout, password change. |
152
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.
161
+
162
+ The Coordinator's own version (`ver. X.Y.Z`) is shown under the TestHub logo, top left of every page.
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:
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
+ - 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`.
168
+
169
+ A request is cleared when the Agent/Client reports the new version.
170
+
153
171
  ## Data storage
154
172
 
155
173
  SQLite via `better-sqlite3`, WAL mode. Schema migrations are plain numbered SQL files in `src/db/migrations/`, applied automatically at startup. `job_logs` rows are compacted into `artifacts/<jobId>/console.log` when a job finishes and deleted after `logRetentionDays`; artifacts are deleted after `artifactRetentionDays`. See the [main repo's ER diagram](https://github.com/andrianyablonskyy/thub#9-data-storage-sqlite) for the full schema.
package/bin/thub-admin.js CHANGED
@@ -32,7 +32,10 @@ const { loadConfig } = require('../src/config'),
32
32
  { createAdminUsersService } = require('../src/services/admin-users'),
33
33
  { createArtifactsService } = require('../src/services/artifacts'),
34
34
  { createJobsService } = require('../src/services/jobs'),
35
- { generateToken } = require('../src/services/tokens');
35
+ { generateToken } = require('../src/services/tokens'),
36
+ { PACKAGES, fetchLatestVersion, isNewer, isValidVersion, npmBin } = require('@andrian.yablonskyy/thub-common'),
37
+ { spawnSync } = require('node:child_process'),
38
+ { version: installedVersion } = require('../package.json');
36
39
 
37
40
  function usage(){
38
41
  console.log(`Usage:
@@ -45,6 +48,9 @@ function usage(){
45
48
  thub-admin group add <name> [--comment <text>]
46
49
  thub-admin group list
47
50
  thub-admin group remove <groupId>
51
+ thub-admin check-update Compare this Coordinator with the latest published version
52
+ thub-admin self-update [--to X.Y.Z]
53
+ Update this Coordinator (sudo npm i -g; restarts the service)
48
54
  `);
49
55
  }
50
56
 
@@ -79,9 +85,46 @@ function parseFlags(args){
79
85
  return { positional, flags };
80
86
  }
81
87
 
88
+ // The Coordinator is only ever updated by hand (README §10.2) — never
89
+ // from the dashboard. Neither command needs the database.
90
+ async function updateCommand(cmd, args){
91
+ const { flags } = parseFlags(args),
92
+ latest = await fetchLatestVersion(PACKAGES.coordinator);
93
+ if (cmd === 'check-update'){
94
+ console.log(`Installed: v${installedVersion} Latest: v${latest}`);
95
+ console.log(isNewer(latest, installedVersion) ? 'Update available: thub-admin self-update' : 'Up to date.');
96
+ return 0;
97
+ }
98
+ const target = flags.to || latest;
99
+ if (!isValidVersion(target)){
100
+ console.error(`Invalid version "${target}"`);
101
+ return 4;
102
+ }
103
+ if (!flags.to && !isNewer(target, installedVersion)){
104
+ console.log(`Already on v${installedVersion} (latest v${latest}).`);
105
+ return 0;
106
+ }
107
+ // Root, via sudo, so the postinstall re-renders and restarts the
108
+ // thub-coordinator service for the right user (SUDO_USER).
109
+ const npmArgs = ['i', '-g', `${PACKAGES.coordinator}@${target}`],
110
+ isRoot = process.getuid?.() === 0,
111
+ [bin, argv] = isRoot ? [npmBin(), npmArgs] : ['sudo', [npmBin(), ...npmArgs]];
112
+ console.log(`Updating Coordinator v${installedVersion} -> v${target}: ${[bin, ...argv].join(' ')}`);
113
+ return spawnSync(bin, argv, { stdio: 'inherit' }).status ?? 1;
114
+ }
115
+
82
116
  function main(){
83
- const [, , cmd, sub, ...rest] = process.argv,
84
- config = loadConfig(),
117
+ const [, , cmd, sub, ...rest] = process.argv;
118
+ if (cmd === 'check-update' || cmd === 'self-update'){
119
+ return updateCommand(cmd, [sub, ...rest].filter(Boolean))
120
+ .then((code) => process.exit(code))
121
+ .catch((err) => {
122
+ console.error(`Error: ${err.message}`);
123
+ process.exit(1);
124
+ });
125
+ }
126
+
127
+ const config = loadConfig(),
85
128
  db = openDb(config.dbPath),
86
129
  events = createEventsService(db),
87
130
  registry = createRegistryService(db, { bus, events }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
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": {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file packages/coordinator/public/js/resource-card.js
3
+ * @description Dashboard: opens a resource's info card (modal) when its row/card is clicked or activated by keyboard
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
+ (function (){
17
+ function open(el){
18
+ const modal = document.getElementById(el.dataset.resourceCard);
19
+ if (modal){
20
+ bootstrap.Modal.getOrCreateInstance(modal).show();
21
+ }
22
+ }
23
+
24
+ document.querySelectorAll('[data-resource-card]').forEach((el) => {
25
+ el.addEventListener('click', (e) => {
26
+ // The Resources table's own action buttons stay plain actions.
27
+ if (!e.target.closest('form, button, a, input')){
28
+ open(el);
29
+ }
30
+ });
31
+ el.addEventListener('keydown', (e) => {
32
+ if ((e.key === 'Enter' || e.key === ' ') && e.target === el){
33
+ e.preventDefault();
34
+ open(el);
35
+ }
36
+ });
37
+ });
38
+ })();
package/src/api/agent.js CHANGED
@@ -92,6 +92,14 @@ function createAgentRouter({ services, config }){
92
92
  res.json({ resources: services.registry.list().map(publicResource) });
93
93
  });
94
94
 
95
+ // Checked by the Agent at the start of every run (README §10.2): the
96
+ // version an admin asked it to self-update to, if still newer than the
97
+ // one it runs (auth has already recorded that from its User-Agent).
98
+ router.get('/agents/me/update', auth, (req, res) => {
99
+ const agent = services.agents.get(req.agent.id);
100
+ res.json({ updateTo: agent.update_to || null, latest: services.updates.status().latest.agent || null });
101
+ });
102
+
95
103
  return router;
96
104
  }
97
105
 
@@ -15,7 +15,7 @@
15
15
 
16
16
  const express = require('express'),
17
17
  multer = require('multer'),
18
- { requireRole, requireJoinKey } = require('../auth'),
18
+ { requireRole, requireJoinKey, appVersion } = require('../auth'),
19
19
  { JOB_STATES } = require('@andrian.yablonskyy/thub-common');
20
20
 
21
21
  const upload = multer({ dest: require('node:os').tmpdir() });
@@ -64,7 +64,9 @@ function createResourceRouter({ services, config }){
64
64
  type,
65
65
  labels: labels || capabilities?.labels || [],
66
66
  groups: groups || [],
67
- hostInfo
67
+ hostInfo,
68
+ remoteAddr: req.ip,
69
+ clientVersion: appVersion(req, 'client')
68
70
  });
69
71
  res.json({ resourceId, resourceToken, heartbeatIntervalSec: config.heartbeat.intervalSec });
70
72
  }
@@ -81,9 +83,23 @@ function createResourceRouter({ services, config }){
81
83
 
82
84
  router.post('/resources/:id/heartbeat', auth, requireOwnResource, (req, res, next) => {
83
85
  try {
84
- const { state, activeJobId, localLock, metrics } = req.body;
85
- services.registry.heartbeat(req.params.id, { state, activeJobId, localLock, metrics });
86
- const commands = services.commands.drain(req.params.id);
86
+ const { state, activeJobId, localLock, metrics, addresses } = req.body;
87
+ services.registry.heartbeat(req.params.id, {
88
+ state,
89
+ activeJobId,
90
+ localLock,
91
+ metrics,
92
+ addresses,
93
+ remoteAddr: req.ip,
94
+ clientVersion: appVersion(req, 'client')
95
+ });
96
+ const commands = services.commands.drain(req.params.id),
97
+ // Repeated on every heartbeat until the Client reports the new
98
+ // version (README §10.2) — the Client ignores repeats itself.
99
+ updateTo = services.registry.pendingUpdate(req.params.id);
100
+ if (updateTo){
101
+ commands.push({ command: 'self-update', version: updateTo });
102
+ }
87
103
  res.json({ serverTime: new Date().toISOString(), commands });
88
104
  }
89
105
  catch (err){
package/src/auth.js CHANGED
@@ -17,6 +17,13 @@ const crypto = require('node:crypto'),
17
17
  { hashToken } = require('./services/tokens');
18
18
 
19
19
  // §3.1 / §12: bearer tokens are hashed with SHA-256 and carry a role
20
+ // App version from a "thub-<app>/<version>" User-Agent (shared ApiClient),
21
+ // or null for anything else (curl, an older Agent/Client).
22
+ function appVersion(req, app){
23
+ const m = new RegExp(`^thub-${app}/([0-9A-Za-z.+-]{1,32})(?:\\s|$)`).exec(req.get('user-agent') || '');
24
+ return m ? m[1] : null;
25
+ }
26
+
20
27
  // (agent | resource); admin is authenticated via the dashboard session
21
28
  // instead of an API token (§10).
22
29
  function requireRole(role){
@@ -33,7 +40,7 @@ function requireRole(role){
33
40
  if (!agent){
34
41
  return res.status(401).json({ error: 'Invalid or revoked agent token' });
35
42
  }
36
- req.app.locals.services.agents.touchLastUsed(agent.id);
43
+ req.app.locals.services.agents.touchLastUsed(agent.id, appVersion(req, 'agent'));
37
44
  req.agent = agent;
38
45
  return next();
39
46
  }
@@ -91,4 +98,4 @@ function requireAdminRole(req, res, next){
91
98
  next();
92
99
  }
93
100
 
94
- module.exports = { requireRole, requireJoinKey, requireAdminSession, requireAdminRole };
101
+ module.exports = { requireRole, requireJoinKey, requireAdminSession, requireAdminRole, appVersion };
package/src/config.js CHANGED
@@ -21,6 +21,10 @@ const fs = require('node:fs'),
21
21
  const DEFAULTS = {
22
22
  listen: '127.0.0.1:8080',
23
23
  publicUrl: 'http://localhost:8080',
24
+ // Express `trust proxy`: which proxies' X-Forwarded-For to believe for
25
+ // a Client's external address (resource card). 'loopback' fits the
26
+ // default setup — listening on 127.0.0.1 behind a local reverse proxy.
27
+ trustProxy: 'loopback',
24
28
  dataDir: path.join(process.cwd(), '.data'),
25
29
  sessionSecret: 'dev-only-change-me',
26
30
  // Shared secret Clients present to self-register (see api/resource.js).
@@ -48,6 +52,13 @@ const DEFAULTS = {
48
52
  artifacts: {
49
53
  maxUploadMb: 512,
50
54
  linkTtlHours: 168
55
+ },
56
+ // New-version check of the Coordinator/Agent/Client packages (README
57
+ // §10.2). 0 disables the periodic check; registry null = the public npm
58
+ // registry (or npm_config_registry).
59
+ updates: {
60
+ checkIntervalHours: 6,
61
+ registry: null
51
62
  }
52
63
  };
53
64
 
@@ -0,0 +1,5 @@
1
+ -- Address the Coordinator last saw this resource's Client connect from
2
+ -- (register or heartbeat) — its external IP from the Coordinator's point
3
+ -- of view, shown on the dashboard's resource card next to the Client's own
4
+ -- interface addresses (host_info.addresses).
5
+ ALTER TABLE resources ADD COLUMN remote_addr TEXT;
@@ -0,0 +1,5 @@
1
+ -- App version each Agent / Client last reported (User-Agent header
2
+ -- "thub-agent/<version>" / "thub-client/<version>"), shown on the
3
+ -- dashboard's Agents page and resource card (README §10).
4
+ ALTER TABLE agents ADD COLUMN version TEXT;
5
+ ALTER TABLE resources ADD COLUMN client_version TEXT;
@@ -0,0 +1,5 @@
1
+ -- Self-update requests (README §10.2): the version an admin asked this
2
+ -- Agent / Client to update to, cleared once it reports that version (or
3
+ -- newer). NULL = no update pending.
4
+ ALTER TABLE agents ADD COLUMN update_to TEXT;
5
+ ALTER TABLE resources ADD COLUMN update_to TEXT;
package/src/server.js CHANGED
@@ -34,6 +34,7 @@ const path = require('node:path'),
34
34
  { createArtifactsService } = require('./services/artifacts'),
35
35
  { createCommandsService } = require('./services/commands'),
36
36
  { createRetentionService } = require('./services/retention'),
37
+ { createUpdatesService } = require('./services/updates'),
37
38
 
38
39
  { createAgentRouter } = require('./api/agent'),
39
40
  { createResourceRouter } = require('./api/resource'),
@@ -53,9 +54,11 @@ function buildServices(config){
53
54
  jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
54
55
  scheduler = createScheduler(db, { bus, events, registry, config }),
55
56
  heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config }),
56
- retention = createRetentionService(db, { bus, logs, artifacts, config });
57
+ retention = createRetentionService(db, { bus, logs, artifacts, config }),
58
+ updates = createUpdatesService({ config });
57
59
 
58
60
  jobs.reconcileOnStartup();
61
+ updates.start();
59
62
 
60
63
  const sweepInterval = setInterval(() => {
61
64
  jobs.checkAssignAcks();
@@ -77,7 +80,8 @@ function buildServices(config){
77
80
  artifacts,
78
81
  scheduler,
79
82
  heartbeatMonitor,
80
- retention
83
+ retention,
84
+ updates
81
85
  };
82
86
  }
83
87
 
@@ -85,7 +89,10 @@ function createApp(config, services){
85
89
  const app = express();
86
90
  app.set('view engine', 'pug');
87
91
  app.set('views', path.join(__dirname, '..', 'views'));
92
+ app.set('trust proxy', config.trustProxy);
88
93
  app.locals.services = services;
94
+ // Shown in every dashboard page's footer (layout.pug).
95
+ app.locals.coordinatorVersion = require('../package.json').version;
89
96
 
90
97
  app.use(express.json({ limit: '2mb' }));
91
98
  app.use(express.urlencoded({ extended: true }));
@@ -13,7 +13,8 @@
13
13
 
14
14
  'use strict';
15
15
 
16
- const { v4: uuid } = require('uuid'),
16
+ const { compareVersions } = require('@andrian.yablonskyy/thub-common'),
17
+ { v4: uuid } = require('uuid'),
17
18
  { generateToken, hashToken } = require('./tokens');
18
19
 
19
20
  function createAgentsService(db, { events }){
@@ -28,7 +29,7 @@ function createAgentsService(db, { events }){
28
29
  }
29
30
 
30
31
  function list(){
31
- return db.prepare('SELECT id, name, kind, created_at, last_used_at, revoked_at FROM agents ORDER BY created_at DESC').all();
32
+ return db.prepare('SELECT id, name, kind, version, update_to, created_at, last_used_at, revoked_at FROM agents ORDER BY created_at DESC').all();
32
33
  }
33
34
 
34
35
  function create({ name, kind }){
@@ -46,11 +47,39 @@ function createAgentsService(db, { events }){
46
47
  events.record('agent', id, 'agent.revoked', {});
47
48
  }
48
49
 
49
- function touchLastUsed(id){
50
- db.prepare('UPDATE agents SET last_used_at = ? WHERE id = ?').run(new Date().toISOString(), id);
50
+ // `version` only when the Agent reported one — keeps the last known one
51
+ // otherwise. Reporting the requested update's version (or newer) is what
52
+ // completes a self-update request.
53
+ function touchLastUsed(id, version = null){
54
+ db.prepare('UPDATE agents SET last_used_at = ?, version = COALESCE(?, version) WHERE id = ?').run(new Date().toISOString(), version, id);
55
+ const agent = get(id);
56
+ if (agent?.update_to && agent.version && compareVersions(agent.version, agent.update_to) >= 0){
57
+ db.prepare('UPDATE agents SET update_to = NULL WHERE id = ?').run(id);
58
+ events.record('agent', id, 'agent.updated', { version: agent.version });
59
+ }
51
60
  }
52
61
 
53
- return { get, getByTokenHash, list, create, revoke, touchLastUsed };
62
+ // Self-update request (README §10.2): the Agent picks it up on its next
63
+ // run. `version` null cancels a pending one.
64
+ function setUpdateTo(id, version){
65
+ const agent = get(id);
66
+ if (!agent || agent.revoked_at){
67
+ throw Object.assign(new Error('Unknown or revoked agent'), { status: 404 });
68
+ }
69
+ db.prepare('UPDATE agents SET update_to = ? WHERE id = ?').run(version, id);
70
+ events.record('agent', id, version ? 'agent.update_requested' : 'agent.update_canceled', { version });
71
+ }
72
+
73
+ // Every active agent not already on `version` or newer.
74
+ function requestUpdateAll(version){
75
+ const ids = db.prepare('SELECT id, version FROM agents WHERE revoked_at IS NULL').all()
76
+ .filter((a) => !a.version || compareVersions(a.version, version) < 0)
77
+ .map((a) => a.id);
78
+ ids.forEach((id) => setUpdateTo(id, version));
79
+ return ids.length;
80
+ }
81
+
82
+ return { get, getByTokenHash, list, create, revoke, touchLastUsed, setUpdateTo, requestUpdateAll };
54
83
  }
55
84
 
56
85
  module.exports = { createAgentsService };
@@ -14,7 +14,7 @@
14
14
  'use strict';
15
15
 
16
16
  const { v4: uuid } = require('uuid'),
17
- { RESOURCE_STATES, BUSY_SOURCES } = require('@andrian.yablonskyy/thub-common'),
17
+ { RESOURCE_STATES, BUSY_SOURCES, compareVersions } = require('@andrian.yablonskyy/thub-common'),
18
18
  { generateToken, hashToken } = require('./tokens');
19
19
 
20
20
  function rowToResource(row){
@@ -29,6 +29,28 @@ function rowToResource(row){
29
29
  };
30
30
  }
31
31
 
32
+ // host_info.addresses comes straight from the Client, so keep only
33
+ // well-formed { iface, address, family } entries and cap the list.
34
+ const MAX_ADDRESSES = 64;
35
+ function sanitizeAddresses(addresses){
36
+ if (!Array.isArray(addresses)){
37
+ return undefined;
38
+ }
39
+ return addresses
40
+ .filter((a) => a && typeof a.address === 'string' && /^[0-9A-Fa-f:.%\w-]{2,64}$/.test(a.address))
41
+ .slice(0, MAX_ADDRESSES)
42
+ .map((a) => ({
43
+ iface: typeof a.iface === 'string' ? a.iface.slice(0, 64) : '',
44
+ address: a.address,
45
+ family: a.family === 'IPv6' ? 'IPv6' : 'IPv4'
46
+ }));
47
+ }
48
+
49
+ // Express reports IPv4 clients of a dual-stack socket as ::ffff:a.b.c.d.
50
+ function normalizeRemoteAddr(addr){
51
+ return typeof addr === 'string' && addr ? addr.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/, '$1') : null;
52
+ }
53
+
32
54
  function createRegistryService(db, { bus, events }){
33
55
  function get(id){
34
56
  return rowToResource(db.prepare('SELECT * FROM resources WHERE id = ?').get(id));
@@ -84,8 +106,10 @@ function createRegistryService(db, { bus, events }){
84
106
  // this registration is allowed to reclaim it. Anything else (IDLE, BUSY,
85
107
  // MAINTENANCE, REGISTERED-but-just-created) means a process might still
86
108
  // be actively using that identity, so it stays a hard conflict.
87
- function registerAuto({ clientId, name, type, labels = [], groups = [], hostInfo }){
88
- const resourceToken = generateToken('res');
109
+ function registerAuto({ clientId, name, type, labels = [], groups = [], hostInfo, remoteAddr, clientVersion = null }){
110
+ const resourceToken = generateToken('res'),
111
+ remote = normalizeRemoteAddr(remoteAddr);
112
+ hostInfo = { ...(hostInfo || {}), addresses: sanitizeAddresses(hostInfo?.addresses) || [] };
89
113
  let existing = getByClientId(clientId);
90
114
 
91
115
  if (!existing){
@@ -133,7 +157,7 @@ function createRegistryService(db, { bus, events }){
133
157
  db.prepare(
134
158
  `UPDATE resources
135
159
  SET token_hash = ?, client_id = ?, name = ?, type = ?, labels = ?, group_ids = ?, host_info = ?,
136
- status = ?, busy_source = NULL, busy_reason = NULL
160
+ remote_addr = ?, client_version = ?, status = ?, busy_source = NULL, busy_reason = NULL
137
161
  WHERE id = ?`
138
162
  ).run(
139
163
  hashToken(resourceToken),
@@ -142,11 +166,14 @@ function createRegistryService(db, { bus, events }){
142
166
  type,
143
167
  JSON.stringify(labels),
144
168
  JSON.stringify(groups),
145
- JSON.stringify(hostInfo || {}),
169
+ JSON.stringify(hostInfo),
170
+ remote,
171
+ clientVersion,
146
172
  nextStatus,
147
173
  existing.id
148
174
  );
149
175
 
176
+ completeUpdateIfDone(existing.id);
150
177
  events.record('resource', existing.id, 'resource.registered', {
151
178
  hostInfo,
152
179
  name,
@@ -160,8 +187,8 @@ function createRegistryService(db, { bus, events }){
160
187
 
161
188
  const id = `res_${uuid()}`;
162
189
  db.prepare(
163
- `INSERT INTO resources (id, name, type, status, labels, group_ids, host_info, token_hash, client_id, created_at)
164
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
165
192
  ).run(
166
193
  id,
167
194
  name,
@@ -169,7 +196,9 @@ function createRegistryService(db, { bus, events }){
169
196
  RESOURCE_STATES.REGISTERED,
170
197
  JSON.stringify(labels),
171
198
  JSON.stringify(groups),
172
- JSON.stringify(hostInfo || {}),
199
+ JSON.stringify(hostInfo),
200
+ remote,
201
+ clientVersion,
173
202
  hashToken(resourceToken),
174
203
  clientId,
175
204
  new Date().toISOString()
@@ -185,7 +214,7 @@ function createRegistryService(db, { bus, events }){
185
214
  }
186
215
 
187
216
  // Reconcile status from a heartbeat's self-reported state (§5.1).
188
- function heartbeat(resourceId, { state, activeJobId, localLock, metrics } = {}){
217
+ function heartbeat(resourceId, { state, activeJobId, localLock, metrics, addresses, remoteAddr, clientVersion = null } = {}){
189
218
  const resource = get(resourceId);
190
219
  if (!resource){
191
220
  throw Object.assign(new Error('Unknown resource'), { status: 404 });
@@ -217,11 +246,26 @@ function createRegistryService(db, { bus, events }){
217
246
  busyReason = null;
218
247
  }
219
248
 
249
+ // Older Clients don't send addresses — keep whatever registration stored.
250
+ const cleanAddresses = sanitizeAddresses(addresses),
251
+ hostInfo = cleanAddresses ? { ...(resource.host_info || {}), addresses: cleanAddresses } : resource.host_info;
252
+
220
253
  db.prepare(
221
254
  `UPDATE resources
222
- SET last_heartbeat_at = ?, status = ?, busy_source = ?, busy_reason = ?
255
+ SET last_heartbeat_at = ?, status = ?, busy_source = ?, busy_reason = ?, host_info = ?,
256
+ remote_addr = COALESCE(?, remote_addr), client_version = COALESCE(?, client_version)
223
257
  WHERE id = ?`
224
- ).run(new Date().toISOString(), nextStatus, busySource, busyReason, resourceId);
258
+ ).run(
259
+ new Date().toISOString(),
260
+ nextStatus,
261
+ busySource,
262
+ busyReason,
263
+ hostInfo ? JSON.stringify(hostInfo) : null,
264
+ normalizeRemoteAddr(remoteAddr),
265
+ clientVersion,
266
+ resourceId
267
+ );
268
+ completeUpdateIfDone(resourceId);
225
269
 
226
270
  if (resource.status !== nextStatus){
227
271
  events.record('resource', resourceId, 'resource.status_changed', {
@@ -285,6 +329,40 @@ function createRegistryService(db, { bus, events }){
285
329
  bus.emit('resource.idle', { resourceId });
286
330
  }
287
331
 
332
+ // Self-update requests (README §10.2): delivered as a `self-update`
333
+ // command on every heartbeat until the Client reports `update_to` (or
334
+ // newer). `version` null cancels a pending one.
335
+ function setUpdateTo(resourceId, version){
336
+ if (!get(resourceId)){
337
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
338
+ }
339
+ db.prepare('UPDATE resources SET update_to = ? WHERE id = ?').run(version, resourceId);
340
+ events.record('resource', resourceId, version ? 'resource.update_requested' : 'resource.update_canceled', { version });
341
+ }
342
+
343
+ // Every resource not already on `version` or newer.
344
+ function requestUpdateAll(version){
345
+ const ids = list()
346
+ .filter((r) => !r.client_version || compareVersions(r.client_version, version) < 0)
347
+ .map((r) => r.id);
348
+ ids.forEach((id) => setUpdateTo(id, version));
349
+ return ids.length;
350
+ }
351
+
352
+ function completeUpdateIfDone(resourceId){
353
+ const r = get(resourceId);
354
+ if (r?.update_to && r.client_version && compareVersions(r.client_version, r.update_to) >= 0){
355
+ db.prepare('UPDATE resources SET update_to = NULL WHERE id = ?').run(resourceId);
356
+ events.record('resource', resourceId, 'resource.updated', { version: r.client_version });
357
+ }
358
+ }
359
+
360
+ // The version a heartbeating Client should update to, if any.
361
+ function pendingUpdate(resourceId){
362
+ const r = get(resourceId);
363
+ return r?.update_to && (!r.client_version || compareVersions(r.client_version, r.update_to) < 0) ? r.update_to : null;
364
+ }
365
+
288
366
  function setMaintenance(resourceId, enabled){
289
367
  const resource = get(resourceId);
290
368
  if (!resource){
@@ -338,6 +416,9 @@ function createRegistryService(db, { bus, events }){
338
416
  markOutOfService,
339
417
  markIdleAfterJob,
340
418
  setMaintenance,
419
+ setUpdateTo,
420
+ requestUpdateAll,
421
+ pendingUpdate,
341
422
  assignToJob,
342
423
  findIdleCandidates,
343
424
  everSatisfiable
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/updates.js
3
+ * @description Periodic new-version check of the Coordinator, Agent and Client npm packages (README §10.2)
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 { PACKAGES, fetchLatestVersion, defaultRegistry, isNewer } = require('@andrian.yablonskyy/thub-common'),
17
+ { version: coordinatorVersion } = require('../../package.json');
18
+
19
+ // Latest published versions, refreshed every `updates.checkIntervalHours`
20
+ // and on demand from the dashboard. Kept in memory only — a restart just
21
+ // checks again.
22
+ function createUpdatesService({ config }){
23
+ const registry = config.updates.registry || defaultRegistry(),
24
+ state = { latest: {}, checkedAt: null, error: null };
25
+ let inFlight = null;
26
+
27
+ async function checkNow(){
28
+ // Concurrent callers (timer + a dashboard click) share one check.
29
+ inFlight = inFlight || (async () => {
30
+ const latest = {},
31
+ errors = [];
32
+ await Promise.all(Object.entries(PACKAGES).map(async ([app, pkg]) => {
33
+ try {
34
+ latest[app] = await fetchLatestVersion(pkg, { registry });
35
+ }
36
+ catch (err){
37
+ errors.push(err.message);
38
+ }
39
+ }));
40
+ state.latest = { ...state.latest, ...latest };
41
+ state.checkedAt = new Date().toISOString();
42
+ state.error = errors.length ? errors.join('; ') : null;
43
+ return status();
44
+ })().finally(() => {
45
+ inFlight = null;
46
+ });
47
+ return inFlight;
48
+ }
49
+
50
+ function status(){
51
+ return {
52
+ ...state,
53
+ coordinatorVersion,
54
+ coordinatorUpdate: isNewer(state.latest.coordinator, coordinatorVersion) ? state.latest.coordinator : null
55
+ };
56
+ }
57
+
58
+ // The version a self-update request targets: the latest known one,
59
+ // checking first if nothing has been fetched yet.
60
+ async function targetVersion(app){
61
+ if (!state.latest[app]){
62
+ await checkNow();
63
+ }
64
+ if (!state.latest[app]){
65
+ throw Object.assign(new Error(`Latest ${app} version unknown (${state.error || 'registry unreachable'})`), { status: 503 });
66
+ }
67
+ return state.latest[app];
68
+ }
69
+
70
+ function start(){
71
+ const hours = Number(config.updates.checkIntervalHours);
72
+ if (!(hours > 0)){
73
+ return;
74
+ }
75
+ checkNow().catch(() => {});
76
+ const timer = setInterval(() => checkNow().catch(() => {}), hours * 3600 * 1000);
77
+ timer.unref?.();
78
+ }
79
+
80
+ return { checkNow, status, targetVersion, start };
81
+ }
82
+
83
+ module.exports = { createUpdatesService };
package/src/web/routes.js CHANGED
@@ -20,7 +20,7 @@ const fs = require('node:fs'),
20
20
  multer = require('multer'),
21
21
  { requireAdminSession, requireAdminRole } = require('../auth'),
22
22
  { attachJobStream } = require('../api/sse'),
23
- { RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES } = require('@andrian.yablonskyy/thub-common');
23
+ { RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES, isNewer } = require('@andrian.yablonskyy/thub-common');
24
24
 
25
25
  // §10.1: avatar uploads are small, single images — a hard size cap and an
26
26
  // allow-list of image mimetypes, same spirit as the join-key/token checks
@@ -45,10 +45,21 @@ function createWebRouter({ services, config }){
45
45
  req.session.flash.push({ type, text });
46
46
  }
47
47
 
48
+ // Where a resource action (maintenance, rotate token) came from — the
49
+ // Resources page or the Overview's resource card. Only a local path,
50
+ // never another site.
51
+ function returnTo(req, fallback){
52
+ const target = req.body.returnTo;
53
+ return typeof target === 'string' && /^\/(?![\/\\])/.test(target) ? target : fallback;
54
+ }
55
+
48
56
  router.use((req, res, next) => {
49
57
  const user = req.session?.user || null;
50
58
  res.locals.user = user;
51
59
  res.locals.messages = req.session?.flash || [];
60
+ // Latest published versions (README §10.2) — navbar, Agents, Resources.
61
+ res.locals.updates = services.updates.status();
62
+ res.locals.isNewer = isNewer;
52
63
  if (req.session){
53
64
  req.session.flash = [];
54
65
  }
@@ -198,8 +209,9 @@ function createWebRouter({ services, config }){
198
209
  jobsLast24h = services.jobs
199
210
  .list({ limit: 1000 })
200
211
  .filter((j) => j.created_at >= dayAgo).length,
201
- onlineCount = resources.filter((r) => r.status !== RESOURCE_STATES.OUT_OF_SERVICE && r.status !== RESOURCE_STATES.REGISTERED).length;
202
- res.render('index', { title: 'Overview', active: 'overview', resources, queueLength, jobsLast24h, onlineCount });
212
+ onlineCount = resources.filter((r) => r.status !== RESOURCE_STATES.OUT_OF_SERVICE && r.status !== RESOURCE_STATES.REGISTERED).length,
213
+ groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g]));
214
+ res.render('index', { title: 'Overview', active: 'overview', resources, queueLength, jobsLast24h, onlineCount, groupsById });
203
215
  });
204
216
 
205
217
  router.get('/resources', (req, res) => {
@@ -214,7 +226,7 @@ function createWebRouter({ services, config }){
214
226
 
215
227
  router.post('/resources/:id/maintenance', requireAdminRole, (req, res) => {
216
228
  services.registry.setMaintenance(req.params.id, req.body.enabled === '1');
217
- res.redirect('/resources');
229
+ res.redirect(returnTo(req, '/resources'));
218
230
  });
219
231
 
220
232
  router.post('/resources/:id/rotate-token', requireAdminRole, (req, res) => {
@@ -223,9 +235,62 @@ function createWebRouter({ services, config }){
223
235
  services.db.prepare('UPDATE resources SET token_hash = ? WHERE id = ?').run(hashToken(token), req.params.id);
224
236
  services.events.record('resource', req.params.id, 'resource.token_rotated', {});
225
237
  flash(req, 'warning', `New resource token (copy it now): ${token}`);
226
- res.redirect('/resources');
238
+ res.redirect(returnTo(req, '/resources'));
227
239
  });
228
240
 
241
+ // Self-update (README §10.2). Clients get a `self-update` heartbeat
242
+ // command, Agents update on their next run; both target the latest
243
+ // published version. The Coordinator itself is only ever updated by hand.
244
+ function updateAction(fn){
245
+ return async (req, res) => {
246
+ try {
247
+ flash(req, 'info', await fn(req));
248
+ }
249
+ catch (err){
250
+ flash(req, 'danger', err.message);
251
+ }
252
+ res.redirect(returnTo(req, '/'));
253
+ };
254
+ }
255
+
256
+ router.post('/updates/check', requireAdminRole, updateAction(async () => {
257
+ const s = await services.updates.checkNow(),
258
+ found = Object.entries(s.latest).map(([app, v]) => `${app} v${v}`).join(', ');
259
+ return s.error ? `Version check: ${found || 'nothing found'} (errors: ${s.error})` : `Latest versions: ${found}.`;
260
+ }));
261
+
262
+ router.post('/resources/update-all', requireAdminRole, updateAction(async () => {
263
+ const version = await services.updates.targetVersion('client'),
264
+ n = services.registry.requestUpdateAll(version);
265
+ return `Requested update to client v${version} on ${n} resource(s).`;
266
+ }));
267
+
268
+ router.post('/resources/:id/update', requireAdminRole, updateAction(async (req) => {
269
+ if (req.body.cancel === '1'){
270
+ services.registry.setUpdateTo(req.params.id, null);
271
+ return 'Update request canceled.';
272
+ }
273
+ const version = await services.updates.targetVersion('client');
274
+ services.registry.setUpdateTo(req.params.id, version);
275
+ return `Requested update to client v${version} — it starts on the Client's next heartbeat.`;
276
+ }));
277
+
278
+ router.post('/admin/agents/update-all', requireAdminRole, updateAction(async () => {
279
+ const version = await services.updates.targetVersion('agent'),
280
+ n = services.agents.requestUpdateAll(version);
281
+ return `Requested update to agent v${version} for ${n} agent(s) — each updates on its next run.`;
282
+ }));
283
+
284
+ router.post('/admin/agents/:id/update', requireAdminRole, updateAction(async (req) => {
285
+ if (req.body.cancel === '1'){
286
+ services.agents.setUpdateTo(req.params.id, null);
287
+ return 'Update request canceled.';
288
+ }
289
+ const version = await services.updates.targetVersion('agent');
290
+ services.agents.setUpdateTo(req.params.id, version);
291
+ return `Requested update to agent v${version} — it updates on its next run.`;
292
+ }));
293
+
229
294
  router.get('/jobs', (req, res) => {
230
295
  const filters = { state: req.query.state || undefined, source: req.query.source || undefined },
231
296
  jobs = services.jobs.list({ ...filters, limit: 200 }).map((j) => ({
@@ -1,9 +1,12 @@
1
1
  extends ../layout
2
+ include ../mixins/updates
2
3
 
3
4
  block content
4
5
  .d-flex.justify-content-between.align-items-center.mb-3
5
6
  h1.h4.mb-0 Agents (CI / developers)
6
7
  button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addAgentModal") Register agent
8
+ .mb-3
9
+ +updatesToolbar('agent', 'agents', '/admin/agents')
7
10
 
8
11
  if newToken
9
12
  .alert.alert-warning
@@ -16,6 +19,7 @@ block content
16
19
  tr
17
20
  th Name
18
21
  th Kind
22
+ th Version
19
23
  th Created
20
24
  th Last used
21
25
  th Status
@@ -25,13 +29,16 @@ block content
25
29
  tr(class=a.revoked_at ? 'opacity-50' : '')
26
30
  td= a.name
27
31
  td.text-uppercase= a.kind
32
+ td: +versionCell(a.version, updates.latest.agent, a.update_to)
28
33
  td= fmtDate(a.created_at)
29
34
  td= fmtDate(a.last_used_at, 'never')
30
35
  td= a.revoked_at ? 'revoked' : 'active'
31
36
  td
32
37
  if !a.revoked_at
33
- form(method="post" action=`/admin/agents/${a.id}/revoke` onsubmit="return confirm('Revoke this agent token?')")
34
- button.btn.btn-sm.btn-outline-danger(type="submit") Revoke
38
+ .d-flex.justify-content-end
39
+ +updateButton(`/admin/agents/${a.id}/update`, a.update_to, '/admin/agents')
40
+ form.ms-1(method="post" action=`/admin/agents/${a.id}/revoke` onsubmit="return confirm('Revoke this agent token?')")
41
+ button.btn.btn-sm.btn-outline-danger(type="submit") Revoke
35
42
 
36
43
  .modal(tabindex="-1" id="addAgentModal")
37
44
  .modal-dialog
package/views/index.pug CHANGED
@@ -1,5 +1,7 @@
1
1
  extends layout
2
2
  include mixins/status-badge
3
+ include mixins/resource
4
+ include mixins/updates
3
5
 
4
6
  block content
5
7
  h1.h4.mb-2 Overview
@@ -24,7 +26,7 @@ block content
24
26
  .row.g-3
25
27
  each r in resources
26
28
  .col-sm-6.col-md-4.col-lg-3
27
- .card.h-100(class=`border-${{IDLE:'success',BUSY:'primary',OUT_OF_SERVICE:'danger',MAINTENANCE:'secondary',REGISTERED:'secondary'}[r.status] || 'secondary'}`)
29
+ .card.h-100(role="button" tabindex="0" data-resource-card=`resource-card-${r.id}` class=`border-${{IDLE:'success',BUSY:'primary',OUT_OF_SERVICE:'danger',MAINTENANCE:'secondary',REGISTERED:'secondary'}[r.status] || 'secondary'}`)
28
30
  .card-body
29
31
  h5.card-title= r.name
30
32
  p.card-text.mb-1
@@ -35,3 +37,9 @@ block content
35
37
  p.card-text.small.text-body-secondary busy: #{r.busy_source} #{r.busy_reason ? '— ' + r.busy_reason : ''}
36
38
  p.card-text.small.text-body-secondary
37
39
  | heartbeat: #{fmtDate(r.last_heartbeat_at, 'never')}
40
+
41
+ each r in resources
42
+ +resourceCard(r, groupsById, '/')
43
+
44
+ block scripts
45
+ script(src="/js/resource-card.js")
package/views/layout.pug CHANGED
@@ -10,7 +10,16 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
10
10
  body
11
11
  nav.navbar.navbar-expand-lg.bg-body-tertiary.border-bottom
12
12
  .container-fluid
13
- a.navbar-brand(href="/") TestHub
13
+ a.navbar-brand.d-flex.flex-column.lh-1.py-1(href="/")
14
+ span TestHub
15
+ small.text-body-secondary.fw-normal.mt-1(style="font-size: .7rem;")
16
+ | ver. #{coordinatorVersion}
17
+ if user && updates.coordinatorUpdate
18
+ span.badge.text-bg-warning.ms-1(
19
+ data-bs-toggle="tooltip"
20
+ data-bs-placement="bottom"
21
+ data-bs-title="Update the Coordinator by hand on its host: thub-admin self-update"
22
+ )= `v${updates.coordinatorUpdate} available`
14
23
  button.navbar-toggler(type="button" data-bs-toggle="collapse" data-bs-target="#nav")
15
24
  span.navbar-toggler-icon
16
25
  #nav.collapse.navbar-collapse
@@ -0,0 +1,93 @@
1
+ //- Resource actions and the resource info card (modal), shared by the
2
+ //- Overview and Resources pages. `returnTo` is the page the action forms
3
+ //- redirect back to.
4
+
5
+ mixin resourceActions(r, returnTo)
6
+ form.d-inline(method="post" action=`/resources/${r.id}/maintenance`)
7
+ input(type="hidden" name="enabled" value=r.status === 'MAINTENANCE' ? '0' : '1')
8
+ input(type="hidden" name="returnTo" value=returnTo)
9
+ if r.status === 'MAINTENANCE'
10
+ button.btn.btn-sm.btn-outline-secondary(
11
+ type="submit"
12
+ aria-label="Enable"
13
+ data-bs-toggle="tooltip"
14
+ data-bs-title="Bring this resource back into rotation so the scheduler can assign it jobs again"
15
+ )
16
+ i.bi.bi-play-circle
17
+ else
18
+ button.btn.btn-sm.btn-outline-secondary(
19
+ type="submit"
20
+ aria-label="Disable"
21
+ data-bs-toggle="tooltip"
22
+ data-bs-title="Take this resource out of rotation for maintenance — the scheduler will skip it"
23
+ )
24
+ i.bi.bi-tools
25
+ form.d-inline.ms-1(method="post" action=`/resources/${r.id}/rotate-token` onsubmit="return confirm('Rotate token? The Client will need the new one.')")
26
+ input(type="hidden" name="returnTo" value=returnTo)
27
+ button.btn.btn-sm.btn-outline-warning(
28
+ type="submit"
29
+ aria-label="Rotate token"
30
+ data-bs-toggle="tooltip"
31
+ data-bs-title="Invalidate the current resource token and issue a new one — the Client won't reconnect until it has it"
32
+ )
33
+ i.bi.bi-arrow-repeat
34
+ +updateButton(`/resources/${r.id}/update`, r.update_to, returnTo)
35
+
36
+ mixin resourceCard(r, groupsById, returnTo)
37
+ - const isLoopback = (a) => /^127\./.test(a) || a === '::1' || a === 'localhost'
38
+ - const local = ((r.host_info && r.host_info.addresses) || []).filter((a) => !isLoopback(a.address))
39
+ - const external = r.remote_addr && !isLoopback(r.remote_addr) ? r.remote_addr : null
40
+ .modal.fade(id=`resource-card-${r.id}` tabindex="-1" aria-labelledby=`resource-card-${r.id}-title` aria-hidden="true")
41
+ .modal-dialog.modal-dialog-centered
42
+ .modal-content
43
+ .modal-header
44
+ h5.modal-title(id=`resource-card-${r.id}-title`)= r.name
45
+ button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
46
+ .modal-body
47
+ dl.row.mb-0
48
+ dt.col-sm-4 Name
49
+ dd.col-sm-8= r.name
50
+ dt.col-sm-4 Type
51
+ dd.col-sm-8.text-uppercase= r.type
52
+ dt.col-sm-4 Status
53
+ dd.col-sm-8: +resourceBadge(r.status)
54
+ dt.col-sm-4 Client version
55
+ dd.col-sm-8: +versionCell(r.client_version, updates.latest.client, r.update_to)
56
+ dt.col-sm-4 IP addresses
57
+ dd.col-sm-8
58
+ if !local.length && !external
59
+ span.text-body-secondary —
60
+ ul.list-unstyled.mb-0
61
+ each a in local
62
+ li
63
+ code= a.address
64
+ span.text-body-secondary.small.ms-2= a.iface ? `${a.iface} · ${a.family}` : a.family
65
+ if external
66
+ li
67
+ code= external
68
+ span.text-body-secondary.small.ms-2(
69
+ data-bs-toggle="tooltip"
70
+ data-bs-title="The address the Coordinator last saw this Client connect from"
71
+ ) external
72
+ dt.col-sm-4 Labels
73
+ dd.col-sm-8
74
+ if r.labels.length
75
+ each l in r.labels
76
+ span.badge.text-bg-light.border.me-1= l
77
+ else
78
+ span.text-body-secondary —
79
+ dt.col-sm-4 Groups
80
+ dd.col-sm-8
81
+ if r.group_ids.length
82
+ each gid in r.group_ids
83
+ span.badge.text-bg-info.me-1(title=gid)= groupsById[gid] ? groupsById[gid].name : `(unknown: ${gid})`
84
+ else
85
+ span.text-body-secondary —
86
+ dt.col-sm-4 Busy source / reason
87
+ dd.col-sm-8= r.busy_source ? `${r.busy_source}${r.busy_reason ? ' — ' + r.busy_reason : ''}` : '—'
88
+ dt.col-sm-4 Last heartbeat
89
+ dd.col-sm-8= fmtDate(r.last_heartbeat_at, 'never')
90
+ if user.role === 'admin'
91
+ dt.col-sm-4 Actions
92
+ dd.col-sm-8.mb-0
93
+ +resourceActions(r, returnTo)
@@ -0,0 +1,67 @@
1
+ //- Version display and self-update controls (README §10.2), shared by
2
+ //- the Agents and Resources pages and the resource card.
3
+
4
+ //- An app's reported version, plus a pending update request or a newer
5
+ //- published version.
6
+ mixin versionCell(current, latest, updateTo)
7
+ span= current ? `v${current}` : '—'
8
+ if updateTo
9
+ span.badge.text-bg-warning.ms-1(
10
+ data-bs-toggle="tooltip"
11
+ data-bs-title="Self-update requested — cleared once it reports this version"
12
+ )= `→ v${updateTo}`
13
+ else if current && isNewer(latest, current)
14
+ span.badge.text-bg-secondary.ms-1(
15
+ data-bs-toggle="tooltip"
16
+ data-bs-title=`v${latest} is available`
17
+ ) update available
18
+
19
+ //- Latest published version of `app` (agent | client), "check now" and,
20
+ //- for admins, "update all".
21
+ mixin updatesToolbar(app, label, returnTo)
22
+ .d-flex.flex-wrap.align-items-center.gap-2.small.text-body-secondary
23
+ span
24
+ | Latest #{app}:
25
+ = ' '
26
+ strong= updates.latest[app] ? `v${updates.latest[app]}` : 'unknown'
27
+ if updates.checkedAt
28
+ = ` · checked ${fmtDate(updates.checkedAt)}`
29
+ if updates.error
30
+ i.bi.bi-exclamation-triangle.text-warning.ms-1(data-bs-toggle="tooltip" data-bs-title=updates.error)
31
+ if user.role === 'admin'
32
+ form.d-inline(method="post" action="/updates/check")
33
+ input(type="hidden" name="returnTo" value=returnTo)
34
+ button.btn.btn-sm.btn-outline-secondary(type="submit")
35
+ i.bi.bi-arrow-clockwise.me-1
36
+ | Check for updates
37
+ form.d-inline(
38
+ method="post"
39
+ action=app === 'agent' ? '/admin/agents/update-all' : '/resources/update-all'
40
+ onsubmit=`return confirm('Request a self-update of every ${label} to the latest version?')`
41
+ )
42
+ input(type="hidden" name="returnTo" value=returnTo)
43
+ button.btn.btn-sm.btn-outline-primary(type="submit" disabled=!updates.latest[app])
44
+ i.bi.bi-cloud-download.me-1
45
+ = `Update all ${label}`
46
+
47
+ //- Per-item "Update" / "Cancel update" button.
48
+ mixin updateButton(action, pending, returnTo)
49
+ form.d-inline.ms-1(method="post" action=action)
50
+ input(type="hidden" name="returnTo" value=returnTo)
51
+ if pending
52
+ input(type="hidden" name="cancel" value="1")
53
+ button.btn.btn-sm.btn-outline-warning(
54
+ type="submit"
55
+ aria-label="Cancel update"
56
+ data-bs-toggle="tooltip"
57
+ data-bs-title=`Cancel the pending self-update to v${pending}`
58
+ )
59
+ i.bi.bi-x-circle
60
+ else
61
+ button.btn.btn-sm.btn-outline-primary(
62
+ type="submit"
63
+ aria-label="Update"
64
+ data-bs-toggle="tooltip"
65
+ data-bs-title="Self-update to the latest published version"
66
+ )
67
+ i.bi.bi-cloud-download
@@ -1,16 +1,22 @@
1
1
  extends ../layout
2
2
  include ../mixins/status-badge
3
+ include ../mixins/resource
4
+ include ../mixins/updates
3
5
 
4
6
  block content
5
7
  h1.h4.mb-3 Resources
6
8
  p.text-body-secondary.small.
7
- Resources register themselves — start a Client with the shared join key and it appears here.
9
+ Resources register themselves — start a Client with the shared join key and it appears here. Click a resource for its details, including IP addresses.
10
+
11
+ .mb-3
12
+ +updatesToolbar('client', 'clients', '/resources')
8
13
 
9
14
  table.table.table-hover.align-middle
10
15
  thead
11
16
  tr
12
17
  th Name
13
18
  th Type
19
+ th Version
14
20
  th Status
15
21
  th Labels
16
22
  th Groups
@@ -20,9 +26,10 @@ block content
20
26
  th Actions
21
27
  tbody
22
28
  each r in resources
23
- tr
29
+ tr(role="button" tabindex="0" data-resource-card=`resource-card-${r.id}`)
24
30
  td= r.name
25
31
  td.text-uppercase= r.type
32
+ td: +versionCell(r.client_version, updates.latest.client, r.update_to)
26
33
  td: +resourceBadge(r.status)
27
34
  td
28
35
  each l in r.labels
@@ -37,29 +44,10 @@ block content
37
44
  td= fmtDate(r.last_heartbeat_at, 'never')
38
45
  if user.role === 'admin'
39
46
  td
40
- form.d-inline(method="post" action=`/resources/${r.id}/maintenance`)
41
- input(type="hidden" name="enabled" value=r.status === 'MAINTENANCE' ? '0' : '1')
42
- if r.status === 'MAINTENANCE'
43
- button.btn.btn-sm.btn-outline-secondary(
44
- type="submit"
45
- aria-label="Enable"
46
- data-bs-toggle="tooltip"
47
- data-bs-title="Bring this resource back into rotation so the scheduler can assign it jobs again"
48
- )
49
- i.bi.bi-play-circle
50
- else
51
- button.btn.btn-sm.btn-outline-secondary(
52
- type="submit"
53
- aria-label="Disable"
54
- data-bs-toggle="tooltip"
55
- data-bs-title="Take this resource out of rotation for maintenance — the scheduler will skip it"
56
- )
57
- i.bi.bi-tools
58
- form.d-inline.ms-1(method="post" action=`/resources/${r.id}/rotate-token` onsubmit="return confirm('Rotate token? The Client will need the new one.')")
59
- button.btn.btn-sm.btn-outline-warning(
60
- type="submit"
61
- aria-label="Rotate token"
62
- data-bs-toggle="tooltip"
63
- data-bs-title="Invalidate the current resource token and issue a new one — the Client won't reconnect until it has it"
64
- )
65
- i.bi.bi-arrow-repeat
47
+ +resourceActions(r, '/resources')
48
+
49
+ each r in resources
50
+ +resourceCard(r, groupsById, '/resources')
51
+
52
+ block scripts
53
+ script(src="/js/resource-card.js")