@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/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
- const AVATAR_MAX_BYTES = 2 * 1024 * 1024,
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(services.registry.list()),
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
- jobs = services.jobs.list({ ...filters, limit: 200 }).map((j) => ({
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
- canManage: req.session.user.role === 'admin'
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
- services.jobs.cancel(req.params.id, { isAdmin: true });
445
- res.redirect(`/jobs/${req.params.id}`);
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
+ });
@@ -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")
@@ -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
- form(method="post" action="/jobs/reset-queue" onsubmit="return confirm('Cancel every queued/assigned/preparing/running job? This stops in-flight work.')")
10
- button.btn.btn-sm.btn-outline-warning(type="submit") Reset queue
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
- th Job
30
- th Source
31
- th User
32
- th State
33
- th Resource
34
- th Created
35
- th Finished
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.
@@ -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