@andrian.yablonskyy/thub-coordinator 1.0.30 → 1.0.32
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 +2 -2
- package/src/api/agent.js +3 -3
- package/src/api/sse.js +24 -2
- package/src/server.js +13 -0
- package/src/services/jobs.js +29 -4
- package/src/services/registry.js +36 -1
- package/src/web/routes.js +4 -0
- package/test/scheduler.test.js +46 -0
- package/views/jobs/show.pug +8 -0
- package/views/layout.pug +6 -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.32",
|
|
4
4
|
"description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"engines": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"lint:fix": "eslint . --fix"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@andrian.yablonskyy/thub-common": "
|
|
22
|
+
"@andrian.yablonskyy/thub-common": "^1.0.27",
|
|
23
23
|
"better-sqlite3": "^12.4.1",
|
|
24
24
|
"express": "^5.1.0",
|
|
25
25
|
"express-session": "^1.18.2",
|
package/src/api/agent.js
CHANGED
|
@@ -47,7 +47,7 @@ function createAgentRouter({ services, config }){
|
|
|
47
47
|
}
|
|
48
48
|
const resource = job.resource_id ? services.registry.get(job.resource_id) : null;
|
|
49
49
|
res.json({
|
|
50
|
-
...job,
|
|
50
|
+
...services.jobs.publicJob(job),
|
|
51
51
|
resource: resource ? { id: resource.id, name: resource.name } : job.resource_name ? { id: null, name: job.resource_name } : null
|
|
52
52
|
});
|
|
53
53
|
});
|
|
@@ -60,13 +60,13 @@ function createAgentRouter({ services, config }){
|
|
|
60
60
|
mine: req.query.mine === 'true' || req.query.mine === '1',
|
|
61
61
|
limit: req.query.limit ? Number(req.query.limit) : undefined
|
|
62
62
|
});
|
|
63
|
-
res.json({ jobs });
|
|
63
|
+
res.json({ jobs: jobs.map(services.jobs.publicJob) });
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
router.post('/jobs/:id/cancel', auth, (req, res, next) => {
|
|
67
67
|
try {
|
|
68
68
|
const job = services.jobs.cancel(req.params.id, { agentId: req.agent.id, isAdmin: false });
|
|
69
|
-
res.json(job);
|
|
69
|
+
res.json(services.jobs.publicJob(job));
|
|
70
70
|
}
|
|
71
71
|
catch (err){
|
|
72
72
|
next(err);
|
package/src/api/sse.js
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
|
-
const { TERMINAL_JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
16
|
+
const { TERMINAL_JOB_STATES, JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
17
|
+
|
|
18
|
+
const WAITING_CHECK_MS = 15_000;
|
|
17
19
|
|
|
18
20
|
// §6.4: log / state / end events, resumable via Last-Event-ID (§7: "the
|
|
19
21
|
// Agent reconnects with exponential backoff and resumes from the last seq").
|
|
@@ -62,7 +64,26 @@ function attachJobStream(req, res, { jobId, services, config }){
|
|
|
62
64
|
const resourceName = (id) => (id ? registry.get(id)?.name : undefined);
|
|
63
65
|
writeEvent('state', { state: current.state, resource: resourceName(current.resource_id) });
|
|
64
66
|
|
|
65
|
-
|
|
67
|
+
// While QUEUED: why no Client has taken it yet (`waiting`), re-checked
|
|
68
|
+
// as Clients come and go; sent again only when the reason changes.
|
|
69
|
+
let lastReason = null;
|
|
70
|
+
function checkWaiting(){
|
|
71
|
+
const job = jobs.get(jobId);
|
|
72
|
+
if (job?.state !== JOB_STATES.QUEUED){
|
|
73
|
+
lastReason = null;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const { type, labels = [], group, client } = job.spec.target,
|
|
77
|
+
reason = registry.waitingReason(type, labels, group, client, { jobImage: Boolean(job.spec.image) });
|
|
78
|
+
if (reason && reason !== lastReason){
|
|
79
|
+
writeEvent('waiting', { reason });
|
|
80
|
+
}
|
|
81
|
+
lastReason = reason;
|
|
82
|
+
}
|
|
83
|
+
checkWaiting();
|
|
84
|
+
const waitingTimer = setInterval(checkWaiting, WAITING_CHECK_MS),
|
|
85
|
+
|
|
86
|
+
onLog = (entry) => {
|
|
66
87
|
if (entry.jobId === jobId){
|
|
67
88
|
writeEvent('log', { ts: entry.ts, stream: entry.stream, line: entry.line }, entry.seq);
|
|
68
89
|
}
|
|
@@ -82,6 +103,7 @@ function attachJobStream(req, res, { jobId, services, config }){
|
|
|
82
103
|
};
|
|
83
104
|
|
|
84
105
|
function cleanup(){
|
|
106
|
+
clearInterval(waitingTimer);
|
|
85
107
|
bus.off('job.log', onLog);
|
|
86
108
|
bus.off('job.state', onState);
|
|
87
109
|
bus.off('job.finished', onFinished);
|
package/src/server.js
CHANGED
|
@@ -90,6 +90,14 @@ function buildServices(config){
|
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
function commonPath(){
|
|
94
|
+
return path.dirname(require.resolve('@andrian.yablonskyy/thub-common/package.json'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function commonVersion(){
|
|
98
|
+
return require('@andrian.yablonskyy/thub-common/package.json').version;
|
|
99
|
+
}
|
|
100
|
+
|
|
93
101
|
function createApp(config, services){
|
|
94
102
|
const app = express();
|
|
95
103
|
app.set('view engine', 'pug');
|
|
@@ -98,6 +106,10 @@ function createApp(config, services){
|
|
|
98
106
|
app.locals.services = services;
|
|
99
107
|
// Shown in every dashboard page's footer (layout.pug).
|
|
100
108
|
app.locals.coordinatorVersion = require('../package.json').version;
|
|
109
|
+
// The thub-common this process actually loaded (it validates job specs):
|
|
110
|
+
// installs keep the one they got, so it can lag behind — shown on the
|
|
111
|
+
// dashboard and logged at startup to make that visible.
|
|
112
|
+
app.locals.commonVersion = commonVersion();
|
|
101
113
|
|
|
102
114
|
app.use(express.json({ limit: '2mb' }));
|
|
103
115
|
app.use(express.urlencoded({ extended: true }));
|
|
@@ -162,6 +174,7 @@ function start(configPath){
|
|
|
162
174
|
const server = app.listen(config.port, config.host, () => {
|
|
163
175
|
console.log(`Config: ${config.configPath || '(built-in defaults)'}; data: ${config.dataDir}`);
|
|
164
176
|
console.log(`TestHub Coordinator listening on http://${config.host}:${config.port}`);
|
|
177
|
+
console.log(`Coordinator v${require('../package.json').version}, thub-common v${commonVersion()} (${commonPath()})`);
|
|
165
178
|
});
|
|
166
179
|
|
|
167
180
|
return { app, server, services, config };
|
package/src/services/jobs.js
CHANGED
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
|
-
const { validateJobSpec, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
|
|
17
|
-
{ paginate } = require('./list-prefs')
|
|
16
|
+
const { validateJobSpec, maskEnv, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
|
|
17
|
+
{ paginate } = require('./list-prefs'),
|
|
18
|
+
// Named in spec errors: a field the Agent sends but this thub-common
|
|
19
|
+
// doesn't know means the Coordinator needs an update.
|
|
20
|
+
VERSIONS = `Coordinator v${require('../../package.json').version}, ` +
|
|
21
|
+
`thub-common v${require('@andrian.yablonskyy/thub-common/package.json').version}`;
|
|
18
22
|
|
|
19
23
|
function rowToJob(row){
|
|
20
24
|
if (!row){
|
|
@@ -131,7 +135,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
131
135
|
rawSpec?.priority === undefined ? { ...rawSpec, priority: DEFAULT_PRIORITY[source] } : rawSpec
|
|
132
136
|
);
|
|
133
137
|
if (!valid){
|
|
134
|
-
throw Object.assign(new Error(`Invalid job spec: ${errors.join('; ')}`), { status: 400 });
|
|
138
|
+
throw Object.assign(new Error(`Invalid job spec (${VERSIONS}): ${errors.join('; ')}`), { status: 400 });
|
|
135
139
|
}
|
|
136
140
|
spec.source = source;
|
|
137
141
|
|
|
@@ -244,6 +248,10 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
244
248
|
bus.emit('job.queued', { jobId: id });
|
|
245
249
|
}
|
|
246
250
|
if (TERMINAL_JOB_STATES.has(state)){
|
|
251
|
+
// A LOST job may be requeued (markLost), and needs its env for that.
|
|
252
|
+
if (state !== JOB_STATES.LOST){
|
|
253
|
+
forgetEnv(id);
|
|
254
|
+
}
|
|
247
255
|
bus.emit('job.finished', { jobId: id, state });
|
|
248
256
|
// LOST means the resource is presumed OUT_OF_SERVICE (that's why the
|
|
249
257
|
// job went LOST) — it only comes back via a real heartbeat (§4.1),
|
|
@@ -374,14 +382,30 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
374
382
|
if (job.state === JOB_STATES.LOST || TERMINAL_JOB_STATES.has(job.state)){
|
|
375
383
|
return job;
|
|
376
384
|
}
|
|
377
|
-
const spec = job.spec;
|
|
378
385
|
setState(id, JOB_STATES.LOST);
|
|
379
386
|
if (config.scheduler.requeueOnLost && job.attempt < 2){
|
|
380
387
|
return setState(id, JOB_STATES.QUEUED);
|
|
381
388
|
}
|
|
389
|
+
forgetEnv(id);
|
|
382
390
|
return get(id);
|
|
383
391
|
}
|
|
384
392
|
|
|
393
|
+
// A finished job's `--env` values (registry passwords etc.) aren't kept:
|
|
394
|
+
// only the names stay, masked.
|
|
395
|
+
function forgetEnv(id){
|
|
396
|
+
const row = db.prepare('SELECT spec FROM jobs WHERE id = ?').get(id),
|
|
397
|
+
spec = row && JSON.parse(row.spec);
|
|
398
|
+
if (spec?.env){
|
|
399
|
+
db.prepare('UPDATE jobs SET spec = ? WHERE id = ?').run(JSON.stringify({ ...spec, env: maskEnv(spec.env) }), id);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// A job as the Agent API shows it: `--env` values masked (any Agent token
|
|
404
|
+
// can read any job; only the Client running it gets them).
|
|
405
|
+
function publicJob(job){
|
|
406
|
+
return job?.spec?.env ? { ...job, spec: { ...job.spec, env: maskEnv(job.spec.env) } } : job;
|
|
407
|
+
}
|
|
408
|
+
|
|
385
409
|
function checkTimeouts(){
|
|
386
410
|
const now = Date.now(),
|
|
387
411
|
active = db
|
|
@@ -509,6 +533,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
509
533
|
|
|
510
534
|
return {
|
|
511
535
|
get,
|
|
536
|
+
publicJob,
|
|
512
537
|
list,
|
|
513
538
|
page,
|
|
514
539
|
create,
|
package/src/services/registry.js
CHANGED
|
@@ -701,6 +701,40 @@ function createRegistryService(db, { bus, events }){
|
|
|
701
701
|
return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId, needs));
|
|
702
702
|
}
|
|
703
703
|
|
|
704
|
+
// Why a queued job isn't assigned yet, in words — for the Agent and the
|
|
705
|
+
// dashboard (null when an idle Client fits: the next scheduler pass takes
|
|
706
|
+
// it, or higher-priority jobs are ahead). Names each matching Client that
|
|
707
|
+
// isn't free and what it's doing, and — for a job's own Docker image —
|
|
708
|
+
// the idle ones that don't allow that (sw.allowJobImages).
|
|
709
|
+
function waitingReason(type, labels, groupId, resourceId, needs = {}){
|
|
710
|
+
const matching = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource)
|
|
711
|
+
.filter((r) => matchesTarget(r, labels, groupId, resourceId)),
|
|
712
|
+
free = (r) => r.status === RESOURCE_STATES.IDLE && !r.remove_requested_at,
|
|
713
|
+
what = (r) => (r.remove_requested_at
|
|
714
|
+
? 'being removed'
|
|
715
|
+
: { [RESOURCE_STATES.BUSY]: 'busy', [RESOURCE_STATES.OUT_OF_SERVICE]: 'offline', [RESOURCE_STATES.MAINTENANCE]: 'in maintenance',
|
|
716
|
+
[RESOURCE_STATES.REGISTERED]: 'not connected yet' }[r.status] || r.status.toLowerCase()),
|
|
717
|
+
names = (rs) => rs.map((r) => `${r.name} (${what(r)})`).join(', '),
|
|
718
|
+
kind = type.toUpperCase();
|
|
719
|
+
if (!matching.length){
|
|
720
|
+
return `no ${kind} Client matches this job's target`;
|
|
721
|
+
}
|
|
722
|
+
if (needs.jobImage){
|
|
723
|
+
const allowing = matching.filter((r) => matchesTarget(r, labels, groupId, resourceId, needs)),
|
|
724
|
+
refusing = matching.filter((r) => !allowing.includes(r) && free(r));
|
|
725
|
+
if (!allowing.some(free)){
|
|
726
|
+
return `no idle ${kind} Client runs job-supplied Docker images — ` +
|
|
727
|
+
(allowing.length ? `the ones that do: ${names(allowing)}` : 'none does') +
|
|
728
|
+
(refusing.length
|
|
729
|
+
? `; idle, but not allowing them: ${refusing.map((r) => r.name).join(', ')} ` +
|
|
730
|
+
'(enable "Run jobs\' own images" on its Emulator config tab, or sw.allowJobImages in its config)'
|
|
731
|
+
: '');
|
|
732
|
+
}
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
return matching.some(free) ? null : `every matching ${kind} Client is taken: ${names(matching)}`;
|
|
736
|
+
}
|
|
737
|
+
|
|
704
738
|
function everSatisfiable(type, labels, groupId, resourceId, needs){
|
|
705
739
|
const rows = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource);
|
|
706
740
|
return rows.some((r) => matchesTarget(r, labels, groupId, resourceId, needs));
|
|
@@ -734,7 +768,8 @@ function createRegistryService(db, { bus, events }){
|
|
|
734
768
|
requestRemoval,
|
|
735
769
|
pendingRemovals,
|
|
736
770
|
findIdleCandidates,
|
|
737
|
-
everSatisfiable
|
|
771
|
+
everSatisfiable,
|
|
772
|
+
waitingReason
|
|
738
773
|
};
|
|
739
774
|
}
|
|
740
775
|
|
package/src/web/routes.js
CHANGED
|
@@ -714,6 +714,10 @@ function createWebRouter({ services, config }){
|
|
|
714
714
|
active: 'jobs',
|
|
715
715
|
job,
|
|
716
716
|
pinnedClient: job.spec.target.client ? services.registry.get(job.spec.target.client) : null,
|
|
717
|
+
waitingReason: job.state === JOB_STATES.QUEUED
|
|
718
|
+
? services.registry.waitingReason(job.spec.target.type, job.spec.target.labels || [], job.spec.target.group,
|
|
719
|
+
job.spec.target.client, { jobImage: Boolean(job.spec.image) })
|
|
720
|
+
: null,
|
|
717
721
|
artifacts,
|
|
718
722
|
jobActive,
|
|
719
723
|
canCancel: req.session.user.role === 'admin' && jobActive
|
package/test/scheduler.test.js
CHANGED
|
@@ -546,6 +546,52 @@ test('a job-supplied Docker image only goes to SW Clients that allow it; otherwi
|
|
|
546
546
|
assert.equal(jobs.get(fileJob.id).resource_id, strict.id); // jobs without an image still go anywhere
|
|
547
547
|
});
|
|
548
548
|
|
|
549
|
+
test('waiting reason: says why a queued job has no Client — a job image nobody idle allows, or every match taken', () => {
|
|
550
|
+
const { registry, db } = buildTestServices(),
|
|
551
|
+
reason = (needs) => registry.waitingReason('sw', [], undefined, undefined, needs),
|
|
552
|
+
strict = registerResource(registry, { name: 'sw-strict', type: 'sw' }),
|
|
553
|
+
{ resourceId: open } = registry.registerAuto({
|
|
554
|
+
clientId: 'c-open', name: 'sw-open', type: 'sw', labels: [], capabilities: { sw: { image: 'emu', allowJobImages: true } }
|
|
555
|
+
});
|
|
556
|
+
registry.heartbeat(strict.id, { state: 'idle' });
|
|
557
|
+
db.prepare('UPDATE resources SET status = ? WHERE id = ?').run(RESOURCE_STATES.OUT_OF_SERVICE, open);
|
|
558
|
+
|
|
559
|
+
assert.equal(
|
|
560
|
+
reason({ jobImage: true }),
|
|
561
|
+
'no idle SW Client runs job-supplied Docker images — the ones that do: sw-open (offline); idle, but not allowing them: sw-strict ' +
|
|
562
|
+
'(enable "Run jobs\' own images" on its Emulator config tab, or sw.allowJobImages in its config)'
|
|
563
|
+
);
|
|
564
|
+
assert.equal(reason({}), null); // sw-strict can take a job without an image
|
|
565
|
+
|
|
566
|
+
registry.heartbeat(open, { state: 'idle' });
|
|
567
|
+
assert.equal(reason({ jobImage: true }), null);
|
|
568
|
+
|
|
569
|
+
registry.assignToJob(open, 'cli');
|
|
570
|
+
registry.assignToJob(strict.id, 'cli');
|
|
571
|
+
assert.equal(reason({}), 'every matching SW Client is taken: sw-strict (busy), sw-open (busy)');
|
|
572
|
+
assert.equal(registry.waitingReason('hw', [], undefined, undefined, {}), 'no HW Client matches this job\'s target');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test('--env: the Client gets the values; the Agent API sees them masked; they\'re dropped when the job ends (not on a requeued LOST)', () => {
|
|
576
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
577
|
+
{ agent } = agents.create({ name: 'dev', kind: 'cli' }),
|
|
578
|
+
r = registerResource(registry, { name: 'sw-env', type: 'sw' }),
|
|
579
|
+
env = { DOCKER_REGISTRY: 'registry.lab:5000', DOCKER_USERNAME: 'ci', DOCKER_PASSWORD: 's3cret' };
|
|
580
|
+
registry.heartbeat(r.id, { state: 'idle' });
|
|
581
|
+
|
|
582
|
+
const job = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ env }) });
|
|
583
|
+
assert.deepEqual(jobs.get(job.id).spec.env, env); // what the Client is handed
|
|
584
|
+
assert.deepEqual(jobs.publicJob(jobs.get(job.id)).spec.env, { DOCKER_REGISTRY: '***', DOCKER_USERNAME: '***', DOCKER_PASSWORD: '***' });
|
|
585
|
+
|
|
586
|
+
scheduler.runPass();
|
|
587
|
+
jobs.markLost(job.id); // requeued: still needs them
|
|
588
|
+
assert.equal(jobs.get(job.id).state, JOB_STATES.QUEUED);
|
|
589
|
+
assert.deepEqual(jobs.get(job.id).spec.env, env);
|
|
590
|
+
|
|
591
|
+
jobs.cancel(job.id, { isAdmin: true });
|
|
592
|
+
assert.deepEqual(jobs.get(job.id).spec.env, { DOCKER_REGISTRY: '***', DOCKER_USERNAME: '***', DOCKER_PASSWORD: '***' });
|
|
593
|
+
});
|
|
594
|
+
|
|
549
595
|
test('every job carries its command and runs on any matching Client; an old-Agent spec is refused', () => {
|
|
550
596
|
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
551
597
|
{ agent } = agents.create({ name: 'dev', kind: 'cli' }),
|
package/views/jobs/show.pug
CHANGED
|
@@ -19,6 +19,10 @@ block content
|
|
|
19
19
|
)
|
|
20
20
|
button.btn.btn-outline-danger.btn-sm(type="submit") Cancel
|
|
21
21
|
|
|
22
|
+
if waitingReason
|
|
23
|
+
.alert.alert-warning.py-2.small
|
|
24
|
+
i.bi.bi-hourglass-split.me-1
|
|
25
|
+
| Waiting: #{waitingReason}
|
|
22
26
|
dl.row
|
|
23
27
|
dt.col-sm-2 Source
|
|
24
28
|
dd.col-sm-10.text-uppercase= job.source
|
|
@@ -32,6 +36,10 @@ block content
|
|
|
32
36
|
if job.spec.target.client
|
|
33
37
|
dt.col-sm-2 Client
|
|
34
38
|
dd.col-sm-10= pinnedClient ? pinnedClient.name : job.spec.target.client
|
|
39
|
+
if job.spec.env && Object.keys(job.spec.env).length
|
|
40
|
+
dt.col-sm-2 Environment
|
|
41
|
+
dd.col-sm-10.font-monospace(data-bs-toggle="tooltip" data-bs-title="Set by --env; values are only given to the Client that runs the job")
|
|
42
|
+
= Object.keys(job.spec.env).join(', ')
|
|
35
43
|
dt.col-sm-2 Created
|
|
36
44
|
dd.col-sm-10= fmtDate(job.created_at)
|
|
37
45
|
if job.duration_sec != null
|
package/views/layout.pug
CHANGED
|
@@ -12,7 +12,12 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
12
12
|
.container-fluid
|
|
13
13
|
a.navbar-brand.d-flex.flex-column.lh-1.py-1(href="/")
|
|
14
14
|
span TestHub
|
|
15
|
-
small.text-body-secondary.fw-normal.mt-1(
|
|
15
|
+
small.text-body-secondary.fw-normal.mt-1(
|
|
16
|
+
style="font-size: .7rem;"
|
|
17
|
+
data-bs-toggle="tooltip"
|
|
18
|
+
data-bs-placement="bottom"
|
|
19
|
+
data-bs-title=`Coordinator v${coordinatorVersion} · thub-common v${commonVersion} (validates job specs)`
|
|
20
|
+
)
|
|
16
21
|
| ver. #{coordinatorVersion}
|
|
17
22
|
if user && user.role !== 'admin' && updates.coordinatorUpdate
|
|
18
23
|
span.badge.text-bg-warning.ms-1(
|