@andrian.yablonskyy/thub-coordinator 1.0.17 → 1.0.19
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/db/migrations/016_add_list_prefs.sql +5 -0
- package/src/services/admin-users.js +27 -2
- package/src/services/heartbeat.js +23 -2
- package/src/services/jobs.js +108 -8
- package/src/services/list-prefs.js +92 -0
- package/src/services/registry.js +40 -2
- package/src/web/routes.js +101 -14
- package/test/list-prefs.test.js +89 -0
- package/test/scheduler.test.js +106 -0
- package/views/index.pug +5 -1
- package/views/jobs/list.pug +77 -13
- package/views/jobs/show.pug +5 -2
- package/views/mixins/confirm-modal.pug +26 -0
- package/views/mixins/list-controls.pug +61 -0
- package/views/mixins/resource.pug +77 -1
- package/views/mixins/status-badge.pug +8 -1
- package/views/resources/list.pug +18 -9
package/src/web/routes.js
CHANGED
|
@@ -20,12 +20,25 @@ const fs = require('node:fs'),
|
|
|
20
20
|
multer = require('multer'),
|
|
21
21
|
{ requireAdminSession, requireAdminRole } = require('../auth'),
|
|
22
22
|
{ attachJobStream } = require('../api/sse'),
|
|
23
|
-
{ RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES, isNewer } = require('@andrian.yablonskyy/thub-common')
|
|
23
|
+
{ RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES, isNewer, compareVersions } = require('@andrian.yablonskyy/thub-common'),
|
|
24
|
+
listPrefs = require('../services/list-prefs');
|
|
25
|
+
|
|
26
|
+
// Resources page sort keys (list-prefs.js) -> comparators. Empty values
|
|
27
|
+
// (never heartbeated, no version reported) sort last in both directions.
|
|
28
|
+
const RESOURCE_SORT_VALUE = {
|
|
29
|
+
name: (r) => r.name,
|
|
30
|
+
type: (r) => r.type,
|
|
31
|
+
version: (r) => r.client_version,
|
|
32
|
+
status: (r) => r.status,
|
|
33
|
+
heartbeat: (r) => r.last_heartbeat_at
|
|
34
|
+
},
|
|
35
|
+
compareResourceValues = (sort, a, b) =>
|
|
36
|
+
sort === 'version' ? compareVersions(a, b) : String(a).localeCompare(String(b), undefined, { numeric: true }),
|
|
24
37
|
|
|
25
|
-
// §10.1: avatar uploads are small, single images — a hard size cap and an
|
|
26
|
-
// allow-list of image mimetypes, same spirit as the join-key/token checks
|
|
27
|
-
// elsewhere (reject outright rather than trying to sanitize).
|
|
28
|
-
|
|
38
|
+
// §10.1: avatar uploads are small, single images — a hard size cap and an
|
|
39
|
+
// allow-list of image mimetypes, same spirit as the join-key/token checks
|
|
40
|
+
// elsewhere (reject outright rather than trying to sanitize).
|
|
41
|
+
AVATAR_MAX_BYTES = 2 * 1024 * 1024,
|
|
29
42
|
AVATAR_EXT_BY_MIMETYPE = {
|
|
30
43
|
'image/png': 'png',
|
|
31
44
|
'image/jpeg': 'jpg',
|
|
@@ -55,6 +68,30 @@ function createWebRouter({ services, config }){
|
|
|
55
68
|
return typeof target === 'string' && /^\/(?![\/\\])/.test(target) ? target : fallback;
|
|
56
69
|
}
|
|
57
70
|
|
|
71
|
+
// A list page's view (§10.1): the user's stored size/sort/dir, overridden
|
|
72
|
+
// by ?size/?sort/?dir — and a change is saved straight to their profile,
|
|
73
|
+
// so it's what they get next time on any device. ?page isn't saved.
|
|
74
|
+
function listView(req, res, list, extraQuery = {}){
|
|
75
|
+
const user = req.session.user,
|
|
76
|
+
{ prefs, changed } = listPrefs.fromQuery(list, req.query, user.listPrefs?.[list]);
|
|
77
|
+
if (changed){
|
|
78
|
+
req.session.user = services.adminUsers.setListPrefs(user.id, list, prefs);
|
|
79
|
+
res.locals.user = req.session.user;
|
|
80
|
+
}
|
|
81
|
+
const base = { ...extraQuery, size: prefs.size, sort: prefs.sort, dir: prefs.dir },
|
|
82
|
+
// Link to this list with some params changed; changing size or sort
|
|
83
|
+
// restarts at page 1. Empty params are dropped.
|
|
84
|
+
urlFor = (changes = {}) => {
|
|
85
|
+
const q = { ...base, ...changes };
|
|
86
|
+
if (('size' in changes || 'sort' in changes) && !('page' in changes)){
|
|
87
|
+
delete q.page;
|
|
88
|
+
}
|
|
89
|
+
const qs = new URLSearchParams(Object.entries(q).filter(([, v]) => v !== undefined && v !== null && v !== '')).toString();
|
|
90
|
+
return `${req.baseUrl}${req.path}${qs ? `?${qs}` : ''}`;
|
|
91
|
+
};
|
|
92
|
+
return { prefs, urlFor, pageSizes: listPrefs.PAGE_SIZES };
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
// The job each resource is on (if any), for the resource card's Cancel.
|
|
59
96
|
function withActiveJobs(resources){
|
|
60
97
|
return resources.map((r) => ({ ...r, activeJob: services.jobs.activeForResource(r.id) }));
|
|
@@ -231,12 +268,26 @@ function createWebRouter({ services, config }){
|
|
|
231
268
|
});
|
|
232
269
|
|
|
233
270
|
router.get('/resources', (req, res) => {
|
|
234
|
-
const groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g]))
|
|
271
|
+
const groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g])),
|
|
272
|
+
view = listView(req, res, 'resources'),
|
|
273
|
+
{ sort, dir, size } = view.prefs,
|
|
274
|
+
value = RESOURCE_SORT_VALUE[sort],
|
|
275
|
+
sorted = services.registry.list().sort((a, b) => {
|
|
276
|
+
const va = value(a),
|
|
277
|
+
vb = value(b);
|
|
278
|
+
if (va == null || vb == null){
|
|
279
|
+
return (va == null) - (vb == null);
|
|
280
|
+
}
|
|
281
|
+
return (dir === 'asc' ? 1 : -1) * compareResourceValues(sort, va, vb) || a.name.localeCompare(b.name);
|
|
282
|
+
}),
|
|
283
|
+
pagination = listPrefs.paginate(sorted.length, size, req.query.page),
|
|
284
|
+
pageUrl = (changes) => view.urlFor({ page: pagination.page, ...changes });
|
|
235
285
|
res.render('resources/list', {
|
|
236
286
|
title: 'Resources',
|
|
237
287
|
active: 'resources',
|
|
238
|
-
resources: withActiveJobs(
|
|
239
|
-
groupsById
|
|
288
|
+
resources: withActiveJobs(sorted.slice(pagination.offset, pagination.offset + pagination.limit)),
|
|
289
|
+
groupsById,
|
|
290
|
+
list: { ...view, pagination, pageUrl }
|
|
240
291
|
});
|
|
241
292
|
});
|
|
242
293
|
|
|
@@ -284,6 +335,26 @@ function createWebRouter({ services, config }){
|
|
|
284
335
|
res.redirect(returnTo(req, '/resources'));
|
|
285
336
|
});
|
|
286
337
|
|
|
338
|
+
router.post('/resources/:id/remove', requireAdminRole, (req, res) => {
|
|
339
|
+
try {
|
|
340
|
+
const { resource: r, pending, stoppedJob, canceledJobs } = services.jobs.removeResource(req.params.id, {
|
|
341
|
+
by: req.session.user.username,
|
|
342
|
+
stopJob: req.body.stopJob === '1'
|
|
343
|
+
});
|
|
344
|
+
flash(
|
|
345
|
+
req,
|
|
346
|
+
'warning',
|
|
347
|
+
pending
|
|
348
|
+
? `Stopped job ${stoppedJob} on ${r.name}; ${r.name} is removed as soon as its Client confirms the job ended.`
|
|
349
|
+
: `Removed ${r.name}.` + (canceledJobs.length ? ` Canceled job(s) queued for it: ${canceledJobs.join(', ')}.` : '')
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
catch (err){
|
|
353
|
+
flash(req, 'danger', err.message);
|
|
354
|
+
}
|
|
355
|
+
res.redirect(returnTo(req, '/resources'));
|
|
356
|
+
});
|
|
357
|
+
|
|
287
358
|
router.post('/resources/:id/rotate-token', requireAdminRole, (req, res) => {
|
|
288
359
|
const { generateToken, hashToken } = require('../services/tokens');
|
|
289
360
|
const token = generateToken('res');
|
|
@@ -393,16 +464,25 @@ function createWebRouter({ services, config }){
|
|
|
393
464
|
|
|
394
465
|
router.get('/jobs', (req, res) => {
|
|
395
466
|
const filters = { state: req.query.state || undefined, source: req.query.source || undefined },
|
|
396
|
-
|
|
467
|
+
view = listView(req, res, 'jobs', filters),
|
|
468
|
+
{ rows, pagination } = services.jobs.page({ ...filters, ...view.prefs, page: req.query.page }),
|
|
469
|
+
jobs = rows.map((j) => ({
|
|
397
470
|
...j,
|
|
398
|
-
resource: j.resource_id ? services.registry.get(j.resource_id) : null
|
|
399
|
-
|
|
471
|
+
resource: j.resource_id ? services.registry.get(j.resource_id) : null,
|
|
472
|
+
active: ACTIVE_JOB_STATES.has(j.state)
|
|
473
|
+
})),
|
|
474
|
+
pageUrl = (changes) => view.urlFor({ page: pagination.page, ...changes });
|
|
400
475
|
res.render('jobs/list', {
|
|
401
476
|
title: 'Jobs',
|
|
402
477
|
active: 'jobs',
|
|
403
478
|
jobs,
|
|
404
479
|
filters,
|
|
405
|
-
|
|
480
|
+
list: { ...view, pagination, pageUrl },
|
|
481
|
+
// A row's Cancel comes back to this exact view (filters, sort, page).
|
|
482
|
+
returnTo: pageUrl(),
|
|
483
|
+
canManage: req.session.user.role === 'admin',
|
|
484
|
+
// For the Reset queue / Clean history confirmation modals.
|
|
485
|
+
jobCounts: req.session.user.role === 'admin' ? services.jobs.countActiveAndFinished() : null
|
|
406
486
|
});
|
|
407
487
|
});
|
|
408
488
|
|
|
@@ -440,9 +520,16 @@ function createWebRouter({ services, config }){
|
|
|
440
520
|
});
|
|
441
521
|
});
|
|
442
522
|
|
|
523
|
+
// From the job page or a row on /jobs (returnTo says which).
|
|
443
524
|
router.post('/jobs/:id/cancel', requireAdminRole, (req, res) => {
|
|
444
|
-
|
|
445
|
-
|
|
525
|
+
try {
|
|
526
|
+
services.jobs.cancel(req.params.id, { isAdmin: true });
|
|
527
|
+
flash(req, 'warning', `Canceled job ${req.params.id}.`);
|
|
528
|
+
}
|
|
529
|
+
catch (err){
|
|
530
|
+
flash(req, 'danger', err.message);
|
|
531
|
+
}
|
|
532
|
+
res.redirect(returnTo(req, `/jobs/${req.params.id}`));
|
|
446
533
|
});
|
|
447
534
|
|
|
448
535
|
// Plain, one-shot fetch — used by the log viewer for a job that's
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/list-prefs.test.js
|
|
3
|
+
* @description Tests: list view preferences (page size/sort), their storage in the user profile, and paged job queries
|
|
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
|
+
{ EventEmitter } = require('node:events'),
|
|
19
|
+
{ openDb } = require('../src/db'),
|
|
20
|
+
{ createAdminUsersService } = require('../src/services/admin-users'),
|
|
21
|
+
{ createEventsService } = require('../src/services/events'),
|
|
22
|
+
{ createRegistryService } = require('../src/services/registry'),
|
|
23
|
+
{ createAgentsService } = require('../src/services/agents'),
|
|
24
|
+
{ createJobsService } = require('../src/services/jobs'),
|
|
25
|
+
{ normalize, fromQuery, paginate } = require('../src/services/list-prefs');
|
|
26
|
+
|
|
27
|
+
test('normalize fills defaults and drops invalid values', () => {
|
|
28
|
+
assert.deepEqual(normalize('jobs'), { size: 25, sort: 'created', dir: 'desc' });
|
|
29
|
+
assert.deepEqual(normalize('resources', { size: '100', sort: 'status', dir: 'desc' }), { size: 100, sort: 'status', dir: 'desc' });
|
|
30
|
+
assert.deepEqual(normalize('resources', { size: 7, sort: 'labels', dir: 'up' }), { size: 25, sort: 'name', dir: 'asc' });
|
|
31
|
+
assert.equal(normalize('jobs', { size: 'all' }).size, 'all');
|
|
32
|
+
assert.throws(() => normalize('nope'), /Unknown list/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('fromQuery overrides stored prefs and reports whether anything changed', () => {
|
|
36
|
+
const stored = { size: 50, sort: 'state', dir: 'asc' };
|
|
37
|
+
assert.deepEqual(fromQuery('jobs', {}, stored), { prefs: stored, changed: false });
|
|
38
|
+
assert.deepEqual(fromQuery('jobs', { size: '10', page: '3' }, stored), { prefs: { ...stored, size: 10 }, changed: true });
|
|
39
|
+
assert.equal(fromQuery('jobs', { sort: 'bogus', size: '11' }, stored).changed, false); // invalid: ignored
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('paginate clamps the page and computes the window', () => {
|
|
43
|
+
assert.deepEqual(paginate(112, 25, '3'), { page: 3, pages: 5, total: 112, offset: 50, limit: 25, from: 51, to: 75 });
|
|
44
|
+
assert.equal(paginate(112, 25, '99').page, 5);
|
|
45
|
+
assert.equal(paginate(112, 25, 'x').page, 1);
|
|
46
|
+
assert.deepEqual(paginate(112, 'all', 1), { page: 1, pages: 1, total: 112, offset: 0, limit: 112, from: 1, to: 112 });
|
|
47
|
+
assert.deepEqual(paginate(0, 10, 1), { page: 1, pages: 1, total: 0, offset: 0, limit: 10, from: 0, to: 0 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('list prefs are stored per page in the user profile', () => {
|
|
51
|
+
const users = createAdminUsersService(openDb(':memory:')),
|
|
52
|
+
{ id } = users.create({ username: 'alice', password: 'pw', role: 'admin' });
|
|
53
|
+
assert.deepEqual(users.getById(id).listPrefs, {});
|
|
54
|
+
|
|
55
|
+
users.setListPrefs(id, 'jobs', { size: 100, sort: 'duration', dir: 'asc' });
|
|
56
|
+
const profile = users.setListPrefs(id, 'resources', { size: 'all', sort: 'nonsense' });
|
|
57
|
+
assert.deepEqual(profile.listPrefs, {
|
|
58
|
+
jobs: { size: 100, sort: 'duration', dir: 'asc' },
|
|
59
|
+
resources: { size: 'all', sort: 'name', dir: 'asc' }
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('jobs.page sorts and pages in SQL, with empty values last', () => {
|
|
64
|
+
const db = openDb(':memory:'),
|
|
65
|
+
bus = new EventEmitter(),
|
|
66
|
+
events = createEventsService(db),
|
|
67
|
+
registry = createRegistryService(db, { bus, events }),
|
|
68
|
+
{ agent } = createAgentsService(db, { events }).create({ name: 'ci', kind: 'ci' }),
|
|
69
|
+
config = { scheduler: { maxQueuedPerAgent: 100 }, jobs: { defaultTimeoutSec: 60, maxTimeoutSec: 60 } },
|
|
70
|
+
jobs = createJobsService(db, { bus, events, registry, artifacts: {}, config });
|
|
71
|
+
registry.registerAuto({ clientId: 'c', name: 'lab', type: 'sw', labels: [] });
|
|
72
|
+
|
|
73
|
+
const ids = ['carol', 'alice', null, 'bob'].map((user) =>
|
|
74
|
+
jobs.create({
|
|
75
|
+
agentId: agent.id,
|
|
76
|
+
source: 'cli',
|
|
77
|
+
spec: { target: { type: 'sw' }, firmware: { url: 'https://x/a' }, tests: { url: 'https://x/t' }, ...(user ? { user } : {}) }
|
|
78
|
+
}).id),
|
|
79
|
+
|
|
80
|
+
byUser = (dir) => jobs.page({ sort: 'user', dir, size: 'all' }).rows.map((j) => j.spec.user ?? null);
|
|
81
|
+
assert.deepEqual(byUser('asc'), ['alice', 'bob', 'carol', null]);
|
|
82
|
+
assert.deepEqual(byUser('desc'), ['carol', 'bob', 'alice', null]);
|
|
83
|
+
|
|
84
|
+
const second = jobs.page({ sort: 'id', dir: 'asc', size: 2, page: 2 });
|
|
85
|
+
assert.deepEqual(second.rows.map((j) => j.id), ids.slice(2));
|
|
86
|
+
assert.equal(second.pagination.total, 4);
|
|
87
|
+
assert.equal(second.pagination.pages, 2);
|
|
88
|
+
assert.equal(jobs.page({ state: 'PASSED' }).pagination.total, 0);
|
|
89
|
+
});
|
package/test/scheduler.test.js
CHANGED
|
@@ -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' }),
|
|
@@ -419,6 +447,84 @@ test('a job with target.client is rejected (422) when the client is unknown or c
|
|
|
419
447
|
);
|
|
420
448
|
});
|
|
421
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
|
+
|
|
422
528
|
test('a resource can belong to several groups at once', () => {
|
|
423
529
|
const { registry, groups } = buildTestServices(),
|
|
424
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
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
extends ../layout
|
|
2
2
|
include ../mixins/status-badge
|
|
3
|
+
include ../mixins/list-controls
|
|
4
|
+
include ../mixins/confirm-modal
|
|
3
5
|
|
|
4
6
|
block content
|
|
5
7
|
.d-flex.justify-content-between.align-items-center.mb-3
|
|
6
8
|
h1.h4.mb-0 Jobs
|
|
7
9
|
if canManage
|
|
8
10
|
.btn-group
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
form.ms-1(method="post" action="/jobs/clean-history" onsubmit="return confirm('Permanently delete all finished jobs and their logs/artifacts? This cannot be undone.')")
|
|
12
|
-
button.btn.btn-sm.btn-outline-danger(type="submit") Clean history
|
|
11
|
+
button.btn.btn-sm.btn-outline-warning(type="button" data-bs-toggle="modal" data-bs-target="#reset-queue-modal") Reset queue
|
|
12
|
+
button.btn.btn-sm.btn-outline-danger.ms-1(type="button" data-bs-toggle="modal" data-bs-target="#clean-history-modal") Clean history
|
|
13
13
|
|
|
14
14
|
form.row.g-2.mb-3(method="get")
|
|
15
15
|
.col-auto
|
|
@@ -22,17 +22,23 @@ block content
|
|
|
22
22
|
option(value="") any source
|
|
23
23
|
option(value="ci" selected=filters.source === 'ci') ci
|
|
24
24
|
option(value="cli" selected=filters.source === 'cli') cli
|
|
25
|
+
.col-auto.ms-auto
|
|
26
|
+
+pageSizePicker(list)
|
|
27
|
+
|
|
28
|
+
+listToolbar(list, 'jobs')
|
|
25
29
|
|
|
26
30
|
table.table.table-hover.align-middle
|
|
27
31
|
thead
|
|
28
32
|
tr
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
+sortHeader(list, 'id', 'Job')
|
|
34
|
+
+sortHeader(list, 'source', 'Source')
|
|
35
|
+
+sortHeader(list, 'user', 'User')
|
|
36
|
+
+sortHeader(list, 'state', 'State')
|
|
37
|
+
+sortHeader(list, 'resource', 'Resource')
|
|
38
|
+
+sortHeader(list, 'created', 'Created', 'desc')
|
|
39
|
+
+sortHeader(list, 'duration', 'Duration', 'desc')
|
|
40
|
+
if canManage
|
|
41
|
+
th
|
|
36
42
|
tbody
|
|
37
43
|
each j in jobs
|
|
38
44
|
tr
|
|
@@ -40,6 +46,64 @@ block content
|
|
|
40
46
|
td.text-uppercase= j.source
|
|
41
47
|
td= j.spec.user || '—'
|
|
42
48
|
td: +jobBadge(j.state)
|
|
43
|
-
td= j.resource ? j.resource.name : '—'
|
|
49
|
+
td= j.resource ? j.resource.name : j.resource_name ? `${j.resource_name} (removed)` : '—'
|
|
44
50
|
td= fmtDate(j.created_at)
|
|
45
|
-
td= fmtDate(j.finished_at)
|
|
51
|
+
td(title=j.finished_at ? `Finished ${fmtDate(j.finished_at)}` : null)= j.duration_sec != null ? fmtDuration(j.duration_sec * 1000) : '—'
|
|
52
|
+
if canManage
|
|
53
|
+
td.text-end
|
|
54
|
+
if j.active
|
|
55
|
+
form.d-inline(
|
|
56
|
+
method="post"
|
|
57
|
+
action=`/jobs/${j.id}/cancel`
|
|
58
|
+
data-confirm=`Cancel job ${j.id}?`
|
|
59
|
+
onsubmit="return confirm(this.dataset.confirm)"
|
|
60
|
+
)
|
|
61
|
+
input(type="hidden" name="returnTo" value=returnTo)
|
|
62
|
+
button.btn.btn-sm.btn-outline-danger(type="submit" aria-label="Cancel" title="Cancel this job")
|
|
63
|
+
i.bi.bi-x-circle
|
|
64
|
+
|
|
65
|
+
if canManage
|
|
66
|
+
+confirmModal({
|
|
67
|
+
id: 'reset-queue-modal',
|
|
68
|
+
tone: 'warning',
|
|
69
|
+
icon: 'bi-exclamation-triangle-fill',
|
|
70
|
+
title: 'Reset the job queue?',
|
|
71
|
+
action: '/jobs/reset-queue',
|
|
72
|
+
confirmLabel: 'Cancel all active jobs',
|
|
73
|
+
confirmIcon: 'bi-x-octagon',
|
|
74
|
+
disabled: !jobCounts.active
|
|
75
|
+
})
|
|
76
|
+
if jobCounts.active
|
|
77
|
+
p
|
|
78
|
+
| This cancels
|
|
79
|
+
|
|
|
80
|
+
strong= `${jobCounts.active} active job${jobCounts.active === 1 ? '' : 's'}`
|
|
81
|
+
| — everything queued, assigned, preparing or running. Running jobs are stopped on their Clients, and all of them end as
|
|
82
|
+
|
|
|
83
|
+
code CANCELED
|
|
84
|
+
| .
|
|
85
|
+
p.small.text-body-secondary.mb-0 Finished jobs, logs and artifacts are not touched.
|
|
86
|
+
else
|
|
87
|
+
p.mb-0 There are no active jobs — nothing to cancel.
|
|
88
|
+
|
|
89
|
+
+confirmModal({
|
|
90
|
+
id: 'clean-history-modal',
|
|
91
|
+
tone: 'danger',
|
|
92
|
+
icon: 'bi-trash3-fill',
|
|
93
|
+
title: 'Clean job history?',
|
|
94
|
+
action: '/jobs/clean-history',
|
|
95
|
+
confirmLabel: 'Delete finished jobs',
|
|
96
|
+
confirmIcon: 'bi-trash3',
|
|
97
|
+
disabled: !jobCounts.finished
|
|
98
|
+
})
|
|
99
|
+
if jobCounts.finished
|
|
100
|
+
p
|
|
101
|
+
| This permanently deletes
|
|
102
|
+
|
|
|
103
|
+
strong= `${jobCounts.finished} finished job${jobCounts.finished === 1 ? '' : 's'}`
|
|
104
|
+
| together with their logs and artifacts (database rows and files on disk).
|
|
105
|
+
p.mb-0
|
|
106
|
+
strong This cannot be undone.
|
|
107
|
+
| Active jobs are not touched.
|
|
108
|
+
else
|
|
109
|
+
p.mb-0 There are no finished jobs — nothing to delete.
|
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,7 +19,7 @@ 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
25
|
if job.spec.target.client
|
|
@@ -27,6 +27,9 @@ block content
|
|
|
27
27
|
dd.col-sm-10= pinnedClient ? pinnedClient.name : job.spec.target.client
|
|
28
28
|
dt.col-sm-2 Created
|
|
29
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)
|
|
30
33
|
if job.message
|
|
31
34
|
dt.col-sm-2 Message
|
|
32
35
|
dd.col-sm-10= job.message
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//- A confirmation modal for a destructive admin action that POSTs to
|
|
2
|
+
//- `opts.action`: opened by a button with data-bs-toggle="modal"
|
|
3
|
+
//- data-bs-target="#<opts.id>" (no JS needed). The block is the body text.
|
|
4
|
+
//- opts: id, title, action, confirmLabel, and optional tone (Bootstrap
|
|
5
|
+
//- color, default danger), icon / confirmIcon (bootstrap-icons classes),
|
|
6
|
+
//- disabled (nothing to do — the confirm button is disabled).
|
|
7
|
+
mixin confirmModal(opts)
|
|
8
|
+
- const tone = opts.tone || 'danger'
|
|
9
|
+
.modal.fade(id=opts.id tabindex="-1" aria-labelledby=`${opts.id}-title` aria-hidden="true")
|
|
10
|
+
.modal-dialog.modal-dialog-centered
|
|
11
|
+
.modal-content
|
|
12
|
+
.modal-header(class=`text-bg-${tone}`)
|
|
13
|
+
h5.modal-title(id=`${opts.id}-title`)
|
|
14
|
+
if opts.icon
|
|
15
|
+
i.bi.me-2(class=opts.icon)
|
|
16
|
+
= opts.title
|
|
17
|
+
button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
|
|
18
|
+
.modal-body
|
|
19
|
+
block
|
|
20
|
+
.modal-footer
|
|
21
|
+
button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Keep
|
|
22
|
+
form(method="post" action=opts.action)
|
|
23
|
+
button.btn(type="submit" class=`btn-${tone}` disabled=opts.disabled)
|
|
24
|
+
if opts.confirmIcon
|
|
25
|
+
i.bi.me-1(class=opts.confirmIcon)
|
|
26
|
+
= opts.confirmLabel
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
//- Pagination and sorting for the dashboard's list pages (Jobs, Resources;
|
|
2
|
+
//- README §10.1). `list` comes from routes.js listView(): prefs {size, sort,
|
|
3
|
+
//- dir}, pagination {page, pages, total, from, to}, pageSizes, urlFor(changes)
|
|
4
|
+
//- (link with params changed; size/sort changes restart at page 1).
|
|
5
|
+
|
|
6
|
+
//- Records-per-page picker — placed on the right of the page's filter row.
|
|
7
|
+
//- Navigates on change (no `name`), so it never rides along with a filter
|
|
8
|
+
//- form it sits in.
|
|
9
|
+
mixin pageSizePicker(list)
|
|
10
|
+
.d-flex.align-items-center.gap-2
|
|
11
|
+
label.small.text-body-secondary.mb-0.text-nowrap(for="list-page-size") Show
|
|
12
|
+
select.form-select.form-select-sm.w-auto#list-page-size(
|
|
13
|
+
aria-label="Records per page"
|
|
14
|
+
onchange="location.href = this.value"
|
|
15
|
+
)
|
|
16
|
+
each s in list.pageSizes
|
|
17
|
+
option(value=list.urlFor({ size: s }) selected=list.prefs.size === s)= s === 'all' ? 'All' : s
|
|
18
|
+
|
|
19
|
+
//- Shown above the table: "Showing 26–50 of 312 jobs" and the page links.
|
|
20
|
+
mixin listToolbar(list, noun)
|
|
21
|
+
- const p = list.pagination
|
|
22
|
+
.d-flex.flex-wrap.align-items-center.justify-content-between.gap-2.mb-2
|
|
23
|
+
span.small.text-body-secondary
|
|
24
|
+
if p.total
|
|
25
|
+
| Showing #{p.from}–#{p.to} of #{p.total} #{noun}
|
|
26
|
+
else
|
|
27
|
+
| No #{noun}
|
|
28
|
+
if p.pages > 1
|
|
29
|
+
+pager(list)
|
|
30
|
+
|
|
31
|
+
//- First / previous / a window of pages around the current one / next / last.
|
|
32
|
+
mixin pager(list)
|
|
33
|
+
- const p = list.pagination
|
|
34
|
+
- const from = Math.max(1, Math.min(p.page - 2, p.pages - 4))
|
|
35
|
+
- const to = Math.min(p.pages, from + 4)
|
|
36
|
+
nav(aria-label="Pages")
|
|
37
|
+
ul.pagination.pagination-sm.mb-0
|
|
38
|
+
li.page-item(class=p.page === 1 ? 'disabled' : null)
|
|
39
|
+
a.page-link(href=list.pageUrl({ page: 1 }) aria-label="First page") «
|
|
40
|
+
li.page-item(class=p.page === 1 ? 'disabled' : null)
|
|
41
|
+
a.page-link(href=list.pageUrl({ page: p.page - 1 }) aria-label="Previous page") ‹
|
|
42
|
+
- for (let n = from; n <= to; n++)
|
|
43
|
+
li.page-item(class=n === p.page ? 'active' : null aria-current=n === p.page ? 'page' : null)
|
|
44
|
+
a.page-link(href=list.pageUrl({ page: n }))= n
|
|
45
|
+
li.page-item(class=p.page === p.pages ? 'disabled' : null)
|
|
46
|
+
a.page-link(href=list.pageUrl({ page: p.page + 1 }) aria-label="Next page") ›
|
|
47
|
+
li.page-item(class=p.page === p.pages ? 'disabled' : null)
|
|
48
|
+
a.page-link(href=list.pageUrl({ page: p.pages }) aria-label="Last page") »
|
|
49
|
+
|
|
50
|
+
//- A sortable column header: click to sort by it, again to flip direction.
|
|
51
|
+
//- `firstDir` is the direction a column starts in (desc for times/durations).
|
|
52
|
+
mixin sortHeader(list, key, label, firstDir)
|
|
53
|
+
- const active = list.prefs.sort === key
|
|
54
|
+
- const nextDir = active ? (list.prefs.dir === 'asc' ? 'desc' : 'asc') : (firstDir || 'asc')
|
|
55
|
+
th(aria-sort=active ? (list.prefs.dir === 'asc' ? 'ascending' : 'descending') : null)
|
|
56
|
+
a.link-body-emphasis.text-decoration-none.text-nowrap(href=list.urlFor({ sort: key, dir: nextDir }))
|
|
57
|
+
= label
|
|
58
|
+
if active
|
|
59
|
+
i.bi.ms-1(class=list.prefs.dir === 'asc' ? 'bi-caret-up-fill' : 'bi-caret-down-fill')
|
|
60
|
+
else
|
|
61
|
+
i.bi.bi-arrow-down-up.ms-1.text-body-tertiary.small
|