@andrian.yablonskyy/thub-coordinator 1.0.18 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.18",
3
+ "version": "1.0.19",
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,5 @@
1
+ -- Per-user list view preferences (README §10.1): page size and sort
2
+ -- column/direction for each dashboard list, as JSON keyed by page —
3
+ -- {"jobs": {"size": 25, "sort": "created", "dir": "desc"}, "resources": {...}}.
4
+ -- NULL/missing keys fall back to each page's defaults (services/list-prefs.js).
5
+ ALTER TABLE admin_users ADD COLUMN list_prefs TEXT;
@@ -14,7 +14,8 @@
14
14
  'use strict';
15
15
 
16
16
  const crypto = require('node:crypto'),
17
- { v4: uuid } = require('uuid');
17
+ { v4: uuid } = require('uuid'),
18
+ { normalize: normalizeListPrefs } = require('./list-prefs');
18
19
 
19
20
  function hashPassword(password){
20
21
  const salt = crypto.randomBytes(16).toString('hex'),
@@ -44,10 +45,21 @@ function toProfile(row){
44
45
  avatarPath: row.avatar_path,
45
46
  timezone: row.timezone,
46
47
  theme: row.theme,
47
- sessionTimeoutMin: row.session_timeout_min
48
+ sessionTimeoutMin: row.session_timeout_min,
49
+ listPrefs: parseListPrefs(row.list_prefs)
48
50
  };
49
51
  }
50
52
 
53
+ function parseListPrefs(json){
54
+ try {
55
+ const prefs = JSON.parse(json || '{}');
56
+ return prefs && typeof prefs === 'object' && !Array.isArray(prefs) ? prefs : {};
57
+ }
58
+ catch {
59
+ return {};
60
+ }
61
+ }
62
+
51
63
  // §10: dashboard login uses its own admin/viewer account table with
52
64
  // session cookies — deliberately separate from the API bearer tokens.
