@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 +1 -1
- package/public/css/thub.css +8 -0
- package/src/api/admin.js +9 -0
- package/src/services/agents.js +19 -1
- package/src/services/jobs.js +13 -6
- package/src/services/registry.js +11 -3
- package/src/services/scheduler.js +6 -1
- package/src/web/routes.js +38 -4
- package/test/agents.test.js +38 -0
- package/test/scheduler.test.js +18 -0
- package/views/admin/agents.pug +25 -4
- package/views/layout.pug +4 -3
- package/views/mixins/resource.pug +8 -17
- package/views/mixins/updates.pug +15 -2
- package/views/resources/list.pug +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.0.
|
|
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": {
|
package/public/css/thub.css
CHANGED
|
@@ -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();
|
package/src/services/agents.js
CHANGED
|
@@ -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 };
|
package/src/services/jobs.js
CHANGED
|
@@ -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
|
|
149
|
-
//
|
|
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
|
|
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
|
|
156
|
-
'
|
|
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
|
);
|
package/src/services/registry.js
CHANGED
|
@@ -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
|
|
473
|
-
//
|
|
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,
|
|
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 ?
|
|
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)
|
|
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.
|
|
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 })}
|
|
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
|
+
});
|
package/test/scheduler.test.js
CHANGED
|
@@ -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' }),
|
package/views/admin/agents.pug
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
38
|
-
.
|
|
39
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
//-
|
|
6
|
-
|
|
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
|
|
212
|
-
//-
|
|
213
|
-
//-
|
|
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' :
|
|
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
|
|
293
|
+
+resourceActions(r, returnTo)
|
package/views/mixins/updates.pug
CHANGED
|
@@ -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
|
|
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
|
package/views/resources/list.pug
CHANGED
|
@@ -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: +
|
|
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
|