@andrian.yablonskyy/thub-coordinator 1.0.24 → 1.0.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.24",
3
+ "version": "1.0.26",
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": {
@@ -53,3 +53,11 @@
53
53
  font-size: 0.55em;
54
54
  vertical-align: middle;
55
55
  }
56
+
57
+ /* Toast text (layout.pug): "\n" in a flash message is a line break, and a
58
+ long unbroken token/URL wraps instead of overflowing the toast. */
59
+ .thub-toast-text {
60
+ white-space: pre-line;
61
+ overflow-wrap: anywhere;
62
+ min-width: 0;
63
+ }
package/src/api/admin.js CHANGED
@@ -37,6 +37,15 @@ function createAdminRouter({ services }){
37
37
  }
38
38
  });
39
39
 
40
+ router.post('/agents/:id', (req, res, next) => {
41
+ try {
42
+ res.json(services.agents.rename(req.params.id, req.body?.name));
43
+ }
44
+ catch (err){
45
+ next(err);
46
+ }
47
+ });
48
+
40
49
  router.delete('/agents/:id', (req, res) => {
41
50
  services.agents.revoke(req.params.id);
42
51
  res.status(204).end();
@@ -42,6 +42,24 @@ function createAgentsService(db, { events }){
42
42
  return { agent: get(id), token };
43
43
  }
44
44
 
45
+ // Dashboard "Edit": the name is only a label (the token is what
46
+ // authenticates), so renaming never affects anything using the agent.
47
+ function rename(id, name){
48
+ const agent = get(id),
49
+ trimmed = typeof name === 'string' ? name.trim() : '';
50
+ if (!agent){
51
+ throw Object.assign(new Error('Unknown agent'), { status: 404 });
52
+ }
53
+ if (!trimmed || trimmed.length > 100){
54
+ throw Object.assign(new Error('Agent name must be 1-100 characters'), { status: 400 });
55
+ }
56
+ if (trimmed !== agent.name){
57
+ db.prepare('UPDATE agents SET name = ? WHERE id = ?').run(trimmed, id);
58
+ events.record('agent', id, 'agent.renamed', { from: agent.name, to: trimmed });
59
+ }
60
+ return get(id);
61
+ }
62
+
45
63
  function revoke(id){
46
64
  db.prepare('UPDATE agents SET revoked_at = ? WHERE id = ?').run(new Date().toISOString(), id);
47
65
  events.record('agent', id, 'agent.revoked', {});
@@ -79,7 +97,7 @@ function createAgentsService(db, { events }){
79
97
  return ids.length;
80
98
  }
81
99
 
82
- return { get, getByTokenHash, list, create, revoke, touchLastUsed, setUpdateTo, requestUpdateAll };
100
+ return { get, getByTokenHash, list, create, rename, revoke, touchLastUsed, setUpdateTo, requestUpdateAll };
83
101
  }
84
102
 
85
103
  module.exports = { createAgentsService };
@@ -145,15 +145,22 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
145
145
  spec.target.client = resource.id;
146
146
  }
147
147
 
148
- // A job-supplied Docker image needs an SW Client that opted in — say so
149
- // specifically rather than the generic "no resource can satisfy".
150
- const needs = { jobImage: Boolean(spec.firmware.image) },
148
+ // A job-supplied Docker image or command needs a Client that opted in —
149
+ // say which, rather than the generic "no resource can satisfy".
150
+ const needs = { jobImage: Boolean(spec.firmware.image), jobCommand: Boolean(spec.tests.command) },
151
151
  { type, labels, group, client } = spec.target;
152
- if (needs.jobImage && registry.everSatisfiable(type, labels, group, client) && !registry.everSatisfiable(type, labels, group, client, needs)){
152
+ if ((needs.jobImage || needs.jobCommand) && registry.everSatisfiable(type, labels, group, client) &&
153
+ !registry.everSatisfiable(type, labels, group, client, needs)){
154
+ const missing = [
155
+ needs.jobImage && !registry.everSatisfiable(type, labels, group, client, { jobImage: true }) &&
156
+ `job-supplied Docker images (${spec.firmware.image}) — "sw": { "allowJobImages": true }`,
157
+ needs.jobCommand && !registry.everSatisfiable(type, labels, group, client, { jobCommand: true }) &&
158
+ 'job-supplied commands (--run) — "allowJobCommands": true'
159
+ ].filter(Boolean);
153
160
  throw Object.assign(
154
161
  new Error(
155
- `No matching SW Client runs job-supplied Docker images (${spec.firmware.image}) — enable it on a Client ` +
156
- 'with "sw": { "allowJobImages": true } in its config, or pass a firmware URL instead'
162
+ `No matching Client runs ${missing.length ? missing.join(', nor ') : 'both a job-supplied Docker image and command together'}` +
163
+ ' — enable it in a Client\'s config'
157
164
  ),
158
165
  { status: 422 }
159
166
  );
@@ -67,10 +67,16 @@ const MAX_DEVICES = 8,
67
67
  present: typeof d?.present === 'boolean' ? d.present : null
68
68
  });
69
69
 
70
+ // Plus `allowJobCommands` (either type): runs a job's own `tests.command`.
70
71
  function sanitizeCapabilities(caps){
71
72
  if (!caps || typeof caps !== 'object'){
72
73
  return null;
73
74
  }
75
+ const typed = sanitizeTypedCapabilities(caps);
76
+ return typed ? { ...typed, allowJobCommands: caps.allowJobCommands === true } : null;
77
+ }
78
+
79
+ function sanitizeTypedCapabilities(caps){
74
80
  if (caps.sw && typeof caps.sw === 'object'){
75
81
  const sw = caps.sw;
76
82
  return {
@@ -469,13 +475,15 @@ function createRegistryService(db, { bus, events }){
469
475
  );
470
476
  }
471
477
 
472
- // `needs.jobImage`: the job brings its own Docker image (firmware.image),
473
- // which only SW Clients that opted in (sw.allowJobImages) will run.
478
+ // `needs`: what a job brings that a Client must have opted in to run —
479
+ // jobImage (firmware.image; sw.allowJobImages) and jobCommand
480
+ // (tests.command; allowJobCommands).
474
481
  function matchesTarget(r, labels, groupId, resourceId, needs = {}){
475
482
  return labels.every((l) => r.labels.includes(l)) &&
476
483
  (!groupId || r.group_ids.includes(groupId)) &&
477
484
  (!resourceId || r.id === resourceId) &&
478
- (!needs.jobImage || r.capabilities?.sw?.allowJobImages === true);
485
+ (!needs.jobImage || r.capabilities?.sw?.allowJobImages === true) &&
486
+ (!needs.jobCommand || r.capabilities?.allowJobCommands === true);
479
487
  }
480
488
 
481
489
  // Admin: remove a resource from the registry (dashboard "Remove"). Refused
@@ -15,6 +15,11 @@
15
15
 
16
16
  const { JOB_STATES, BUSY_SOURCES } = require('@andrian.yablonskyy/thub-common');
17
17
 
18
+ // What a job needs a Client to have opted in to (registry matchesTarget).
19
+ function jobNeeds(spec){
20
+ return { jobImage: Boolean(spec.firmware?.image), jobCommand: Boolean(spec.tests?.command) };
21
+ }
22
+
18
23
  function rowToJob(row){
19
24
  return { ...row, spec: JSON.parse(row.spec) };
20
25
  }
@@ -43,7 +48,7 @@ function createScheduler(db, { bus, events, registry, config }){
43
48
  for (const job of queued){
44
49
  const { type, labels = [], group, client } = job.spec.target,
45
50
  candidates = registry
46
- .findIdleCandidates(type, labels, group, client, { jobImage: Boolean(job.spec.firmware?.image) })
51
+ .findIdleCandidates(type, labels, group, client, jobNeeds(job.spec))
47
52
  .filter((r) => !claimedResourceIds.has(r.id));
48
53
  if (candidates.length === 0){
49
54
  continue;
package/src/web/routes.js CHANGED
@@ -125,6 +125,14 @@ function createWebRouter({ services, config }){
125
125
  return resources.map((r) => ({ ...r, activeJob: services.jobs.activeForResource(r.id) }));
126
126
  }
127
127
 
128
+ // Express 5 leaves req.body undefined for a POST without a form body (a
129
+ // browser always sends one; curl or a script may not) — treat it as an
130
+ // empty form rather than crashing every handler that reads a field.
131
+ router.use((req, res, next) => {
132
+ req.body ??= {};
133
+ next();
134
+ });
135
+
128
136
  router.use((req, res, next) => {
129
137
  const user = req.session?.user || null;
130
138
  res.locals.user = user;
@@ -388,7 +396,7 @@ function createWebRouter({ services, config }){
388
396
  'warning',
389
397
  pending
390
398
  ? `Stopped job ${stoppedJob} on ${r.name}; ${r.name} is removed as soon as its Client confirms the job ended.`
391
- : `Removed ${r.name}.` + (canceledJobs.length ? ` Canceled job(s) queued for it: ${canceledJobs.join(', ')}.` : '')
399
+ : `Removed ${r.name}.` + (canceledJobs.length ? `\nCanceled job(s) queued for it: ${canceledJobs.join(', ')}.` : '')
392
400
  );
393
401
  }
394
402
  catch (err){
@@ -403,7 +411,7 @@ function createWebRouter({ services, config }){
403
411
  services.db.prepare('UPDATE resources SET token_hash = ? WHERE id = ?').run(hashToken(token), req.params.id);
404
412
  services.events.record('resource', req.params.id, 'resource.token_rotated', {});
405
413
  // Sticky: shown only once, so it mustn't close before it's copied.
406
- flash(req, 'warning', `New resource token (copy it now): ${token}`, { sticky: true });
414
+ flash(req, 'warning', `New resource token (copy it now):\n${token}`, { sticky: true });
407
415
  res.redirect(returnTo(req, '/resources'));
408
416
  });
409
417
 
@@ -491,8 +499,20 @@ function createWebRouter({ services, config }){
491
499
  }));
492
500
 
493
501
  router.post('/resources/:id/update', requireAdminRole, updateAction(async (req) => {
502
+ // Cancel (next to the version): withdraw the request and, if the host is
503
+ // already holding for the update, abort that too (the Client's next
504
+ // heartbeat) — the one control for canceling a Client update.
494
505
  if (req.body.cancel === '1'){
495
- services.registry.setUpdateTo(req.params.id, null);
506
+ const r = services.registry.get(req.params.id);
507
+ if (!r){
508
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
509
+ }
510
+ services.registry.setUpdateTo(r.id, null);
511
+ if (r.activity?.state === 'update-hold'){
512
+ services.commands.push(r.id, { command: 'cancel-update' });
513
+ services.events.record('resource', r.id, 'resource.activity_canceled', { activity: 'update-hold', by: req.session.user.username });
514
+ return { type: 'warning', text: `Canceling the self-update on ${r.name}'s host (on its next heartbeat).` };
515
+ }
496
516
  return 'Update request canceled.';
497
517
  }
498
518
  const version = await services.updates.targetVersion('client');
@@ -568,7 +588,7 @@ function createWebRouter({ services, config }){
568
588
  req,
569
589
  'warning',
570
590
  `Deleted ${r.jobs} finished job(s) and ${r.events} history record(s) from before ` +
571
- `${formatDateTime(r.before, { timeZone: tz })}. Database: ${mb(r.bytesBefore)} → ${mb(r.bytesAfter)}.`
591
+ `${formatDateTime(r.before, { timeZone: tz })}.\nDatabase: ${mb(r.bytesBefore)} → ${mb(r.bytesAfter)}.`
572
592
  );
573
593
  }
574
594
  catch (err){
@@ -661,6 +681,20 @@ function createWebRouter({ services, config }){
661
681
  res.render('admin/agents', { title: 'Agents', active: 'agents', agents: services.agents.list(), newToken: token });
662
682
  });
663
683
 
684
+ router.post('/admin/agents/:id/rename', requireAdminRole, (req, res) => {
685
+ try {
686
+ const before = services.agents.get(req.params.id)?.name,
687
+ agent = services.agents.rename(req.params.id, req.body.name);
688
+ if (before !== agent.name){
689
+ flash(req, 'success', `Renamed agent "${before}" to "${agent.name}".`);
690
+ }
691
+ }
692
+ catch (err){
693
+ flash(req, 'danger', err.message);
694
+ }
695
+ res.redirect('/admin/agents');
696
+ });
697
+
664
698
  router.post('/admin/agents/:id/revoke', requireAdminRole, (req, res) => {
665
699
  services.agents.revoke(req.params.id);
666
700
  res.redirect('/admin/agents');
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file packages/coordinator/test/agents.test.js
3
+ * @description Tests: agent rename (dashboard Edit) — a label only; the token keeps working
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
+ { openDb } = require('../src/db'),
19
+ { createEventsService } = require('../src/services/events'),
20
+ { createAgentsService } = require('../src/services/agents'),
21
+ { hashToken } = require('../src/services/tokens');
22
+
23
+ test('renaming an agent changes only its name; its token keeps authenticating', () => {
24
+ const db = openDb(':memory:'),
25
+ agents = createAgentsService(db, { events: createEventsService(db) }),
26
+ { agent, token } = agents.create({ name: 'ci-old', kind: 'ci' });
27
+
28
+ assert.equal(agents.rename(agent.id, ' ci-firmware ').name, 'ci-firmware');
29
+ assert.equal(agents.getByTokenHash(hashToken(token)).id, agent.id);
30
+ assert.equal(db.prepare('SELECT COUNT(*) AS n FROM events WHERE type = \'agent.renamed\'').get().n, 1);
31
+
32
+ agents.rename(agent.id, 'ci-firmware'); // unchanged: no new event
33
+ assert.equal(db.prepare('SELECT COUNT(*) AS n FROM events WHERE type = \'agent.renamed\'').get().n, 1);
34
+
35
+ assert.throws(() => agents.rename(agent.id, ' '), /1-100 characters/);
36
+ assert.throws(() => agents.rename(agent.id, 'x'.repeat(101)), /1-100 characters/);
37
+ assert.throws(() => agents.rename('agt_nope', 'x'), /Unknown agent/);
38
+ });
@@ -547,6 +547,24 @@ test('a job-supplied Docker image only goes to SW Clients that allow it; otherwi
547
547
  assert.equal(jobs.get(fileJob.id).resource_id, strict.id); // firmware URLs still go anywhere
548
548
  });
549
549
 
550
+ test('a job-supplied command only goes to Clients with allowJobCommands; otherwise 422 naming it', () => {
551
+ const { registry, agents, jobs, scheduler } = buildTestServices(),
552
+ { agent } = agents.create({ name: 'dev', kind: 'cli' }),
553
+ commandJob = makeSpec({ tests: { url: 'https://x/t.tar.gz', command: 'make test' } }),
554
+ plain = registerResource(registry, { name: 'sw-plain', type: 'sw' });
555
+ registry.heartbeat(plain.id, { state: 'idle' });
556
+
557
+ assert.throws(() => jobs.create({ agentId: agent.id, source: 'cli', spec: commandJob }), /allowJobCommands/);
558
+
559
+ const { resourceId } = registry.registerAuto({
560
+ clientId: 'c-cmd', name: 'sw-cmd', type: 'sw', labels: [], capabilities: { sw: { image: 'emu' }, allowJobCommands: true }
561
+ });
562
+ registry.heartbeat(resourceId, { state: 'idle' });
563
+ const job = jobs.create({ agentId: agent.id, source: 'cli', spec: commandJob });
564
+ scheduler.runPass();
565
+ assert.equal(jobs.get(job.id).resource_id, resourceId);
566
+ });
567
+
550
568
  test('a resource can belong to several groups at once', () => {
551
569
  const { registry, groups } = buildTestServices(),
552
570
  g1 = groups.create({ name: 'g1' }),
@@ -29,14 +29,20 @@ block content
29
29
  tr(class=a.revoked_at ? 'opacity-50' : '')
30
30
  td= a.name
31
31
  td.text-uppercase= a.kind
32
- td: +versionCell(a.version, updates.latest.agent, a.update_to)
32
+ //- A revoked token can't pick an update up: no button.
33
+ td: +versionWithUpdate(a.version, updates.latest.agent, a.update_to, `/admin/agents/${a.id}/update`, '/admin/agents', !a.revoked_at)
33
34
  td= fmtDate(a.created_at)
34
35
  td= fmtDate(a.last_used_at, 'never')
35
36
  td= a.revoked_at ? 'revoked' : 'active'
36
37
  td
37
- if !a.revoked_at
38
- .d-flex.justify-content-end
39
- +updateButton(`/admin/agents/${a.id}/update`, a.update_to, '/admin/agents')
38
+ .d-flex.justify-content-end
39
+ button.btn.btn-sm.btn-outline-secondary(
40
+ type="button"
41
+ data-bs-toggle="modal"
42
+ data-bs-target=`#editAgentModal-${a.id}`
43
+ aria-label=`Edit agent ${a.name}`
44
+ ) Edit
45
+ if !a.revoked_at
40
46
  form.ms-1(
41
47
  method="post"
42
48
  action=`/admin/agents/${a.id}/revoke`
@@ -46,6 +52,21 @@ block content
46
52
  )
47
53
  button.btn.btn-sm.btn-outline-danger(type="submit") Revoke
48
54
 
55
+ .modal(tabindex="-1" id=`editAgentModal-${a.id}` aria-labelledby=`editAgentTitle-${a.id}`)
56
+ .modal-dialog
57
+ .modal-content
58
+ form(method="post" action=`/admin/agents/${a.id}/rename`)
59
+ .modal-header
60
+ h5.modal-title(id=`editAgentTitle-${a.id}`) Edit agent
61
+ button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
62
+ .modal-body
63
+ label.form-label(for=`agentName-${a.id}`) Name
64
+ input.form-control(id=`agentName-${a.id}` type="text" name="name" value=a.name required maxlength="100")
65
+ .form-text Only a label — the agent's token, and everything using it, keep working.
66
+ .modal-footer
67
+ button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
68
+ button.btn.btn-primary(type="submit") Save
69
+
49
70
  .modal(tabindex="-1" id="addAgentModal")
50
71
  .modal-dialog
51
72
  .modal-content
package/views/layout.pug CHANGED
@@ -134,9 +134,10 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
134
134
  data-sticky=m.sticky ? '1' : false
135
135
  )
136
136
  .d-flex
137
- .toast-body.d-flex.gap-2
138
- i.bi(class=`bi-${icon}`)
139
- span= m.text
137
+ //- Multi-line: "\n" in a message is a line break (thub.css).
138
+ .toast-body.d-flex.align-items-start.gap-2
139
+ i.bi.flex-shrink-0(class=`bi-${icon}`)
140
+ span.thub-toast-text= m.text
140
141
  button.btn-close.me-2.m-auto(
141
142
  type="button"
142
143
  class=type === 'danger' || type === 'success' ? 'btn-close-white' : ''
@@ -2,9 +2,8 @@
2
2
  //- Overview and Resources pages. `returnTo` is the page the action forms
3
3
  //- redirect back to.
4
4
 
5
- //- `opts.update: false` leaves out the Update button — the resource card
6
- //- shows it on its Client version line instead.
7
- mixin resourceActions(r, returnTo, opts = {})
5
+ //- Update isn't here: it sits next to the version (+versionWithUpdate).
6
+ mixin resourceActions(r, returnTo)
8
7
  form.d-inline(method="post" action=`/resources/${r.id}/maintenance`)
9
8
  input(type="hidden" name="enabled" value=r.status === 'MAINTENANCE' ? '0' : '1')
10
9
  input(type="hidden" name="returnTo" value=returnTo)
@@ -40,8 +39,6 @@ mixin resourceActions(r, returnTo, opts = {})
40
39
  data-bs-title="Invalidate the current resource token and issue a new one — the Client won't reconnect until it has it"
41
40
  )
42
41
  i.bi.bi-arrow-repeat
43
- if opts.update !== false
44
- +updateButton(`/resources/${r.id}/update`, r.update_to, returnTo)
45
42
  +removeResourceButton(r, returnTo)
46
43
 
47
44
  //- Remove: a plain confirm when the resource is idle; with a running job,
@@ -208,12 +205,12 @@ mixin uptimeRows(r, returnTo)
208
205
  if user.role === 'admin'
209
206
  +cancelActivityButton(r, returnTo)
210
207
 
211
- //- Cancel the Client's current activity (its job, a local lock or a
212
- //- self-update hold) — icon only, on the card's Task / status line, and
213
- //- only while there's something to cancel.
208
+ //- Cancel the Client's current job or local lock — icon only, on the card's
209
+ //- Task / status line, and only while there's one. A self-update hold is
210
+ //- canceled from the version line instead (+versionWithUpdate).
214
211
  mixin cancelActivityButton(r, returnTo)
215
212
  - const state = r.activity && r.activity.state
216
- - const what = r.activeJob ? `job ${r.activeJob.id}` : state === 'locked' ? 'the local lock' : state === 'update-hold' ? 'the self-update' : null
213
+ - const what = r.activeJob ? `job ${r.activeJob.id}` : state === 'locked' ? 'the local lock' : null
217
214
  if what
218
215
  form.d-inline.ms-2(
219
216
  method="post"
@@ -252,13 +249,7 @@ mixin resourceCard(r, groupsById, returnTo)
252
249
  dd.col-sm-8: +resourceBadge(r.status, r.remove_requested_at)
253
250
  +uptimeRows(r, returnTo)
254
251
  dt.col-sm-4 Client version
255
- dd.col-sm-8
256
- +versionCell(r.client_version, updates.latest.client, r.update_to)
257
- //- Admins: Update only when a newer Client is published (or
258
- //- Cancel, while one is requested). An unknown version (too
259
- //- old to report it) can't be compared, so no button.
260
- if user.role === 'admin' && (r.update_to || (r.client_version && isNewer(updates.latest.client, r.client_version)))
261
- +updateButton(`/resources/${r.id}/update`, r.update_to, returnTo)
252
+ dd.col-sm-8: +versionWithUpdate(r.client_version, updates.latest.client, r.update_to, `/resources/${r.id}/update`, returnTo, true, r.activity && r.activity.state === 'update-hold')
262
253
  dt.col-sm-4 IP addresses
263
254
  dd.col-sm-8
264
255
  if !local.length && !external
@@ -299,4 +290,4 @@ mixin resourceCard(r, groupsById, returnTo)
299
290
  if user.role === 'admin'
300
291
  dt.col-sm-4 Actions
301
292
  dd.col-sm-8.mb-0
302
- +resourceActions(r, returnTo, { update: false })
293
+ +resourceActions(r, returnTo)
@@ -16,6 +16,18 @@ mixin versionCell(current, latest, updateTo)
16
16
  data-bs-title=`v${latest} is available`
17
17
  ) update available
18
18
 
19
+ //- A version cell with its Update button right next to it — shown only to
20
+ //- admins, and only when a newer version is published (or as Cancel while
21
+ //- an update is requested). An unknown version (too old to report it)
22
+ //- can't be compared, so no button; `canUpdate: false` (e.g. a revoked
23
+ //- agent) hides it too. `holding`: a Client's host is already holding for
24
+ //- a self-update — Cancel then also aborts that (the one cancel control for
25
+ //- an update). Agents table, Resources table, resource card.
26
+ mixin versionWithUpdate(current, latest, updateTo, action, returnTo, canUpdate = true, holding = false)
27
+ +versionCell(current, latest, updateTo)
28
+ if user.role === 'admin' && canUpdate && (updateTo || holding || (current && isNewer(latest, current)))
29
+ +updateButton(action, updateTo || (holding ? true : null), returnTo)
30
+
19
31
  //- Latest published version of `app` (agent | client), "check now" and,
20
32
  //- for admins, "update all".
21
33
  mixin updatesToolbar(app, label, returnTo)
@@ -47,7 +59,8 @@ mixin updatesToolbar(app, label, returnTo)
47
59
  i.bi.bi-cloud-download.me-1
48
60
  = `Update all ${label}`
49
61
 
50
- //- Per-item "Update" / "Cancel update" button.
62
+ //- Per-item "Update" / "Cancel update" button. `pending`: the requested
63
+ //- version, or true for an update already under way without one.
51
64
  mixin updateButton(action, pending, returnTo)
52
65
  form.d-inline.ms-1(method="post" action=action)
53
66
  input(type="hidden" name="returnTo" value=returnTo)
@@ -57,7 +70,7 @@ mixin updateButton(action, pending, returnTo)
57
70
  type="submit"
58
71
  aria-label="Cancel update"
59
72
  data-bs-toggle="tooltip"
60
- data-bs-title=`Cancel the pending self-update to v${pending}`
73
+ data-bs-title=pending === true ? 'Cancel the self-update in progress on this host' : `Cancel the pending self-update to v${pending}`
61
74
  )
62
75
  i.bi.bi-x-circle
63
76
  else
@@ -34,7 +34,7 @@ block content
34
34
  tr(role="button" tabindex="0" data-resource-card=`resource-card-${r.id}`)
35
35
  td= r.name
36
36
  td.text-uppercase= r.type
37
- td: +versionCell(r.client_version, updates.latest.client, r.update_to)
37
+ td: +versionWithUpdate(r.client_version, updates.latest.client, r.update_to, `/resources/${r.id}/update`, list.pageUrl(), true, r.activity && r.activity.state === 'update-hold')
38
38
  td: +resourceBadge(r.status, r.remove_requested_at)
39
39
  td
40
40
  each l in r.labels