53
65
  function createAdminUsersService(db){
@@ -157,6 +169,18 @@ function createAdminUsersService(db){
157
169
  return getById(id);
158
170
  }
159
171
 
172
+ // Remembers one list page's view (size/sort/dir, §10.1) for this user;
173
+ // stored normalized, so only valid values ever reach the profile.
174
+ function setListPrefs(id, list, prefs){
175
+ const row = db.prepare('SELECT list_prefs FROM admin_users WHERE id = ?').get(id);
176
+ if (!row){
177
+ throw Object.assign(new Error('Unknown user'), { status: 404 });
178
+ }
179
+ const all = { ...parseListPrefs(row.list_prefs), [list]: normalizeListPrefs(list, prefs) };
180
+ db.prepare('UPDATE admin_users SET list_prefs = ? WHERE id = ?').run(JSON.stringify(all), id);
181
+ return getById(id);
182
+ }
183
+
160
184
  return {
161
185
  getByUsername,
162
186
  getById,
@@ -166,6 +190,7 @@ function createAdminUsersService(db){
166
190
  changePassword,
167
191
  verify,
168
192
  updateProfile,
193
+ setListPrefs,
169
194
  SESSION_TIMEOUT_OPTIONS_MIN,
170
195
  THEMES
171
196
  };
@@ -13,7 +13,8 @@
13
13
 
14
14
  'use strict';
15
15
 
16
- const { validateJobSpec, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES } = require('@andrian.yablonskyy/thub-common');
16
+ const { validateJobSpec, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES } = require('@andrian.yablonskyy/thub-common'),
17
+ { paginate } = require('./list-prefs');
17
18
 
18
19
  function rowToJob(row){
19
20
  if (!row){
@@ -79,6 +80,44 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
79
80
  return db.prepare(sql).all(...params).map(rowToJob);
80
81
  }
81
82
 
83
+ // Dashboard /jobs (§10): one sorted page plus the total, both in SQL so a
84
+ // long history never has to be loaded whole. `sort` is a list-prefs key.
85
+ const PAGE_SORT_SQL = {
86
+ id: 'jobs.id',
87
+ source: 'jobs.source',
88
+ user: 'json_extract(jobs.spec, \'$.user\')',
89
+ state: 'jobs.state',
90
+ resource: 'COALESCE(r.name, jobs.resource_name)',
91
+ created: 'jobs.created_at',
92
+ duration: 'jobs.duration_sec'
93
+ };
94
+
95
+ function page({ state, source, sort = 'created', dir = 'desc', size = 25, page: pageNo = 1 } = {}){
96
+ const where = [],
97
+ params = [];
98
+ if (state){
99
+ where.push('jobs.state = ?');
100
+ params.push(state);
101
+ }
102
+ if (source){
103
+ where.push('jobs.source = ?');
104
+ params.push(source);
105
+ }
106
+ const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '',
107
+ column = PAGE_SORT_SQL[sort] || PAGE_SORT_SQL.created,
108
+ order = dir === 'asc' ? 'ASC' : 'DESC',
109
+ total = db.prepare(`SELECT COUNT(*) AS n FROM jobs ${whereSql}`).get(...params).n,
110
+ pagination = paginate(total, size, pageNo),
111
+ // Empty values (no user, never ran, …) last either way; created_at
112
+ // breaks ties so paging is stable.
113
+ rows = db.prepare(
114
+ `SELECT jobs.* FROM jobs LEFT JOIN resources r ON r.id = jobs.resource_id ${whereSql}
115
+ ORDER BY ${column} IS NULL, ${column} ${order}, jobs.created_at DESC
116
+ LIMIT ? OFFSET ?`
117
+ ).all(...params, pagination.limit, pagination.offset);
118
+ return { rows: rows.map(rowToJob), pagination };
119
+ }
120
+
82
121
  function countQueuedForAgent(agentId){
83
122
  return db
84
123
  .prepare('SELECT COUNT(*) AS n FROM jobs WHERE agent_id = ? AND state IN (\'QUEUED\',\'ASSIGNED\')')
@@ -363,6 +402,15 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
363
402
  return active.length;
364
403
  }
365
404
 
405
+ // How many jobs Reset queue / Clean history would touch — shown in their
406
+ // confirmation modals on /jobs.
407
+ function countActiveAndFinished(){
408
+ const count = (states) => db
409
+ .prepare(`SELECT COUNT(*) AS n FROM jobs WHERE state IN (${[...states].map(() => '?').join(',')})`)
410
+ .get(...states).n;
411
+ return { active: count(ACTIVE_JOB_STATES), finished: count(TERMINAL_JOB_STATES) };
412
+ }
413
+
366
414
  // Admin: "clean the queue" — permanently delete finished jobs (and their
367
415
  // logs/artifacts, on disk and in the DB), for when the history itself,
368
416
  // not just active work, needs clearing out. Unlike the nightly retention
@@ -406,6 +454,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
406
454
  return {
407
455
  get,
408
456
  list,
457
+ page,
409
458
  create,
410
459
  setState,
411
460
  cancel,
@@ -420,6 +469,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
420
469
  checkAssignAcks,
421
470
  reconcileOnStartup,
422
471
  resetQueue,
472
+ countActiveAndFinished,
423
473
  cleanHistory
424
474
  };
425
475
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/list-prefs.js
3
+ * @description Per-user list view preferences (page size, sort column/direction) for the dashboard's
4
+ * Jobs and Resources pages (README §10.1), plus the pagination math they share
5
+ *
6
+ * @author Andrian Yablonskyy
7
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
8
+ *
9
+ * This file is part of TestHub and is proprietary and confidential.
10
+ * Unauthorized copying, modification, distribution, or use of this file,
11
+ * via any medium, is strictly prohibited without prior written permission
12
+ * from AdSystem.PRO.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ // 'all' shows every record on one page.
18
+ const PAGE_SIZES = [10, 25, 50, 100, 'all'],
19
+ DEFAULT_PAGE_SIZE = 25,
20
+ DIRS = ['asc', 'desc'],
21
+
22
+ // Sortable columns per page, and each page's default order. The keys are
23
+ // what the URL (?sort=) and the stored profile carry; the pages map them
24
+ // to actual columns (jobs.page's SQL, the resources route's comparators).
25
+ LISTS = {
26
+ jobs: {
27
+ sorts: ['id', 'source', 'user', 'state', 'resource', 'created', 'duration'],
28
+ defaults: { size: DEFAULT_PAGE_SIZE, sort: 'created', dir: 'desc' }
29
+ },
30
+ resources: {
31
+ sorts: ['name', 'type', 'version', 'status', 'heartbeat'],
32
+ defaults: { size: DEFAULT_PAGE_SIZE, sort: 'name', dir: 'asc' }
33
+ }
34
+ };
35
+
36
+ function parseSize(value){
37
+ if (value === 'all'){
38
+ return 'all';
39
+ }
40
+ const n = Number(value);
41
+ return PAGE_SIZES.includes(n) ? n : undefined;
42
+ }
43
+
44
+ // A complete, valid {size, sort, dir} for `list`: each field from `prefs`
45
+ // when valid there, else that page's default.
46
+ function normalize(list, prefs = {}){
47
+ const def = LISTS[list];
48
+ if (!def){
49
+ throw Object.assign(new Error(`Unknown list "${list}"`), { status: 400 });
50
+ }
51
+ return {
52
+ size: parseSize(prefs.size) ?? def.defaults.size,
53
+ sort: def.sorts.includes(prefs.sort) ? prefs.sort : def.defaults.sort,
54
+ dir: DIRS.includes(prefs.dir) ? prefs.dir : def.defaults.dir
55
+ };
56
+ }
57
+
58
+ // What a request asks for: the stored prefs, overridden by whichever of
59
+ // ?size / ?sort / ?dir the URL carries (invalid values are ignored).
60
+ function fromQuery(list, query, stored){
61
+ const base = normalize(list, stored),
62
+ wanted = { ...base };
63
+ if (query.size !== undefined && parseSize(query.size) !== undefined){
64
+ wanted.size = parseSize(query.size);
65
+ }
66
+ if (LISTS[list].sorts.includes(query.sort)){
67
+ wanted.sort = query.sort;
68
+ }
69
+ if (DIRS.includes(query.dir)){
70
+ wanted.dir = query.dir;
71
+ }
72
+ return { prefs: wanted, changed: ['size', 'sort', 'dir'].some((k) => wanted[k] !== base[k]) };
73
+ }
74
+
75
+ // Page window for `total` records: `page` is 1-based and clamped into range.
76
+ function paginate(total, size, page){
77
+ const limit = size === 'all' ? Math.max(total, 1) : size,
78
+ pages = Math.max(1, Math.ceil(total / limit)),
79
+ current = Math.min(Math.max(1, Number.parseInt(page, 10) || 1), pages),
80
+ offset = (current - 1) * limit;
81
+ return {
82
+ page: current,
83
+ pages,
84
+ total,
85
+ offset,
86
+ limit,
87
+ from: total ? offset + 1 : 0,
88
+ to: Math.min(offset + limit, total)
89
+ };
90
+ }
91
+
92
+ module.exports = { PAGE_SIZES, DEFAULT_PAGE_SIZE, LISTS, normalize, fromQuery, paginate };
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
 
@@ -413,22 +464,25 @@ function createWebRouter({ services, config }){
413
464
 
414
465
  router.get('/jobs', (req, res) => {
415
466
  const filters = { state: req.query.state || undefined, source: req.query.source || undefined },
416
- 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) => ({
417
470
  ...j,
418
471
  resource: j.resource_id ? services.registry.get(j.resource_id) : null,
419
472
  active: ACTIVE_JOB_STATES.has(j.state)
420
- }));
473
+ })),
474
+ pageUrl = (changes) => view.urlFor({ page: pagination.page, ...changes });
421
475
  res.render('jobs/list', {
422
476
  title: 'Jobs',
423
477
  active: 'jobs',
424
478
  jobs,
425
479
  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
- })(),
431
- 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
432
486
  });
