@andrian.yablonskyy/thub-coordinator 1.0.17 → 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 +57 -7
- package/src/services/registry.js +40 -2
- package/src/web/routes.js +36 -3
- package/test/scheduler.test.js +106 -0
- package/views/index.pug +5 -1
- package/views/jobs/list.pug +17 -3
- package/views/jobs/show.pug +5 -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,7 +88,9 @@ 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
|
}
|
|
@@ -152,8 +157,10 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
152
157
|
params.push(now);
|
|
153
158
|
}
|
|
154
159
|
if (TERMINAL_JOB_STATES.has(state)){
|
|
155
|
-
|
|
156
|
-
|
|
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);
|
|
157
164
|
}
|
|
158
165
|
if (extra.message !== undefined){
|
|
159
166
|
fields.push('message = ?');
|
|
@@ -168,9 +175,11 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
168
175
|
params.push(JSON.stringify(extra.summary));
|
|
169
176
|
}
|
|
170
177
|
if (state === JOB_STATES.QUEUED){
|
|
171
|
-
// Requeue: drop the previous assignment
|
|
172
|
-
|
|
173
|
-
|
|
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);
|
|
174
183
|
}
|
|
175
184
|
|
|
176
185
|
params.push(id);
|
|
@@ -213,6 +222,44 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
213
222
|
return setState(id, JOB_STATES.CANCELED);
|
|
214
223
|
}
|
|
215
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
|
+
|
|
216
263
|
// The job a resource is working on (ASSIGNED through RUNNING), if any —
|
|
217
264
|
// for the resource card's Cancel button.
|
|
218
265
|
function activeForResource(resourceId){
|
|
@@ -362,6 +409,9 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
362
409
|
create,
|
|
363
410
|
setState,
|
|
364
411
|
cancel,
|
|
412
|
+
cancelPinnedTo,
|
|
413
|
+
removeResource,
|
|
414
|
+
completeRemoval,
|
|
365
415
|
activeForResource,
|
|
366
416
|
applyResult,
|
|
367
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){
|
|
@@ -474,9 +474,44 @@ function createRegistryService(db, { bus, events }){
|
|
|
474
474
|
(!resourceId || r.id === resourceId);
|
|
475
475
|
}
|
|
476
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
|
+
|
|
477
512
|
function findIdleCandidates(type, labels, groupId, resourceId){
|
|
478
513
|
const rows = db
|
|
479
|
-
.prepare('SELECT * FROM resources WHERE status = ? AND type = ?')
|
|
514
|
+
.prepare('SELECT * FROM resources WHERE status = ? AND type = ? AND remove_requested_at IS NULL')
|
|
480
515
|
.all(RESOURCE_STATES.IDLE, type)
|
|
481
516
|
.map(rowToResource);
|
|
482
517
|
return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
@@ -504,6 +539,9 @@ function createRegistryService(db, { bus, events }){
|
|
|
504
539
|
requestUpdateAll,
|
|
505
540
|
pendingUpdate,
|
|
506
541
|
assignToJob,
|
|
542
|
+
remove,
|
|
543
|
+
requestRemoval,
|
|
544
|
+
pendingRemovals,
|
|
507
545
|
findIdleCandidates,
|
|
508
546
|
everSatisfiable
|
|
509
547
|
};
|
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
|
});
|
|
@@ -440,9 +466,16 @@ function createWebRouter({ services, config }){
|
|
|
440
466
|
});
|
|
441
467
|
});
|
|
442
468
|
|
|
469
|
+
// From the job page or a row on /jobs (returnTo says which).
|
|
443
470
|
router.post('/jobs/:id/cancel', requireAdminRole, (req, res) => {
|
|
444
|
-
|
|
445
|
-
|
|
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}`));
|
|
446
479
|
});
|
|
447
480
|
|
|
448
481
|
// Plain, one-shot fetch — used by the log viewer for a job that's
|
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
|
@@ -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,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
|
|
@@ -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")
|