@andrian.yablonskyy/thub-coordinator 1.0.16 → 1.0.18
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/js/remove-resource.js +45 -0
- package/src/api/admin.js +15 -0
- package/src/api/agent.js +9 -2
- package/src/api/resource.js +6 -0
- package/src/db/migrations/013_add_job_resource_name.sql +4 -0
- package/src/db/migrations/014_add_remove_requested_at.sql +5 -0
- package/src/db/migrations/015_add_job_duration.sql +9 -0
- package/src/services/heartbeat.js +23 -2
- package/src/services/jobs.js +70 -9
- package/src/services/registry.js +50 -10
- package/src/services/scheduler.js +15 -8
- package/src/web/routes.js +37 -3
- package/test/scheduler.test.js +177 -1
- package/views/index.pug +5 -1
- package/views/jobs/list.pug +17 -3
- package/views/jobs/show.pug +8 -2
- package/views/mixins/resource.pug +77 -1
- package/views/mixins/status-badge.pug +8 -1
- package/views/resources/list.pug +5 -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.18",
|
|
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,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/public/js/remove-resource.js
|
|
3
|
+
* @description Dashboard: warning modal for removing a resource that is running a job (stop job, then remove)
|
|
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
|
+
const modalEl = document.getElementById('remove-resource-modal');
|
|
18
|
+
if (!modalEl){
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const field = (name) => modalEl.querySelector(`[data-field="${name}"]`);
|
|
22
|
+
|
|
23
|
+
document.querySelectorAll('[data-remove-resource]').forEach((btn) => {
|
|
24
|
+
btn.addEventListener('click', () => {
|
|
25
|
+
const { removeResource: id, removeName: name, removeJob: job, removeReturn: returnTo } = btn.dataset;
|
|
26
|
+
field('name').textContent = name;
|
|
27
|
+
field('job').textContent = job;
|
|
28
|
+
field('job').href = `/jobs/${encodeURIComponent(job)}`;
|
|
29
|
+
field('job-text').textContent = job;
|
|
30
|
+
field('form').action = `/resources/${encodeURIComponent(id)}/remove`;
|
|
31
|
+
field('return').value = returnTo || '/resources';
|
|
32
|
+
|
|
33
|
+
// Clicked from inside a resource card: close that first — Bootstrap
|
|
34
|
+
// doesn't stack modals.
|
|
35
|
+
const card = btn.closest('.modal');
|
|
36
|
+
if (card){
|
|
37
|
+
card.addEventListener('hidden.bs.modal', () => bootstrap.Modal.getOrCreateInstance(modalEl).show(), { once: true });
|
|
38
|
+
bootstrap.Modal.getInstance(card)?.hide();
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
bootstrap.Modal.getOrCreateInstance(modalEl).show();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
})();
|
package/src/api/admin.js
CHANGED
|
@@ -60,6 +60,21 @@ function createAdminRouter({ services }){
|
|
|
60
60
|
res.json({ resourceToken: token });
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
// Removes a resource; 409 while it runs a job unless ?stopJob=1, which
|
|
64
|
+
// cancels the job and removes the resource once the Client confirms
|
|
65
|
+
// (`pending: true`). Queued jobs pinned to it are canceled on removal.
|
|
66
|
+
router.delete('/resources/:id', (req, res, next) => {
|
|
67
|
+
try {
|
|
68
|
+
const { resource, pending, stoppedJob, canceledJobs } = services.jobs.removeResource(req.params.id, {
|
|
69
|
+
stopJob: req.query.stopJob === '1'
|
|
70
|
+
});
|
|
71
|
+
res.json({ removed: resource.id, pending, stoppedJob, canceledJobs });
|
|
72
|
+
}
|
|
73
|
+
catch (err){
|
|
74
|
+
next(err);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
63
78
|
// Cancels every QUEUED/ASSIGNED/PREPARING/RUNNING job (§13.1).
|
|
64
79
|
router.post('/jobs/reset-queue', (req, res) => {
|
|
65
80
|
const canceled = services.jobs.resetQueue();
|
package/src/api/agent.js
CHANGED
|
@@ -28,7 +28,11 @@ function createAgentRouter({ services, config }){
|
|
|
28
28
|
|
|
29
29
|
router.post('/jobs', auth, (req, res, next) => {
|
|
30
30
|
try {
|
|
31
|
-
|
|
31
|
+
// The job source (A-/M- id series, default priority) is the agent
|
|
32
|
+
// token's admin-assigned kind alone — never taken from the request,
|
|
33
|
+
// so it can't be spoofed or mis-detected on the Agent side.
|
|
34
|
+
const source = req.agent.kind,
|
|
35
|
+
job = services.jobs.create({ agentId: req.agent.id, source, spec: req.body });
|
|
32
36
|
res.status(201).json({ jobId: job.id, state: job.state, webUrl: `${config.publicUrl}/jobs/${job.id}` });
|
|
33
37
|
}
|
|
34
38
|
catch (err){
|
|
@@ -42,7 +46,10 @@ function createAgentRouter({ services, config }){
|
|
|
42
46
|
return res.status(404).json({ error: 'Unknown job' });
|
|
43
47
|
}
|
|
44
48
|
const resource = job.resource_id ? services.registry.get(job.resource_id) : null;
|
|
45
|
-
res.json({
|
|
49
|
+
res.json({
|
|
50
|
+
...job,
|
|
51
|
+
resource: resource ? { id: resource.id, name: resource.name } : job.resource_name ? { id: null, name: job.resource_name } : null
|
|
52
|
+
});
|
|
46
53
|
});
|
|
47
54
|
|
|
48
55
|
router.get('/jobs', auth, (req, res) => {
|
package/src/api/resource.js
CHANGED
|
@@ -103,6 +103,12 @@ function createResourceRouter({ services, config }){
|
|
|
103
103
|
if (updateTo){
|
|
104
104
|
commands.push({ command: 'self-update', version: updateTo });
|
|
105
105
|
}
|
|
106
|
+
// Removal pending (jobs.removeResource): once the Client reports its
|
|
107
|
+
// job stopped, finish it. This response still goes out; the next
|
|
108
|
+
// heartbeat gets 401, the token being gone with the resource.
|
|
109
|
+
if (req.resource.remove_requested_at && !activeJobId){
|
|
110
|
+
services.jobs.completeRemoval(req.params.id, { by: 'removal-confirmed' });
|
|
111
|
+
}
|
|
106
112
|
res.json({ serverTime: new Date().toISOString(), commands });
|
|
107
113
|
}
|
|
108
114
|
catch (err){
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
-- Name of the resource a job ran on, kept once that resource is removed
|
|
2
|
+
-- from the dashboard (registry.remove): jobs.resource_id is then NULLed
|
|
3
|
+
-- (it's a foreign key), and this is what the job's history shows instead.
|
|
4
|
+
ALTER TABLE jobs ADD COLUMN resource_name TEXT;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- Set when an admin removes a resource that is running a job: the job is
|
|
2
|
+
-- canceled right away, and the resource itself is deleted once its Client
|
|
3
|
+
-- confirms the job stopped (next heartbeat with no active job) or after a
|
|
4
|
+
-- timeout (heartbeat.js). Meanwhile the scheduler never assigns it work.
|
|
5
|
+
ALTER TABLE resources ADD COLUMN remove_requested_at TEXT;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- How long a finished job ran (README §9): seconds from started_at (RUNNING)
|
|
2
|
+
-- to finished_at, computed once when it reaches a terminal state
|
|
3
|
+
-- (jobs.setState) and shown instead of the finish time. NULL for a job
|
|
4
|
+
-- that never ran (e.g. canceled while queued). finished_at itself stays:
|
|
5
|
+
-- log/artifact retention is keyed on it. Backfilled for existing jobs.
|
|
6
|
+
ALTER TABLE jobs ADD COLUMN duration_sec INTEGER;
|
|
7
|
+
UPDATE jobs
|
|
8
|
+
SET duration_sec = MAX(0, CAST(ROUND((julianday(finished_at) - julianday(started_at)) * 86400) AS INTEGER))
|
|
9
|
+
WHERE finished_at IS NOT NULL AND started_at IS NOT NULL;
|
|
@@ -47,10 +47,31 @@ function createHeartbeatMonitor(db, { bus, events, registry, jobs, config }){
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
// Resources an admin removed while they ran a job (jobs.removeResource):
|
|
51
|
+
// normally deleted by the heartbeat route once the Client reports the job
|
|
52
|
+
// stopped; this covers a Client that's gone (OUT_OF_SERVICE) or never
|
|
53
|
+
// confirms — after the time a cancel plus the runner's kill grace needs.
|
|
54
|
+
function sweepRemovals(){
|
|
55
|
+
const timeoutMs = Math.max(60, 6 * config.heartbeat.intervalSec) * 1000;
|
|
56
|
+
for (const r of registry.pendingRemovals()){
|
|
57
|
+
if (r.status === RESOURCE_STATES.OUT_OF_SERVICE || Date.now() - new Date(r.remove_requested_at).getTime() >= timeoutMs){
|
|
58
|
+
try {
|
|
59
|
+
jobs.completeRemoval(r.id, { by: 'removal-timeout' });
|
|
60
|
+
}
|
|
61
|
+
catch (err){
|
|
62
|
+
console.error(`removing ${r.name}: ${err.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const timer = setInterval(() => {
|
|
69
|
+
sweepOnce();
|
|
70
|
+
sweepRemovals();
|
|
71
|
+
}, config.heartbeat.sweepIntervalSec * 1000);
|
|
51
72
|
timer.unref?.();
|
|
52
73
|
|
|
53
|
-
return { sweepOnce, stop: () => clearInterval(timer) };
|
|
74
|
+
return { sweepOnce, sweepRemovals, stop: () => clearInterval(timer) };
|
|
54
75
|
}
|
|
55
76
|
|
|
56
77
|
module.exports = { createHeartbeatMonitor };
|
package/src/services/jobs.js
CHANGED
|
@@ -30,7 +30,10 @@ function rowToJob(row){
|
|
|
30
30
|
// developer's own machine — separate series (each own counter row) rather
|
|
31
31
|
// than a shared counter with just a different letter, so e.g. A-00042
|
|
32
32
|
// doesn't imply 41 manual runs happened first.
|
|
33
|
-
const JOB_ID_PREFIXES = { ci: 'A', cli: 'M' }
|
|
33
|
+
const JOB_ID_PREFIXES = { ci: 'A', cli: 'M' },
|
|
34
|
+
// Default priority when the spec doesn't set one (§7.1): a developer's
|
|
35
|
+
// manual run jumps ahead of CI's queue.
|
|
36
|
+
DEFAULT_PRIORITY = { ci: 50, cli: 60 };
|
|
34
37
|
|
|
35
38
|
function nextJobId(db, source){
|
|
36
39
|
const prefix = JOB_ID_PREFIXES[source];
|
|
@@ -85,17 +88,30 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
85
88
|
// Agent: POST /jobs (§6.1, §4.3). Rejects unsatisfiable label requests
|
|
86
89
|
// immediately (§5.4) instead of letting them starve in the queue.
|
|
87
90
|
function create({ agentId, source, spec: rawSpec }){
|
|
88
|
-
const { valid, spec, errors } = validateJobSpec(
|
|
91
|
+
const { valid, spec, errors } = validateJobSpec(
|
|
92
|
+
rawSpec?.priority === undefined ? { ...rawSpec, priority: DEFAULT_PRIORITY[source] } : rawSpec
|
|
93
|
+
);
|
|
89
94
|
if (!valid){
|
|
90
95
|
throw Object.assign(new Error(`Invalid job spec: ${errors.join('; ')}`), { status: 400 });
|
|
91
96
|
}
|
|
92
97
|
spec.source = source;
|
|
93
98
|
|
|
94
|
-
|
|
99
|
+
// `target.client` (thub run --client) accepts a resource id or name;
|
|
100
|
+
// store the id so the job stays pinned to the same Client across renames.
|
|
101
|
+
if (spec.target.client){
|
|
102
|
+
const resource = registry.get(spec.target.client) || registry.getByName(spec.target.client);
|
|
103
|
+
if (!resource){
|
|
104
|
+
throw Object.assign(new Error(`Unknown client "${spec.target.client}"`), { status: 422 });
|
|
105
|
+
}
|
|
106
|
+
spec.target.client = resource.id;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!registry.everSatisfiable(spec.target.type, spec.target.labels, spec.target.group, spec.target.client)){
|
|
95
110
|
throw Object.assign(
|
|
96
111
|
new Error(
|
|
97
112
|
`No registered resource can ever satisfy type=${spec.target.type} labels=${spec.target.labels.join(',')}` +
|
|
98
|
-
(spec.target.group ? ` group=${spec.target.group}` : '')
|
|
113
|
+
(spec.target.group ? ` group=${spec.target.group}` : '') +
|
|
114
|
+
(spec.target.client ? ` client=${spec.target.client}` : '')
|
|
99
115
|
),
|
|
100
116
|
{ status: 422 }
|
|
101
117
|
);
|
|
@@ -141,8 +157,10 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
141
157
|
params.push(now);
|
|
142
158
|
}
|
|
143
159
|
if (TERMINAL_JOB_STATES.has(state)){
|
|
144
|
-
|
|
145
|
-
|
|
160
|
+
// Duration is computed once, here, and stored — never derived at
|
|
161
|
+
// display time. Measured from started_at (RUNNING), like the timeout.
|
|
162
|
+
fields.push('finished_at = ?', 'duration_sec = ?');
|
|
163
|
+
params.push(now, job.started_at ? Math.max(0, Math.round((Date.parse(now) - Date.parse(job.started_at)) / 1000)) : null);
|
|
146
164
|
}
|
|
147
165
|
if (extra.message !== undefined){
|
|
148
166
|
fields.push('message = ?');
|
|
@@ -157,9 +175,11 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
157
175
|
params.push(JSON.stringify(extra.summary));
|
|
158
176
|
}
|
|
159
177
|
if (state === JOB_STATES.QUEUED){
|
|
160
|
-
// Requeue: drop the previous assignment
|
|
161
|
-
|
|
162
|
-
|
|
178
|
+
// Requeue: drop the previous assignment (and its start time, so the
|
|
179
|
+
// next attempt's duration/timeout count from its own start) so the
|
|
180
|
+
// scheduler treats it fresh.
|
|
181
|
+
fields.push('resource_id = ?', 'assigned_at = ?', 'started_at = ?', 'attempt = attempt + 1');
|
|
182
|
+
params.push(null, null, null);
|
|
163
183
|
}
|
|
164
184
|
|
|
165
185
|
params.push(id);
|
|
@@ -202,6 +222,44 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
202
222
|
return setState(id, JOB_STATES.CANCELED);
|
|
203
223
|
}
|
|
204
224
|
|
|
225
|
+
// Queued jobs pinned to a resource (`target.client`) can never run once it's
|
|
226
|
+
// removed, so they're canceled rather than left in the queue forever.
|
|
227
|
+
function cancelPinnedTo(resourceId, message){
|
|
228
|
+
const queued = db.prepare('SELECT id, spec FROM jobs WHERE state = ?').all(JOB_STATES.QUEUED)
|
|
229
|
+
.filter((row) => JSON.parse(row.spec).target?.client === resourceId);
|
|
230
|
+
for (const { id }of queued){
|
|
231
|
+
setState(id, JOB_STATES.CANCELED, { message });
|
|
232
|
+
}
|
|
233
|
+
return queued.map((row) => row.id);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Admin "Remove" (dashboard, DELETE /admin/resources/:id). A resource
|
|
237
|
+
// that's running a job needs `stopJob`: the job is canceled at once, but
|
|
238
|
+
// the Client only hears that in its next heartbeat's commands — so the
|
|
239
|
+
// resource (and with it the Client's token) is deleted only after that
|
|
240
|
+
// (completeRemoval, from the heartbeat route or the sweeper's timeout).
|
|
241
|
+
function removeResource(resourceId, { by, stopJob = false } = {}){
|
|
242
|
+
const r = registry.get(resourceId);
|
|
243
|
+
if (!r){
|
|
244
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
245
|
+
}
|
|
246
|
+
const job = activeForResource(resourceId);
|
|
247
|
+
if (job && !stopJob){
|
|
248
|
+
throw Object.assign(new Error(`${r.name} is running job ${job.id}`), { status: 409, jobId: job.id });
|
|
249
|
+
}
|
|
250
|
+
if (job){
|
|
251
|
+
cancel(job.id, { isAdmin: true });
|
|
252
|
+
registry.requestRemoval(resourceId, { by });
|
|
253
|
+
return { resource: r, pending: true, stoppedJob: job.id, canceledJobs: [] };
|
|
254
|
+
}
|
|
255
|
+
return { resource: r, pending: false, stoppedJob: null, canceledJobs: completeRemoval(resourceId, { by }) };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function completeRemoval(resourceId, { by } = {}){
|
|
259
|
+
const r = registry.remove(resourceId, { by });
|
|
260
|
+
return cancelPinnedTo(resourceId, `Client ${r.name} was removed`);
|
|
261
|
+
}
|
|
262
|
+
|
|
205
263
|
// The job a resource is working on (ASSIGNED through RUNNING), if any —
|
|
206
264
|
// for the resource card's Cancel button.
|
|
207
265
|
function activeForResource(resourceId){
|
|
@@ -351,6 +409,9 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
351
409
|
create,
|
|
352
410
|
setState,
|
|
353
411
|
cancel,
|
|
412
|
+
cancelPinnedTo,
|
|
413
|
+
removeResource,
|
|
414
|
+
completeRemoval,
|
|
354
415
|
activeForResource,
|
|
355
416
|
applyResult,
|
|
356
417
|
requeueUnacked,
|
package/src/services/registry.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
16
|
const { v4: uuid } = require('uuid'),
|
|
17
|
-
{ RESOURCE_STATES, BUSY_SOURCES, compareVersions } = require('@andrian.yablonskyy/thub-common'),
|
|
17
|
+
{ RESOURCE_STATES, BUSY_SOURCES, ACTIVE_JOB_STATES, compareVersions } = require('@andrian.yablonskyy/thub-common'),
|
|
18
18
|
{ generateToken, hashToken } = require('./tokens');
|
|
19
19
|
|
|
20
20
|
function rowToResource(row){
|
|
@@ -468,21 +468,58 @@ function createRegistryService(db, { bus, events }){
|
|
|
468
468
|
);
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
-
function
|
|
471
|
+
function matchesTarget(r, labels, groupId, resourceId){
|
|
472
|
+
return labels.every((l) => r.labels.includes(l)) &&
|
|
473
|
+
(!groupId || r.group_ids.includes(groupId)) &&
|
|
474
|
+
(!resourceId || r.id === resourceId);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Admin: remove a resource from the registry (dashboard "Remove"). Refused
|
|
478
|
+
// while it holds an active job — cancel that first. Its finished jobs stay,
|
|
479
|
+
// keeping the resource's name (jobs.resource_name) in place of the id.
|
|
480
|
+
// A Client that's still running reappears when it next re-registers.
|
|
481
|
+
function remove(resourceId, { by } = {}){
|
|
482
|
+
const r = get(resourceId);
|
|
483
|
+
if (!r){
|
|
484
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
485
|
+
}
|
|
486
|
+
const placeholders = [...ACTIVE_JOB_STATES].map(() => '?').join(','),
|
|
487
|
+
active = db
|
|
488
|
+
.prepare(`SELECT id FROM jobs WHERE resource_id = ? AND state IN (${placeholders}) LIMIT 1`)
|
|
489
|
+
.get(resourceId, ...ACTIVE_JOB_STATES);
|
|
490
|
+
if (active){
|
|
491
|
+
throw Object.assign(new Error(`${r.name} is working on job ${active.id} — cancel it first`), { status: 409 });
|
|
492
|
+
}
|
|
493
|
+
db.transaction(() => {
|
|
494
|
+
db.prepare('UPDATE jobs SET resource_name = ?, resource_id = NULL WHERE resource_id = ?').run(r.name, resourceId);
|
|
495
|
+
db.prepare('DELETE FROM resources WHERE id = ?').run(resourceId);
|
|
496
|
+
})();
|
|
497
|
+
events.record('resource', resourceId, 'resource.removed', { name: r.name, by });
|
|
498
|
+
return r;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Step one of removing a resource that's running a job (jobs.removeResource):
|
|
502
|
+
// it takes no new work from here on and is deleted once the job stopped.
|
|
503
|
+
function requestRemoval(resourceId, { by } = {}){
|
|
504
|
+
db.prepare('UPDATE resources SET remove_requested_at = ? WHERE id = ?').run(new Date().toISOString(), resourceId);
|
|
505
|
+
events.record('resource', resourceId, 'resource.removal_requested', { by });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function pendingRemovals(){
|
|
509
|
+
return db.prepare('SELECT * FROM resources WHERE remove_requested_at IS NOT NULL').all().map(rowToResource);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function findIdleCandidates(type, labels, groupId, resourceId){
|
|
472
513
|
const rows = db
|
|
473
|
-
.prepare('SELECT * FROM resources WHERE status = ? AND type = ?')
|
|
514
|
+
.prepare('SELECT * FROM resources WHERE status = ? AND type = ? AND remove_requested_at IS NULL')
|
|
474
515
|
.all(RESOURCE_STATES.IDLE, type)
|
|
475
516
|
.map(rowToResource);
|
|
476
|
-
return rows.filter(
|
|
477
|
-
(r) => labels.every((l) => r.labels.includes(l)) && (!groupId || r.group_ids.includes(groupId))
|
|
478
|
-
);
|
|
517
|
+
return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
479
518
|
}
|
|
480
519
|
|
|
481
|
-
function everSatisfiable(type, labels, groupId){
|
|
520
|
+
function everSatisfiable(type, labels, groupId, resourceId){
|
|
482
521
|
const rows = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource);
|
|
483
|
-
return rows.some(
|
|
484
|
-
(r) => labels.every((l) => r.labels.includes(l)) && (!groupId || r.group_ids.includes(groupId))
|
|
485
|
-
);
|
|
522
|
+
return rows.some((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
486
523
|
}
|
|
487
524
|
|
|
488
525
|
return {
|
|
@@ -502,6 +539,9 @@ function createRegistryService(db, { bus, events }){
|
|
|
502
539
|
requestUpdateAll,
|
|
503
540
|
pendingUpdate,
|
|
504
541
|
assignToJob,
|
|
542
|
+
remove,
|
|
543
|
+
requestRemoval,
|
|
544
|
+
pendingRemovals,
|
|
505
545
|
findIdleCandidates,
|
|
506
546
|
everSatisfiable
|
|
507
547
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file packages/coordinator/src/services/scheduler.js
|
|
3
|
-
* @description Scheduler: matches queued jobs to idle resources by type/labels/group (README §5.4)
|
|
3
|
+
* @description Scheduler: matches queued jobs to idle resources by type/labels/group/client (README §5.4)
|
|
4
4
|
*
|
|
5
5
|
* @author Andrian Yablonskyy
|
|
6
6
|
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
@@ -34,21 +34,28 @@ function createScheduler(db, { bus, events, registry, config }){
|
|
|
34
34
|
.map(rowToJob),
|
|
35
35
|
|
|
36
36
|
assignments = [],
|
|
37
|
-
claimedResourceIds = new Set()
|
|
37
|
+
claimedResourceIds = new Set(),
|
|
38
|
+
// Resources that some queued job is pinned to (`target.client`) —
|
|
39
|
+
// an unpinned job only takes one of these when nothing else fits,
|
|
40
|
+
// so it doesn't jump the pinned job's per-client queue.
|
|
41
|
+
reservedResourceIds = new Set(queued.map((j) => j.spec.target.client).filter(Boolean));
|
|
38
42
|
|
|
39
43
|
for (const job of queued){
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
const { type, labels = [], group, client } = job.spec.target,
|
|
45
|
+
candidates = registry
|
|
46
|
+
.findIdleCandidates(type, labels, group, client)
|
|
47
|
+
.filter((r) => !claimedResourceIds.has(r.id));
|
|
43
48
|
if (candidates.length === 0){
|
|
44
49
|
continue;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
|
-
//
|
|
52
|
+
// Unreserved first, then least-recently-used: spread wear across benches (§5.4).
|
|
48
53
|
candidates.sort((a, b) => {
|
|
49
|
-
const
|
|
54
|
+
const ra = reservedResourceIds.has(a.id) ? 1 : 0,
|
|
55
|
+
rb = reservedResourceIds.has(b.id) ? 1 : 0,
|
|
56
|
+
ta = a.last_job_finished_at ? new Date(a.last_job_finished_at).getTime() : 0,
|
|
50
57
|
tb = b.last_job_finished_at ? new Date(b.last_job_finished_at).getTime() : 0;
|
|
51
|
-
return ta - tb;
|
|
58
|
+
return ra - rb || ta - tb;
|
|
52
59
|
});
|
|
53
60
|
const resource = candidates[0];
|
|
54
61
|
claimedResourceIds.add(resource.id);
|
package/src/web/routes.js
CHANGED
|
@@ -284,6 +284,26 @@ function createWebRouter({ services, config }){
|
|
|
284
284
|
res.redirect(returnTo(req, '/resources'));
|
|
285
285
|
});
|
|
286
286
|
|
|
287
|
+
router.post('/resources/:id/remove', requireAdminRole, (req, res) => {
|
|
288
|
+
try {
|
|
289
|
+
const { resource: r, pending, stoppedJob, canceledJobs } = services.jobs.removeResource(req.params.id, {
|
|
290
|
+
by: req.session.user.username,
|
|
291
|
+
stopJob: req.body.stopJob === '1'
|
|
292
|
+
});
|
|
293
|
+
flash(
|
|
294
|
+
req,
|
|
295
|
+
'warning',
|
|
296
|
+
pending
|
|
297
|
+
? `Stopped job ${stoppedJob} on ${r.name}; ${r.name} is removed as soon as its Client confirms the job ended.`
|
|
298
|
+
: `Removed ${r.name}.` + (canceledJobs.length ? ` Canceled job(s) queued for it: ${canceledJobs.join(', ')}.` : '')
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
catch (err){
|
|
302
|
+
flash(req, 'danger', err.message);
|
|
303
|
+
}
|
|
304
|
+
res.redirect(returnTo(req, '/resources'));
|
|
305
|
+
});
|
|
306
|
+
|
|
287
307
|
router.post('/resources/:id/rotate-token', requireAdminRole, (req, res) => {
|
|
288
308
|
const { generateToken, hashToken } = require('../services/tokens');
|
|
289
309
|
const token = generateToken('res');
|
|
@@ -395,13 +415,19 @@ function createWebRouter({ services, config }){
|
|
|
395
415
|
const filters = { state: req.query.state || undefined, source: req.query.source || undefined },
|
|
396
416
|
jobs = services.jobs.list({ ...filters, limit: 200 }).map((j) => ({
|
|
397
417
|
...j,
|
|
398
|
-
resource: j.resource_id ? services.registry.get(j.resource_id) : null
|
|
418
|
+
resource: j.resource_id ? services.registry.get(j.resource_id) : null,
|
|
419
|
+
active: ACTIVE_JOB_STATES.has(j.state)
|
|
399
420
|
}));
|
|
400
421
|
res.render('jobs/list', {
|
|
401
422
|
title: 'Jobs',
|
|
402
423
|
active: 'jobs',
|
|
403
424
|
jobs,
|
|
404
425
|
filters,
|
|
426
|
+
// Keeps the list's filters across a row's Cancel.
|
|
427
|
+
filterQuery: (() => {
|
|
428
|
+
const q = new URLSearchParams(Object.entries(filters).filter(([, v]) => v)).toString();
|
|
429
|
+
return q ? `?${q}` : '';
|
|
430
|
+
})(),
|
|
405
431
|
canManage: req.session.user.role === 'admin'
|
|
406
432
|
});
|
|
407
433
|
});
|
|
@@ -433,15 +459,23 @@ function createWebRouter({ services, config }){
|
|
|
433
459
|
title: job.id,
|
|
434
460
|
active: 'jobs',
|
|
435
461
|
job,
|
|
462
|
+
pinnedClient: job.spec.target.client ? services.registry.get(job.spec.target.client) : null,
|
|
436
463
|
artifacts,
|
|
437
464
|
jobActive,
|
|
438
465
|
canCancel: req.session.user.role === 'admin' && jobActive
|
|
439
466
|
});
|
|
440
467
|
});
|
|
441
468
|
|
|
469
|
+
// From the job page or a row on /jobs (returnTo says which).
|
|
442
470
|
router.post('/jobs/:id/cancel', requireAdminRole, (req, res) => {
|
|
443
|
-
|
|
444
|
-
|
|
471
|
+
try {
|
|
472
|
+
services.jobs.cancel(req.params.id, { isAdmin: true });
|
|
473
|
+
flash(req, 'warning', `Canceled job ${req.params.id}.`);
|
|
474
|
+
}
|
|
475
|
+
catch (err){
|
|
476
|
+
flash(req, 'danger', err.message);
|
|
477
|
+
}
|
|
478
|
+
res.redirect(returnTo(req, `/jobs/${req.params.id}`));
|
|
445
479
|
});
|
|
446
480
|
|
|
447
481
|
// Plain, one-shot fetch — used by the log viewer for a job that's
|
package/test/scheduler.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file packages/coordinator/test/scheduler.test.js
|
|
3
|
-
* @description Tests: scheduler, registry, groups, job id allocation, and admin queue operations
|
|
3
|
+
* @description Tests: scheduler, registry, groups, client pinning, job id allocation, and admin queue operations
|
|
4
4
|
*
|
|
5
5
|
* @author Andrian Yablonskyy
|
|
6
6
|
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
@@ -157,6 +157,34 @@ test('job ids are split by source: A-##### for ci, M-##### for cli', () => {
|
|
|
157
157
|
assert.equal(Number(ciJob2.id.slice(2)), Number(ciJob.id.slice(2)) + 1);
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
+
test('default priority follows the job source; an explicit one is kept', () => {
|
|
161
|
+
const { agents, registry, jobs } = buildTestServices(),
|
|
162
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' });
|
|
163
|
+
registerResource(registry, { name: 'lab-sw-01', type: 'sw' });
|
|
164
|
+
|
|
165
|
+
assert.equal(jobs.create({ agentId: agent.id, source: 'ci', spec: makeSpec() }).priority, 50);
|
|
166
|
+
assert.equal(jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() }).priority, 60);
|
|
167
|
+
assert.equal(jobs.create({ agentId: agent.id, source: 'ci', spec: makeSpec({ priority: 90 }) }).priority, 90);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('duration is stored once when a job finishes, from its start; none if it never ran', () => {
|
|
171
|
+
const { registry, agents, jobs, scheduler, db } = buildTestServices(),
|
|
172
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
173
|
+
r = registerResource(registry, { name: 'lab-sw-01', type: 'sw' });
|
|
174
|
+
registry.heartbeat(r.id, { state: 'idle' });
|
|
175
|
+
|
|
176
|
+
const ran = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
177
|
+
scheduler.runPass();
|
|
178
|
+
jobs.setState(ran.id, JOB_STATES.RUNNING);
|
|
179
|
+
db.prepare('UPDATE jobs SET started_at = ? WHERE id = ?').run(new Date(Date.now() - 125e3).toISOString(), ran.id);
|
|
180
|
+
jobs.setState(ran.id, JOB_STATES.PASSED);
|
|
181
|
+
assert.equal(jobs.get(ran.id).duration_sec, 125);
|
|
182
|
+
|
|
183
|
+
const never = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
184
|
+
jobs.cancel(never.id, { isAdmin: true });
|
|
185
|
+
assert.equal(jobs.get(never.id).duration_sec, null);
|
|
186
|
+
});
|
|
187
|
+
|
|
160
188
|
test('admin resetQueue cancels every active job and leaves finished ones alone', () => {
|
|
161
189
|
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
162
190
|
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
@@ -349,6 +377,154 @@ test('a job with target.group is rejected (422) when no resource is ever a membe
|
|
|
349
377
|
);
|
|
350
378
|
});
|
|
351
379
|
|
|
380
|
+
test('a job with target.client (by name) waits for that client even when another matching one is idle', () => {
|
|
381
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
382
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
383
|
+
pinned = registerResource(registry, { name: 'lab-pinned', type: 'sw' }),
|
|
384
|
+
other = registerResource(registry, { name: 'lab-other', type: 'sw' });
|
|
385
|
+
registry.heartbeat(other.id, { state: 'idle' }); // pinned is still REGISTERED, not IDLE
|
|
386
|
+
|
|
387
|
+
const job = jobs.create({
|
|
388
|
+
agentId: agent.id,
|
|
389
|
+
source: 'cli',
|
|
390
|
+
spec: makeSpec({ target: { type: 'sw', labels: [], client: 'lab-pinned' } })
|
|
391
|
+
});
|
|
392
|
+
assert.equal(job.spec.target.client, pinned.id); // name resolved to the stable resource id
|
|
393
|
+
|
|
394
|
+
scheduler.runPass();
|
|
395
|
+
assert.equal(jobs.get(job.id).state, JOB_STATES.QUEUED); // not grabbed by lab-other
|
|
396
|
+
|
|
397
|
+
registry.heartbeat(pinned.id, { state: 'idle' });
|
|
398
|
+
scheduler.runPass();
|
|
399
|
+
const assigned = jobs.get(job.id);
|
|
400
|
+
assert.equal(assigned.state, JOB_STATES.ASSIGNED);
|
|
401
|
+
assert.equal(assigned.resource_id, pinned.id);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('an unpinned job prefers a resource no queued job is pinned to', () => {
|
|
405
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
406
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
407
|
+
pinned = registerResource(registry, { name: 'lab-pinned', type: 'sw' }),
|
|
408
|
+
other = registerResource(registry, { name: 'lab-other', type: 'sw' });
|
|
409
|
+
registry.heartbeat(pinned.id, { state: 'idle' });
|
|
410
|
+
registry.heartbeat(other.id, { state: 'idle' });
|
|
411
|
+
|
|
412
|
+
// Unpinned job has higher priority, so it's considered first; it must not
|
|
413
|
+
// take lab-pinned (which is also least-recently-used) away from the pinned job.
|
|
414
|
+
const unpinned = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ priority: 90 }) }),
|
|
415
|
+
pinnedJob = jobs.create({
|
|
416
|
+
agentId: agent.id,
|
|
417
|
+
source: 'cli',
|
|
418
|
+
spec: makeSpec({ target: { type: 'sw', labels: [], client: pinned.id }, priority: 10 })
|
|
419
|
+
});
|
|
420
|
+
scheduler.runPass();
|
|
421
|
+
|
|
422
|
+
assert.equal(jobs.get(unpinned.id).resource_id, other.id);
|
|
423
|
+
assert.equal(jobs.get(pinnedJob.id).resource_id, pinned.id);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test('a job with target.client is rejected (422) when the client is unknown or cannot satisfy the target', () => {
|
|
427
|
+
const { registry, agents, jobs } = buildTestServices(),
|
|
428
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' });
|
|
429
|
+
registerResource(registry, { name: 'lab-sw', type: 'sw', labels: ['board:a'] });
|
|
430
|
+
|
|
431
|
+
assert.throws(
|
|
432
|
+
() => jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', client: 'nope' } }) }),
|
|
433
|
+
/Unknown client "nope"/
|
|
434
|
+
);
|
|
435
|
+
assert.throws(
|
|
436
|
+
() => jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'hw', client: 'lab-sw' } }) }),
|
|
437
|
+
/No registered resource/
|
|
438
|
+
);
|
|
439
|
+
assert.throws(
|
|
440
|
+
() =>
|
|
441
|
+
jobs.create({
|
|
442
|
+
agentId: agent.id,
|
|
443
|
+
source: 'cli',
|
|
444
|
+
spec: makeSpec({ target: { type: 'sw', labels: ['board:b'], client: 'lab-sw' } })
|
|
445
|
+
}),
|
|
446
|
+
/No registered resource/
|
|
447
|
+
);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('removing a resource keeps its job history by name and cancels jobs pinned to it', () => {
|
|
451
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
452
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
453
|
+
r = registerResource(registry, { name: 'lab-old', type: 'sw' });
|
|
454
|
+
registry.heartbeat(r.id, { state: 'idle' });
|
|
455
|
+
|
|
456
|
+
const done = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
457
|
+
scheduler.runPass();
|
|
458
|
+
assert.throws(() => registry.remove(r.id), /working on job/); // ASSIGNED: refused
|
|
459
|
+
jobs.setState(done.id, JOB_STATES.PASSED);
|
|
460
|
+
registry.heartbeat(r.id, { state: 'busy' }); // keep it from taking the pinned job below
|
|
461
|
+
|
|
462
|
+
const pinned = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', client: r.id } }) });
|
|
463
|
+
registry.remove(r.id);
|
|
464
|
+
assert.deepEqual(jobs.cancelPinnedTo(r.id, 'removed'), [pinned.id]);
|
|
465
|
+
|
|
466
|
+
assert.equal(registry.get(r.id), undefined);
|
|
467
|
+
const kept = jobs.get(done.id);
|
|
468
|
+
assert.equal(kept.resource_id, null);
|
|
469
|
+
assert.equal(kept.resource_name, 'lab-old');
|
|
470
|
+
assert.equal(jobs.get(pinned.id).state, JOB_STATES.CANCELED);
|
|
471
|
+
assert.throws(() => registry.remove(r.id), /Unknown resource/);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test('removing a resource running a job: stops the job now, removes once the Client confirms', () => {
|
|
475
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
476
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
477
|
+
r = registerResource(registry, { name: 'lab-busy', type: 'sw' });
|
|
478
|
+
registry.heartbeat(r.id, { state: 'idle' });
|
|
479
|
+
const running = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
480
|
+
scheduler.runPass();
|
|
481
|
+
jobs.setState(running.id, JOB_STATES.RUNNING);
|
|
482
|
+
|
|
483
|
+
assert.throws(() => jobs.removeResource(r.id), /running job/); // needs stopJob
|
|
484
|
+
|
|
485
|
+
const res = jobs.removeResource(r.id, { stopJob: true });
|
|
486
|
+
assert.equal(res.pending, true);
|
|
487
|
+
assert.equal(res.stoppedJob, running.id);
|
|
488
|
+
assert.equal(jobs.get(running.id).state, JOB_STATES.CANCELED);
|
|
489
|
+
assert.ok(registry.get(r.id).remove_requested_at); // still there: the Client hasn't heard yet
|
|
490
|
+
|
|
491
|
+
// Pending removal takes no new work, even once it reports idle.
|
|
492
|
+
registry.heartbeat(r.id, { state: 'idle' });
|
|
493
|
+
const next = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
494
|
+
scheduler.runPass();
|
|
495
|
+
assert.equal(jobs.get(next.id).state, JOB_STATES.QUEUED);
|
|
496
|
+
|
|
497
|
+
jobs.completeRemoval(r.id); // what the heartbeat route does on the Client's "no active job"
|
|
498
|
+
assert.equal(registry.get(r.id), undefined);
|
|
499
|
+
assert.equal(jobs.get(running.id).resource_name, 'lab-busy');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test('the sweeper finishes a pending removal whose Client never confirms', () => {
|
|
503
|
+
const { registry, agents, jobs, scheduler, heartbeatMonitor, db } = buildTestServices(),
|
|
504
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
505
|
+
r = registerResource(registry, { name: 'lab-silent', type: 'sw' });
|
|
506
|
+
registry.heartbeat(r.id, { state: 'idle' });
|
|
507
|
+
jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
508
|
+
scheduler.runPass();
|
|
509
|
+
jobs.removeResource(r.id, { stopJob: true });
|
|
510
|
+
|
|
511
|
+
heartbeatMonitor.sweepRemovals();
|
|
512
|
+
assert.ok(registry.get(r.id)); // not timed out yet
|
|
513
|
+
|
|
514
|
+
db.prepare('UPDATE resources SET remove_requested_at = ? WHERE id = ?').run(new Date(Date.now() - 3600e3).toISOString(), r.id);
|
|
515
|
+
heartbeatMonitor.sweepRemovals();
|
|
516
|
+
assert.equal(registry.get(r.id), undefined);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test('a removed Client re-registers as a fresh resource under the same name', () => {
|
|
520
|
+
const { registry } = buildTestServices(),
|
|
521
|
+
r = registerResource(registry, { name: 'lab-back', type: 'sw', clientId: 'c-back' });
|
|
522
|
+
registry.remove(r.id);
|
|
523
|
+
const again = registerResource(registry, { name: 'lab-back', type: 'sw', clientId: 'c-back' });
|
|
524
|
+
assert.notEqual(again.id, r.id);
|
|
525
|
+
assert.equal(again.name, 'lab-back');
|
|
526
|
+
});
|
|
527
|
+
|
|
352
528
|
test('a resource can belong to several groups at once', () => {
|
|
353
529
|
const { registry, groups } = buildTestServices(),
|
|
354
530
|
g1 = groups.create({ name: 'g1' }),
|
package/views/index.pug
CHANGED
|
@@ -30,7 +30,7 @@ block content
|
|
|
30
30
|
.card-body
|
|
31
31
|
h5.card-title= r.name
|
|
32
32
|
p.card-text.mb-1
|
|
33
|
-
+resourceBadge(r.status)
|
|
33
|
+
+resourceBadge(r.status, r.remove_requested_at)
|
|
34
34
|
|
|
|
35
35
|
= ' ' + r.type.toUpperCase()
|
|
36
36
|
if r.busy_source
|
|
@@ -41,5 +41,9 @@ block content
|
|
|
41
41
|
each r in resources
|
|
42
42
|
+resourceCard(r, groupsById, '/')
|
|
43
43
|
|
|
44
|
+
if user && user.role === 'admin'
|
|
45
|
+
+removeResourceModal()
|
|
46
|
+
|
|
44
47
|
block scripts
|
|
45
48
|
script(src="/js/resource-card.js")
|
|
49
|
+
script(src="/js/remove-resource.js")
|
package/views/jobs/list.pug
CHANGED
|
@@ -32,7 +32,9 @@ block content
|
|
|
32
32
|
th State
|
|
33
33
|
th Resource
|
|
34
34
|
th Created
|
|
35
|
-
th
|
|
35
|
+
th Duration
|
|
36
|
+
if canManage
|
|
37
|
+
th
|
|
36
38
|
tbody
|
|
37
39
|
each j in jobs
|
|
38
40
|
tr
|
|
@@ -40,6 +42,18 @@ block content
|
|
|
40
42
|
td.text-uppercase= j.source
|
|
41
43
|
td= j.spec.user || '—'
|
|
42
44
|
td: +jobBadge(j.state)
|
|
43
|
-
td= j.resource ? j.resource.name : '—'
|
|
45
|
+
td= j.resource ? j.resource.name : j.resource_name ? `${j.resource_name} (removed)` : '—'
|
|
44
46
|
td= fmtDate(j.created_at)
|
|
45
|
-
td= fmtDate(j.finished_at)
|
|
47
|
+
td(title=j.finished_at ? `Finished ${fmtDate(j.finished_at)}` : null)= j.duration_sec != null ? fmtDuration(j.duration_sec * 1000) : '—'
|
|
48
|
+
if canManage
|
|
49
|
+
td.text-end
|
|
50
|
+
if j.active
|
|
51
|
+
form.d-inline(
|
|
52
|
+
method="post"
|
|
53
|
+
action=`/jobs/${j.id}/cancel`
|
|
54
|
+
data-confirm=`Cancel job ${j.id}?`
|
|
55
|
+
onsubmit="return confirm(this.dataset.confirm)"
|
|
56
|
+
)
|
|
57
|
+
input(type="hidden" name="returnTo" value=`/jobs${filterQuery}`)
|
|
58
|
+
button.btn.btn-sm.btn-outline-danger(type="submit" aria-label="Cancel" title="Cancel this job")
|
|
59
|
+
i.bi.bi-x-circle
|
package/views/jobs/show.pug
CHANGED
|
@@ -9,7 +9,7 @@ block content
|
|
|
9
9
|
|
|
|
10
10
|
+jobBadge(job.state)
|
|
11
11
|
if canCancel
|
|
12
|
-
form(method="post" action=`/jobs/${job.id}/cancel`)
|
|
12
|
+
form(method="post" action=`/jobs/${job.id}/cancel` onsubmit=`return confirm('Cancel job ${job.id}?')`)
|
|
13
13
|
button.btn.btn-outline-danger.btn-sm(type="submit") Cancel
|
|
14
14
|
|
|
15
15
|
dl.row
|
|
@@ -19,11 +19,17 @@ block content
|
|
|
19
19
|
dt.col-sm-2 User
|
|
20
20
|
dd.col-sm-10= job.spec.user
|
|
21
21
|
dt.col-sm-2 Resource
|
|
22
|
-
dd.col-sm-10= job.resource ? job.resource.name : '—'
|
|
22
|
+
dd.col-sm-10= job.resource ? job.resource.name : job.resource_name ? `${job.resource_name} (removed)` : '—'
|
|
23
23
|
dt.col-sm-2 Target
|
|
24
24
|
dd.col-sm-10= `${job.spec.target.type} — ${(job.spec.target.labels || []).join(', ')}`
|
|
25
|
+
if job.spec.target.client
|
|
26
|
+
dt.col-sm-2 Client
|
|
27
|
+
dd.col-sm-10= pinnedClient ? pinnedClient.name : job.spec.target.client
|
|
25
28
|
dt.col-sm-2 Created
|
|
26
29
|
dd.col-sm-10= fmtDate(job.created_at)
|
|
30
|
+
if job.duration_sec != null
|
|
31
|
+
dt.col-sm-2 Duration
|
|
32
|
+
dd.col-sm-10(title=`Finished ${fmtDate(job.finished_at)}`)= fmtDuration(job.duration_sec * 1000)
|
|
27
33
|
if job.message
|
|
28
34
|
dt.col-sm-2 Message
|
|
29
35
|
dd.col-sm-10= job.message
|
|
@@ -32,6 +32,82 @@ mixin resourceActions(r, returnTo)
|
|
|
32
32
|
)
|
|
33
33
|
i.bi.bi-arrow-repeat
|
|
34
34
|
+updateButton(`/resources/${r.id}/update`, r.update_to, returnTo)
|
|
35
|
+
+removeResourceButton(r, returnTo)
|
|
36
|
+
|
|
37
|
+
//- Remove: a plain confirm when the resource is idle; with a running job,
|
|
38
|
+
//- the shared warning modal (+removeResourceModal) instead, which stops the
|
|
39
|
+
//- job first (stopJob=1). Disabled once a removal is already pending.
|
|
40
|
+
mixin removeResourceButton(r, returnTo)
|
|
41
|
+
if r.remove_requested_at
|
|
42
|
+
span.d-inline-block.ms-1(tabindex="0" data-bs-toggle="tooltip" data-bs-title="Removal pending — waiting for the Client to confirm its job stopped")
|
|
43
|
+
button.btn.btn-sm.btn-outline-danger(type="button" disabled style="pointer-events: none;" aria-label="Remove")
|
|
44
|
+
i.bi.bi-hourglass-split
|
|
45
|
+
else if r.activeJob
|
|
46
|
+
button.btn.btn-sm.btn-outline-danger.ms-1(
|
|
47
|
+
type="button"
|
|
48
|
+
aria-label="Remove"
|
|
49
|
+
data-bs-toggle="tooltip"
|
|
50
|
+
data-bs-title="Remove this resource from the list — it's running a job"
|
|
51
|
+
data-remove-resource=r.id
|
|
52
|
+
data-remove-name=r.name
|
|
53
|
+
data-remove-job=r.activeJob.id
|
|
54
|
+
data-remove-return=returnTo
|
|
55
|
+
)
|
|
56
|
+
i.bi.bi-trash
|
|
57
|
+
else
|
|
58
|
+
form.d-inline.ms-1(
|
|
59
|
+
method="post"
|
|
60
|
+
action=`/resources/${r.id}/remove`
|
|
61
|
+
data-confirm=`Remove ${r.name} from the list? Its job history is kept. A Client that's still running shows up again when it restarts and re-registers.`
|
|
62
|
+
onsubmit="return confirm(this.dataset.confirm)"
|
|
63
|
+
)
|
|
64
|
+
input(type="hidden" name="returnTo" value=returnTo)
|
|
65
|
+
button.btn.btn-sm.btn-outline-danger(
|
|
66
|
+
type="submit"
|
|
67
|
+
aria-label="Remove"
|
|
68
|
+
data-bs-toggle="tooltip"
|
|
69
|
+
data-bs-title="Remove this resource from the list"
|
|
70
|
+
)
|
|
71
|
+
i.bi.bi-trash
|
|
72
|
+
|
|
73
|
+
//- One per page (admins): filled in by public/js/remove-resource.js from
|
|
74
|
+
//- the clicked Remove button's data-remove-* attributes.
|
|
75
|
+
mixin removeResourceModal()
|
|
76
|
+
.modal.fade#remove-resource-modal(tabindex="-1" aria-labelledby="remove-resource-title" aria-hidden="true")
|
|
77
|
+
.modal-dialog.modal-dialog-centered
|
|
78
|
+
.modal-content
|
|
79
|
+
.modal-header.text-bg-warning
|
|
80
|
+
h5.modal-title#remove-resource-title
|
|
81
|
+
i.bi.bi-exclamation-triangle-fill.me-2
|
|
82
|
+
| Client is running a job
|
|
83
|
+
button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
|
|
84
|
+
.modal-body
|
|
85
|
+
p
|
|
86
|
+
strong(data-field="name")
|
|
87
|
+
| is running job
|
|
88
|
+
|
|
|
89
|
+
a(data-field="job")
|
|
90
|
+
| .
|
|
91
|
+
p.mb-2 Removing it will:
|
|
92
|
+
ul.mb-2
|
|
93
|
+
li
|
|
94
|
+
| stop job
|
|
95
|
+
|
|
|
96
|
+
strong(data-field="job-text")
|
|
97
|
+
| immediately (it ends as
|
|
98
|
+
|
|
|
99
|
+
code CANCELED
|
|
100
|
+
| ), then
|
|
101
|
+
li remove the resource as soon as its Client confirms the job stopped (at most about a minute if the Client doesn't answer).
|
|
102
|
+
p.small.text-body-secondary.mb-0 Its job history is kept. A Client that's still running shows up again when it restarts and re-registers.
|
|
103
|
+
.modal-footer
|
|
104
|
+
button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Keep it
|
|
105
|
+
form(method="post" data-field="form")
|
|
106
|
+
input(type="hidden" name="stopJob" value="1")
|
|
107
|
+
input(type="hidden" name="returnTo" data-field="return")
|
|
108
|
+
button.btn.btn-danger(type="submit")
|
|
109
|
+
i.bi.bi-stop-circle.me-1
|
|
110
|
+
| Stop job and remove
|
|
35
111
|
|
|
36
112
|
//- One udev device line: path (or udev index / ST-Link serial), extra
|
|
37
113
|
//- detail, and whether the node was present when the Client registered.
|
|
@@ -156,7 +232,7 @@ mixin resourceCard(r, groupsById, returnTo)
|
|
|
156
232
|
dt.col-sm-4 Type
|
|
157
233
|
dd.col-sm-8.text-uppercase= r.type
|
|
158
234
|
dt.col-sm-4 Status
|
|
159
|
-
dd.col-sm-8: +resourceBadge(r.status)
|
|
235
|
+
dd.col-sm-8: +resourceBadge(r.status, r.remove_requested_at)
|
|
160
236
|
+uptimeRows(r)
|
|
161
237
|
dt.col-sm-4 Client version
|
|
162
238
|
dd.col-sm-8: +versionCell(r.client_version, updates.latest.client, r.update_to)
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
//- `removing`: the resource's remove_requested_at — removal is pending
|
|
2
|
+
//- until its Client confirms its stopped job ended (jobs.removeResource).
|
|
3
|
+
mixin resourceBadge(status, removing)
|
|
2
4
|
- const map = { IDLE: 'success', BUSY: 'primary', OUT_OF_SERVICE: 'danger', MAINTENANCE: 'secondary', REGISTERED: 'secondary' }
|
|
3
5
|
span.badge(class=`text-bg-${map[status] || 'secondary'}`)= status
|
|
6
|
+
if removing
|
|
7
|
+
span.badge.text-bg-warning.ms-1(
|
|
8
|
+
data-bs-toggle="tooltip"
|
|
9
|
+
data-bs-title="Its job was stopped; removed as soon as the Client confirms"
|
|
10
|
+
) REMOVING
|
|
4
11
|
|
|
5
12
|
mixin jobBadge(state)
|
|
6
13
|
- const map = { QUEUED: 'secondary', ASSIGNED: 'info', PREPARING: 'info', RUNNING: 'primary', PASSED: 'success', FAILED: 'danger', ERROR: 'danger', TIMEOUT: 'warning', CANCELED: 'secondary', LOST: 'danger' }
|
package/views/resources/list.pug
CHANGED
|
@@ -30,7 +30,7 @@ block content
|
|
|
30
30
|
td= r.name
|
|
31
31
|
td.text-uppercase= r.type
|
|
32
32
|
td: +versionCell(r.client_version, updates.latest.client, r.update_to)
|
|
33
|
-
td: +resourceBadge(r.status)
|
|
33
|
+
td: +resourceBadge(r.status, r.remove_requested_at)
|
|
34
34
|
td
|
|
35
35
|
each l in r.labels
|
|
36
36
|
span.badge.text-bg-light.border.me-1= l
|
|
@@ -49,5 +49,9 @@ block content
|
|
|
49
49
|
each r in resources
|
|
50
50
|
+resourceCard(r, groupsById, '/resources')
|
|
51
51
|
|
|
52
|
+
if user && user.role === 'admin'
|
|
53
|
+
+removeResourceModal()
|
|
54
|
+
|
|
52
55
|
block scripts
|
|
53
56
|
script(src="/js/resource-card.js")
|
|
57
|
+
script(src="/js/remove-resource.js")
|