433
487
  });
434
488
 
@@ -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
+ });
@@ -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,21 @@ 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 Duration
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')
36
40
  if canManage
37
41
  th
38
42
  tbody
@@ -54,6 +58,52 @@ block content
54
58
  data-confirm=`Cancel job ${j.id}?`
55
59
  onsubmit="return confirm(this.dataset.confirm)"
56
60
  )
57
- input(type="hidden" name="returnTo" value=`/jobs${filterQuery}`)
61
+ input(type="hidden" name="returnTo" value=returnTo)
58
62
  button.btn.btn-sm.btn-outline-danger(type="submit" aria-label="Cancel" title="Cancel this job")
59
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.
@@ -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
@@ -2,26 +2,31 @@ extends ../layout
2
2
  include ../mixins/status-badge
3
3
  include ../mixins/resource
4
4
  include ../mixins/updates
5
+ include ../mixins/list-controls
5
6
 
6
7
  block content
7
8
  h1.h4.mb-3 Resources
8
9
  p.text-body-secondary.small.
9
10
  Resources register themselves — start a Client with the shared join key and it appears here. Click a resource for its details, including IP addresses.
10
11
 
11
- .mb-3
12
+ .d-flex.flex-wrap.align-items-center.gap-2.mb-3
12
13
  +updatesToolbar('client', 'clients', '/resources')
14
+ .ms-auto
15
+ +pageSizePicker(list)
16
+
17
+ +listToolbar(list, 'resources')
13
18
 
14
19
  table.table.table-hover.align-middle
15
20
  thead
16
21
  tr
17
- th Name
18
- th Type
19
- th Version
20
- th Status
22
+ +sortHeader(list, 'name', 'Name')
23
+ +sortHeader(list, 'type', 'Type')
24
+ +sortHeader(list, 'version', 'Version', 'desc')
25
+ +sortHeader(list, 'status', 'Status')
21
26
  th Labels
22
27
  th Groups
23
28
  th Busy source / reason
24
- th Last heartbeat
29
+ +sortHeader(list, 'heartbeat', 'Last heartbeat', 'desc')
25
30
  if user.role === 'admin'
26
31
  th Actions
27
32
  tbody
@@ -44,10 +49,10 @@ block content
44
49
  td= fmtDate(r.last_heartbeat_at, 'never')
45
50
  if user.role === 'admin'
46
51
  td
47
- +resourceActions(r, '/resources')
52
+ +resourceActions(r, list.pageUrl())
48
53
 
49
54
  each r in resources
50
- +resourceCard(r, groupsById, '/resources')
55
+ +resourceCard(r, groupsById, list.pageUrl())
51
56
 
52
57
  if user && user.role === 'admin'
53
58
  +removeResourceModal()