@andrian.yablonskyy/thub-coordinator 1.0.18 → 1.0.20
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/css/thub.css +24 -0
- package/public/js/toasts.js +17 -1
- package/public/js/update-watch.js +54 -0
- package/scripts/install-systemd-unit.js +1 -0
- package/scripts/self-update-helper.js +49 -3
- package/src/db/migrations/016_add_list_prefs.sql +5 -0
- package/src/services/admin-users.js +27 -2
- package/src/services/jobs.js +51 -1
- package/src/services/list-prefs.js +92 -0
- package/src/services/updates.js +76 -11
- package/src/web/routes.js +95 -17
- package/systemd/thub-coordinator-update.path +6 -3
- package/test/list-prefs.test.js +89 -0
- package/test/updates.test.js +106 -0
- package/views/jobs/list.pug +62 -12
- package/views/layout.pug +16 -3
- package/views/mixins/confirm-modal.pug +26 -0
- package/views/mixins/list-controls.pug +61 -0
- package/views/resources/list.pug +13 -8
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.20",
|
|
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": {
|
package/public/css/thub.css
CHANGED
|
@@ -4,3 +4,27 @@
|
|
|
4
4
|
white-space: pre-wrap;
|
|
5
5
|
word-break: break-all;
|
|
6
6
|
}
|
|
7
|
+
|
|
8
|
+
/* Toast auto-close countdown (public/js/toasts.js): a thin bar along the
|
|
9
|
+
bottom that shrinks over the toast's delay; the toast closes when it
|
|
10
|
+
runs out. Hovering or focusing the toast pauses it. currentColor keeps
|
|
11
|
+
it readable on every text-bg-* variant. */
|
|
12
|
+
.thub-toast-progress {
|
|
13
|
+
position: absolute;
|
|
14
|
+
inset: auto 0 0 0;
|
|
15
|
+
height: 3px;
|
|
16
|
+
background: currentColor;
|
|
17
|
+
opacity: 0.45;
|
|
18
|
+
transform-origin: left center;
|
|
19
|
+
animation: thub-toast-countdown var(--thub-toast-delay, 10s) linear forwards;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.toast:hover .thub-toast-progress,
|
|
23
|
+
.toast:focus-within .thub-toast-progress {
|
|
24
|
+
animation-play-state: paused;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@keyframes thub-toast-countdown {
|
|
28
|
+
from { transform: scaleX(1); }
|
|
29
|
+
to { transform: scaleX(0); }
|
|
30
|
+
}
|
package/public/js/toasts.js
CHANGED
|
@@ -22,12 +22,28 @@
|
|
|
22
22
|
document.querySelectorAll('[data-thub-toast]').forEach((el) => {
|
|
23
23
|
const type = el.dataset.thubToast in DELAY_MS ? el.dataset.thubToast : 'info',
|
|
24
24
|
delay = el.dataset.sticky ? null : DELAY_MS[type],
|
|
25
|
-
|
|
25
|
+
// Our own countdown instead of Bootstrap's autohide: that one restarts
|
|
26
|
+
// the full delay after every hover, which a progress bar can't show.
|
|
27
|
+
toast = new bootstrap.Toast(el, { autohide: false });
|
|
26
28
|
// "Self-destroying": gone from the page once hidden, not just invisible.
|
|
27
29
|
el.addEventListener('hidden.bs.toast', () => {
|
|
28
30
|
toast.dispose();
|
|
29
31
|
el.remove();
|
|
30
32
|
});
|
|
33
|
+
|
|
34
|
+
// The bar is the timer: a CSS animation over `delay` (paused while the
|
|
35
|
+
// toast is hovered or focused, see thub.css) — the toast closes when
|
|
36
|
+
// it ends. Added here, not in the markup, so without this script no
|
|
37
|
+
// bar suggests a close that would never happen.
|
|
38
|
+
if (delay !== null){
|
|
39
|
+
const bar = document.createElement('div');
|
|
40
|
+
bar.className = 'thub-toast-progress';
|
|
41
|
+
bar.setAttribute('aria-hidden', 'true');
|
|
42
|
+
bar.style.setProperty('--thub-toast-delay', `${delay}ms`);
|
|
43
|
+
bar.addEventListener('animationend', () => toast.hide(), { once: true });
|
|
44
|
+
el.classList.add('position-relative', 'overflow-hidden');
|
|
45
|
+
el.appendChild(bar);
|
|
46
|
+
}
|
|
31
47
|
toast.show();
|
|
32
48
|
});
|
|
33
49
|
})();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/public/js/update-watch.js
|
|
3
|
+
* @description Dashboard: while a Coordinator self-update is pending, polls its status and reloads the page when done
|
|
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 marker = document.getElementById('coordinator-update-pending');
|
|
18
|
+
if (!marker){
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const currentVersion = marker.dataset.currentVersion,
|
|
22
|
+
POLL_MS = 3000;
|
|
23
|
+
|
|
24
|
+
// Reload once the Coordinator runs another version (the update is done),
|
|
25
|
+
// or no longer has one pending (it failed — the reloaded page says why).
|
|
26
|
+
// While it restarts, requests fail or hit the proxy's error page: keep
|
|
27
|
+
// polling. A reply that isn't our JSON (e.g. the login page, sessions
|
|
28
|
+
// having been reset by the restart) also means it's back — reload.
|
|
29
|
+
async function poll(){
|
|
30
|
+
let res;
|
|
31
|
+
try {
|
|
32
|
+
res = await fetch('/updates/status', { cache: 'no-store', credentials: 'same-origin', headers: { Accept: 'application/json' } });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return setTimeout(poll, POLL_MS); // down mid-restart
|
|
36
|
+
}
|
|
37
|
+
if (!res.ok){
|
|
38
|
+
return setTimeout(poll, POLL_MS); // e.g. 502 from the reverse proxy
|
|
39
|
+
}
|
|
40
|
+
let status;
|
|
41
|
+
try {
|
|
42
|
+
status = await res.json();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return location.reload();
|
|
46
|
+
}
|
|
47
|
+
if (status.coordinatorVersion !== currentVersion || !status.pending){
|
|
48
|
+
return location.reload();
|
|
49
|
+
}
|
|
50
|
+
setTimeout(poll, POLL_MS);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setTimeout(poll, POLL_MS);
|
|
54
|
+
})();
|
|
@@ -48,6 +48,7 @@ function renderUpdateUnits(user, paths){
|
|
|
48
48
|
unitPath = (name) => path.join(__dirname, '..', 'systemd', name);
|
|
49
49
|
return {
|
|
50
50
|
[UPDATE_PATH_UNIT]: fs.readFileSync(unitPath(UPDATE_PATH_UNIT), 'utf8')
|
|
51
|
+
.replace(/^PathExists=.*$/m, `PathExists=${paths.updateRequestFile}`)
|
|
51
52
|
.replace(/^PathModified=.*$/m, `PathModified=${paths.updateRequestFile}`),
|
|
52
53
|
// npm is a `#!/usr/bin/env node` script, so this node goes first on PATH.
|
|
53
54
|
[UPDATE_SERVICE_UNIT]: fs.readFileSync(unitPath(UPDATE_SERVICE_UNIT), 'utf8')
|
|
@@ -15,9 +15,33 @@
|
|
|
15
15
|
'use strict';
|
|
16
16
|
|
|
17
17
|
const fs = require('node:fs'),
|
|
18
|
+
path = require('node:path'),
|
|
19
|
+
{ execFileSync } = require('node:child_process'),
|
|
18
20
|
{ PACKAGES, isValidVersion, compareVersions, npmInstallGlobal } = require('@andrian.yablonskyy/thub-common'),
|
|
19
21
|
{ version: installedVersion } = require('../package.json');
|
|
20
22
|
|
|
23
|
+
// Progress for the dashboard (services/updates.js reads it), next to the
|
|
24
|
+
// request: {version, state, message, at} with state installing |
|
|
25
|
+
// restarting | failed. Without it the dashboard can't tell a failed or
|
|
26
|
+
// never-started update from one still running, and waits forever.
|
|
27
|
+
let statusFile = null;
|
|
28
|
+
function report(version, state, message = null){
|
|
29
|
+
if (!statusFile){
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
fs.writeFileSync(statusFile, JSON.stringify({ version, state, message, at: new Date().toISOString() }) + '\n', { mode: 0o644 });
|
|
34
|
+
}
|
|
35
|
+
catch (err){
|
|
36
|
+
console.error(`thub-coordinator-update: could not write ${statusFile}: ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The request only ever comes from a Coordinator older than `version`.
|
|
41
|
+
function restartCoordinator(){
|
|
42
|
+
execFileSync('systemctl', ['restart', 'thub-coordinator.service'], { stdio: 'inherit' });
|
|
43
|
+
}
|
|
44
|
+
|
|
21
45
|
// argv: <request file> <user the Coordinator runs as>. The request file
|
|
22
46
|
// is written by the (unprivileged) Coordinator, so only its `version` is
|
|
23
47
|
// used, and only if it's a plain semver — the package is fixed.
|
|
@@ -27,6 +51,7 @@ function main(){
|
|
|
27
51
|
console.error('usage: self-update-helper.js <request file> <user>');
|
|
28
52
|
return 2;
|
|
29
53
|
}
|
|
54
|
+
statusFile = path.join(path.dirname(requestFile), 'update-status.json');
|
|
30
55
|
|
|
31
56
|
let request;
|
|
32
57
|
try {
|
|
@@ -46,20 +71,41 @@ function main(){
|
|
|
46
71
|
const target = request?.version;
|
|
47
72
|
if (!isValidVersion(target)){
|
|
48
73
|
console.error('thub-coordinator-update: ignoring request with an invalid version');
|
|
74
|
+
report(String(target), 'failed', 'The update request had an invalid version.');
|
|
49
75
|
return 1;
|
|
50
76
|
}
|
|
77
|
+
// Already installed on disk (e.g. a manual `npm i -g` whose restart never
|
|
78
|
+
// happened) while the running Coordinator is older — it asked, after all.
|
|
79
|
+
// Just restart it onto what's installed instead of leaving it waiting.
|
|
51
80
|
if (compareVersions(installedVersion, target) >= 0){
|
|
52
|
-
console.log(`thub-coordinator-update:
|
|
53
|
-
|
|
81
|
+
console.log(`thub-coordinator-update: v${installedVersion} is already installed (requested v${target}) — restarting the Coordinator onto it`);
|
|
82
|
+
report(target, 'restarting');
|
|
83
|
+
try {
|
|
84
|
+
restartCoordinator();
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
catch (err){
|
|
88
|
+
report(target, 'failed', `v${installedVersion} is installed, but restarting the Coordinator failed: ${err.message}`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
54
91
|
}
|
|
55
92
|
|
|
56
93
|
console.log(`thub-coordinator-update: v${installedVersion} -> v${target} (requested by ${request.requestedBy || 'the dashboard'})`);
|
|
94
|
+
report(target, 'installing');
|
|
57
95
|
// SUDO_USER makes the postinstall scripts target the Coordinator's user,
|
|
58
96
|
// as a `sudo npm i -g` by that user would (install-target.js); the
|
|
59
97
|
// postinstall then restarts thub-coordinator on the new version.
|
|
60
|
-
|
|
98
|
+
let status;
|
|
99
|
+
try {
|
|
100
|
+
status = npmInstallGlobal(PACKAGES.coordinator, target, { env: { ...process.env, SUDO_USER: user } });
|
|
101
|
+
}
|
|
102
|
+
catch (err){
|
|
103
|
+
report(target, 'failed', `Could not run npm: ${err.message}`);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
61
106
|
if (status !== 0){
|
|
62
107
|
console.error(`thub-coordinator-update: npm i -g failed (exit ${status})`);
|
|
108
|
+
report(target, 'failed', `npm i -g failed (exit ${status}) — see: journalctl -u thub-coordinator-update`);
|
|
63
109
|
}
|
|
64
110
|
return status;
|
|
65
111
|
}
|
|
@@ -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
|
};
|
package/src/services/jobs.js
CHANGED
|
@@ -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/services/updates.js
CHANGED
|
@@ -15,21 +15,82 @@
|
|
|
15
15
|
|
|
16
16
|
const fs = require('node:fs'),
|
|
17
17
|
path = require('node:path'),
|
|
18
|
-
{ PACKAGES, fetchLatestVersion, defaultRegistry, isNewer } = require('@andrian.yablonskyy/thub-common'),
|
|
18
|
+
{ PACKAGES, fetchLatestVersion, defaultRegistry, isNewer, compareVersions } = require('@andrian.yablonskyy/thub-common'),
|
|
19
19
|
{ version: coordinatorVersion } = require('../../package.json');
|
|
20
20
|
|
|
21
21
|
// Installed by scripts/install-systemd-unit.js; runs the actual `npm i -g`
|
|
22
22
|
// as root when the dashboard writes an update request (README §10.2).
|
|
23
|
-
const UPDATE_PATH_UNIT = '/etc/systemd/system/thub-coordinator-update.path'
|
|
23
|
+
const UPDATE_PATH_UNIT = '/etc/systemd/system/thub-coordinator-update.path',
|
|
24
|
+
// No sign of the root helper by then: the request wasn't picked up.
|
|
25
|
+
PICKUP_TIMEOUT_MS = 60_000,
|
|
26
|
+
// Longer than thub-coordinator-update.service's TimeoutStartSec (15 min).
|
|
27
|
+
INSTALL_TIMEOUT_MS = 20 * 60_000;
|
|
24
28
|
|
|
25
29
|
// Latest published versions, refreshed every `updates.checkIntervalMin`
|
|
26
30
|
// and on demand from the dashboard. Kept in memory only — a restart just
|
|
27
31
|
// checks again.
|
|
28
|
-
|
|
32
|
+
// `pathUnit`, `fetchLatest` and `now` are overridable for tests only.
|
|
33
|
+
function createUpdatesService({ config, pathUnit = UPDATE_PATH_UNIT, fetchLatest = fetchLatestVersion, now = Date.now }){
|
|
29
34
|
const registry = config.updates.registry || defaultRegistry(),
|
|
30
|
-
state = { latest: {}, checkedAt: null, error: null, coordinatorPending: null }
|
|
35
|
+
state = { latest: {}, checkedAt: null, error: null, coordinatorPending: null, coordinatorRequestedAt: null, coordinatorUpdateError: null },
|
|
36
|
+
requestFile = path.join(config.dataDir, 'update-request.json'),
|
|
37
|
+
// Written by the root helper (scripts/self-update-helper.js).
|
|
38
|
+
statusFile = path.join(config.dataDir, 'update-status.json');
|
|
31
39
|
let inFlight = null;
|
|
32
40
|
|
|
41
|
+
function readHelperStatus(){
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(fs.readFileSync(statusFile, 'utf8'));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function failPending(message){
|
|
51
|
+
state.coordinatorUpdateError = { version: state.coordinatorPending, message, at: new Date().toISOString() };
|
|
52
|
+
state.coordinatorPending = null;
|
|
53
|
+
state.coordinatorRequestedAt = null;
|
|
54
|
+
console.error(`Coordinator self-update failed: ${message}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A pending update ends with this process being restarted onto the new
|
|
58
|
+
// version. Anything else — the helper reporting failure, never picking
|
|
59
|
+
// the request up, or taking far too long — must end the wait too, or the
|
|
60
|
+
// dashboard shows "Updating…" forever.
|
|
61
|
+
function refreshPending(){
|
|
62
|
+
if (!state.coordinatorPending){
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const requestedAt = Date.parse(state.coordinatorRequestedAt),
|
|
66
|
+
helper = readHelperStatus(),
|
|
67
|
+
fresh = helper && Date.parse(helper.at) >= requestedAt - 1000;
|
|
68
|
+
if (fresh && helper.state === 'failed'){
|
|
69
|
+
failPending(helper.message || 'The update helper reported a failure — see: journalctl -u thub-coordinator-update');
|
|
70
|
+
}
|
|
71
|
+
else if (!fresh && now() - requestedAt > PICKUP_TIMEOUT_MS){
|
|
72
|
+
failPending(
|
|
73
|
+
fs.existsSync(requestFile)
|
|
74
|
+
? `The update request wasn't picked up within ${PICKUP_TIMEOUT_MS / 1000} s — is the watcher running? ` +
|
|
75
|
+
'sudo systemctl enable --now thub-coordinator-update.path'
|
|
76
|
+
: 'The update helper started but never reported progress — see: journalctl -u thub-coordinator-update'
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
else if (fresh && now() - Date.parse(helper.at) > INSTALL_TIMEOUT_MS){
|
|
80
|
+
failPending(`The update has been "${helper.state}" for over ${INSTALL_TIMEOUT_MS / 60_000} min — see: journalctl -u thub-coordinator-update`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// On startup: a helper status for this version (or older) means the
|
|
85
|
+
// update that restarted us is done — clear it.
|
|
86
|
+
function settleHelperStatus(){
|
|
87
|
+
const helper = readHelperStatus();
|
|
88
|
+
if (helper && helper.state !== 'failed' && compareVersions(coordinatorVersion, String(helper.version)) >= 0){
|
|
89
|
+
fs.rmSync(statusFile, { force: true });
|
|
90
|
+
console.log(`Coordinator self-update to v${helper.version} completed`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
33
94
|
async function checkNow(){
|
|
34
95
|
// Concurrent callers (timer + a dashboard click) share one check.
|
|
35
96
|
inFlight = inFlight || (async () => {
|
|
@@ -37,7 +98,7 @@ function createUpdatesService({ config }){
|
|
|
37
98
|
errors = [];
|
|
38
99
|
await Promise.all(Object.entries(PACKAGES).map(async ([app, pkg]) => {
|
|
39
100
|
try {
|
|
40
|
-
latest[app] = await
|
|
101
|
+
latest[app] = await fetchLatest(pkg, { registry });
|
|
41
102
|
}
|
|
42
103
|
catch (err){
|
|
43
104
|
errors.push(err.message);
|
|
@@ -55,6 +116,7 @@ function createUpdatesService({ config }){
|
|
|
55
116
|
}
|
|
56
117
|
|
|
57
118
|
function status(){
|
|
119
|
+
refreshPending();
|
|
58
120
|
return {
|
|
59
121
|
...state,
|
|
60
122
|
coordinatorVersion,
|
|
@@ -70,17 +132,19 @@ function createUpdatesService({ config }){
|
|
|
70
132
|
if (!target){
|
|
71
133
|
throw Object.assign(new Error(`Already on the latest version (v${coordinatorVersion}).`), { status: 409 });
|
|
72
134
|
}
|
|
73
|
-
if (!fs.existsSync(
|
|
135
|
+
if (!fs.existsSync(pathUnit)){
|
|
74
136
|
throw Object.assign(
|
|
75
|
-
new Error(`Self-update isn't installed on this host (${
|
|
137
|
+
new Error(`Self-update isn't installed on this host (${pathUnit}) — update by hand: thub-admin self-update`),
|
|
76
138
|
{ status: 501 }
|
|
77
139
|
);
|
|
78
140
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
);
|
|
141
|
+
const requestedAt = new Date(now()).toISOString();
|
|
142
|
+
// A previous attempt's result mustn't be mistaken for this one's.
|
|
143
|
+
fs.rmSync(statusFile, { force: true });
|
|
144
|
+
fs.writeFileSync(requestFile, JSON.stringify({ version: target, requestedBy, requestedAt }) + '\n');
|
|
83
145
|
state.coordinatorPending = target;
|
|
146
|
+
state.coordinatorRequestedAt = requestedAt;
|
|
147
|
+
state.coordinatorUpdateError = null;
|
|
84
148
|
return target;
|
|
85
149
|
}
|
|
86
150
|
|
|
@@ -97,6 +161,7 @@ function createUpdatesService({ config }){
|
|
|
97
161
|
}
|
|
98
162
|
|
|
99
163
|
function start(){
|
|
164
|
+
settleHelperStatus();
|
|
100
165
|
const minutes = Number(config.updates.checkIntervalMin);
|
|
101
166
|
if (!(minutes > 0)){
|
|
102
167
|
return;
|
package/src/web/routes.js
CHANGED
|
@@ -20,12 +20,25 @@ const fs = require('node:fs'),
|
|
|
20
20
|
multer = require('multer'),
|
|
21
21
|
{ requireAdminSession, requireAdminRole } = require('../auth'),
|
|
22
22
|
{ attachJobStream } = require('../api/sse'),
|
|
23
|
-
{ RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES, isNewer } = require('@andrian.yablonskyy/thub-common')
|
|
23
|
+
{ RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES, isNewer, compareVersions } = require('@andrian.yablonskyy/thub-common'),
|
|
24
|
+
listPrefs = require('../services/list-prefs');
|
|
25
|
+
|
|
26
|
+
// Resources page sort keys (list-prefs.js) -> comparators. Empty values
|
|
27
|
+
// (never heartbeated, no version reported) sort last in both directions.
|
|
28
|
+
const RESOURCE_SORT_VALUE = {
|
|
29
|
+
name: (r) => r.name,
|
|
30
|
+
type: (r) => r.type,
|
|
31
|
+
version: (r) => r.client_version,
|
|
32
|
+
status: (r) => r.status,
|
|
33
|
+
heartbeat: (r) => r.last_heartbeat_at
|
|
34
|
+
},
|
|
35
|
+
compareResourceValues = (sort, a, b) =>
|
|
36
|
+
sort === 'version' ? compareVersions(a, b) : String(a).localeCompare(String(b), undefined, { numeric: true }),
|
|
24
37
|
|
|
25
|
-
// §10.1: avatar uploads are small, single images — a hard size cap and an
|
|
26
|
-
// allow-list of image mimetypes, same spirit as the join-key/token checks
|
|
27
|
-
// elsewhere (reject outright rather than trying to sanitize).
|
|
28
|
-
|
|
38
|
+
// §10.1: avatar uploads are small, single images — a hard size cap and an
|
|
39
|
+
// allow-list of image mimetypes, same spirit as the join-key/token checks
|
|
40
|
+
// elsewhere (reject outright rather than trying to sanitize).
|
|
41
|
+
AVATAR_MAX_BYTES = 2 * 1024 * 1024,
|
|
29
42
|
AVATAR_EXT_BY_MIMETYPE = {
|
|
30
43
|
'image/png': 'png',
|
|
31
44
|
'image/jpeg': 'jpg',
|
|
@@ -55,6 +68,30 @@ function createWebRouter({ services, config }){
|
|
|
55
68
|
return typeof target === 'string' && /^\/(?![\/\\])/.test(target) ? target : fallback;
|
|
56
69
|
}
|
|
57
70
|
|
|
71
|
+
// A list page's view (§10.1): the user's stored size/sort/dir, overridden
|
|
72
|
+
// by ?size/?sort/?dir — and a change is saved straight to their profile,
|
|
73
|
+
// so it's what they get next time on any device. ?page isn't saved.
|
|
74
|
+
function listView(req, res, list, extraQuery = {}){
|
|
75
|
+
const user = req.session.user,
|
|
76
|
+
{ prefs, changed } = listPrefs.fromQuery(list, req.query, user.listPrefs?.[list]);
|
|
77
|
+
if (changed){
|
|
78
|
+
req.session.user = services.adminUsers.setListPrefs(user.id, list, prefs);
|
|
79
|
+
res.locals.user = req.session.user;
|
|
80
|
+
}
|
|
81
|
+
const base = { ...extraQuery, size: prefs.size, sort: prefs.sort, dir: prefs.dir },
|
|
82
|
+
// Link to this list with some params changed; changing size or sort
|
|
83
|
+
// restarts at page 1. Empty params are dropped.
|
|
84
|
+
urlFor = (changes = {}) => {
|
|
85
|
+
const q = { ...base, ...changes };
|
|
86
|
+
if (('size' in changes || 'sort' in changes) && !('page' in changes)){
|
|
87
|
+
delete q.page;
|
|
88
|
+
}
|
|
89
|
+
const qs = new URLSearchParams(Object.entries(q).filter(([, v]) => v !== undefined && v !== null && v !== '')).toString();
|
|
90
|
+
return `${req.baseUrl}${req.path}${qs ? `?${qs}` : ''}`;
|
|
91
|
+
};
|
|
92
|
+
return { prefs, urlFor, pageSizes: listPrefs.PAGE_SIZES };
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
// The job each resource is on (if any), for the resource card's Cancel.
|
|
59
96
|
function withActiveJobs(resources){
|
|
60
97
|
return resources.map((r) => ({ ...r, activeJob: services.jobs.activeForResource(r.id) }));
|
|
@@ -66,6 +103,18 @@ function createWebRouter({ services, config }){
|
|
|
66
103
|
res.locals.messages = req.session?.flash || [];
|
|
67
104
|
// Latest published versions (README §10.2) — navbar, Agents, Resources.
|
|
68
105
|
res.locals.updates = services.updates.status();
|
|
106
|
+
// A failed Coordinator self-update: shown to each admin once, as an
|
|
107
|
+
// error toast (never auto-closes) — on a page view, not the polling
|
|
108
|
+
// endpoint or a POST whose redirect would drop it.
|
|
109
|
+
const updateError = res.locals.updates.coordinatorUpdateError;
|
|
110
|
+
if (updateError && user?.role === 'admin' && req.method === 'GET' && req.path !== '/updates/status' &&
|
|
111
|
+
req.session.seenUpdateError !== updateError.at){
|
|
112
|
+
req.session.seenUpdateError = updateError.at;
|
|
113
|
+
res.locals.messages = [
|
|
114
|
+
...res.locals.messages,
|
|
115
|
+
{ type: 'danger', text: `Coordinator update to v${updateError.version} failed: ${updateError.message}` }
|
|
116
|
+
];
|
|
117
|
+
}
|
|
69
118
|
res.locals.isNewer = isNewer;
|
|
70
119
|
res.locals.currentPath = req.originalUrl;
|
|
71
120
|
if (req.session){
|
|
@@ -231,12 +280,26 @@ function createWebRouter({ services, config }){
|
|
|
231
280
|
});
|
|
232
281
|
|
|
233
282
|
router.get('/resources', (req, res) => {
|
|
234
|
-
const groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g]))
|
|
283
|
+
const groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g])),
|
|
284
|
+
view = listView(req, res, 'resources'),
|
|
285
|
+
{ sort, dir, size } = view.prefs,
|
|
286
|
+
value = RESOURCE_SORT_VALUE[sort],
|
|
287
|
+
sorted = services.registry.list().sort((a, b) => {
|
|
288
|
+
const va = value(a),
|
|
289
|
+
vb = value(b);
|
|
290
|
+
if (va == null || vb == null){
|
|
291
|
+
return (va == null) - (vb == null);
|
|
292
|
+
}
|
|
293
|
+
return (dir === 'asc' ? 1 : -1) * compareResourceValues(sort, va, vb) || a.name.localeCompare(b.name);
|
|
294
|
+
}),
|
|
295
|
+
pagination = listPrefs.paginate(sorted.length, size, req.query.page),
|
|
296
|
+
pageUrl = (changes) => view.urlFor({ page: pagination.page, ...changes });
|
|
235
297
|
res.render('resources/list', {
|
|
236
298
|
title: 'Resources',
|
|
237
299
|
active: 'resources',
|
|
238
|
-
resources: withActiveJobs(
|
|
239
|
-
groupsById
|
|
300
|
+
resources: withActiveJobs(sorted.slice(pagination.offset, pagination.offset + pagination.limit)),
|
|
301
|
+
groupsById,
|
|
302
|
+
list: { ...view, pagination, pageUrl }
|
|
240
303
|
});
|
|
241
304
|
});
|
|
242
305
|
|
|
@@ -376,9 +439,21 @@ function createWebRouter({ services, config }){
|
|
|
376
439
|
router.post('/updates/coordinator', requireAdminRole, updateAction(async (req) => {
|
|
377
440
|
const version = services.updates.requestCoordinatorUpdate(req.session.user.username);
|
|
378
441
|
console.log(`Coordinator self-update to v${version} requested by ${req.session.user.username}`);
|
|
379
|
-
return `Updating the Coordinator to v${version} —
|
|
442
|
+
return `Updating the Coordinator to v${version} — this page reloads by itself once it's back up.`;
|
|
380
443
|
}));
|
|
381
444
|
|
|
445
|
+
// Polled by public/js/update-watch.js while a Coordinator update is
|
|
446
|
+
// pending: a new `coordinatorVersion` (the restart happened) or a cleared
|
|
447
|
+
// `pending` (it failed) tells the page to reload.
|
|
448
|
+
router.get('/updates/status', (req, res) => {
|
|
449
|
+
const s = services.updates.status();
|
|
450
|
+
res.set('Cache-Control', 'no-store').json({
|
|
451
|
+
coordinatorVersion: s.coordinatorVersion,
|
|
452
|
+
pending: s.coordinatorPending,
|
|
453
|
+
error: s.coordinatorUpdateError
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
382
457
|
router.post('/resources/update-all', requireAdminRole, updateAction(async () => {
|
|
383
458
|
const version = await services.updates.targetVersion('client'),
|
|
384
459
|
n = services.registry.requestUpdateAll(version);
|
|
@@ -413,22 +488,25 @@ function createWebRouter({ services, config }){
|
|
|
413
488
|
|
|
414
489
|
router.get('/jobs', (req, res) => {
|
|
415
490
|
const filters = { state: req.query.state || undefined, source: req.query.source || undefined },
|
|
416
|
-
|
|
491
|
+
view = listView(req, res, 'jobs', filters),
|
|
492
|
+
{ rows, pagination } = services.jobs.page({ ...filters, ...view.prefs, page: req.query.page }),
|
|
493
|
+
jobs = rows.map((j) => ({
|
|
417
494
|
...j,
|
|
418
495
|
resource: j.resource_id ? services.registry.get(j.resource_id) : null,
|
|
419
496
|
active: ACTIVE_JOB_STATES.has(j.state)
|
|
420
|
-
}))
|
|
497
|
+
})),
|
|
498
|
+
pageUrl = (changes) => view.urlFor({ page: pagination.page, ...changes });
|
|
421
499
|
res.render('jobs/list', {
|
|
422
500
|
title: 'Jobs',
|
|
423
501
|
active: 'jobs',
|
|
424
502
|
jobs,
|
|
425
503
|
filters,
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
504
|
+
list: { ...view, pagination, pageUrl },
|
|
505
|
+
// A row's Cancel comes back to this exact view (filters, sort, page).
|
|
506
|
+
returnTo: pageUrl(),
|
|
507
|
+
canManage: req.session.user.role === 'admin',
|
|
508
|
+
// For the Reset queue / Clean history confirmation modals.
|
|
509
|
+
jobCounts: req.session.user.role === 'admin' ? services.jobs.countActiveAndFinished() : null
|
|
432
510
|
});
|
|
433
511
|
});
|
|
434
512
|
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
[Unit]
|
|
2
2
|
Description=TestHub Coordinator self-update request watcher
|
|
3
3
|
|
|
4
|
-
# PathModified=
|
|
5
|
-
# update-request.json in the Coordinator's dataDir. The dashboard's
|
|
4
|
+
# PathExists=/PathModified= are rewritten by scripts/install-systemd-unit.js
|
|
5
|
+
# to the update-request.json in the Coordinator's dataDir. The dashboard's
|
|
6
6
|
# "Update app" button writes it; this starts thub-coordinator-update.service
|
|
7
|
-
# (as root) to install the requested version.
|
|
7
|
+
# (as root) to install the requested version. PathExists= also catches a
|
|
8
|
+
# request written while this watch wasn't active (e.g. mid-reinstall) — the
|
|
9
|
+
# helper deletes the file, so it doesn't re-trigger.
|
|
8
10
|
[Path]
|
|
11
|
+
PathExists=/var/lib/thub/.thub/update-request.json
|
|
9
12
|
PathModified=/var/lib/thub/.thub/update-request.json
|
|
10
13
|
Unit=thub-coordinator-update.service
|
|
11
14
|
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/updates.test.js
|
|
3
|
+
* @description Tests: Coordinator self-update request lifecycle — the dashboard never waits forever on a stuck update
|
|
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
|
+
fs = require('node:fs'),
|
|
19
|
+
os = require('node:os'),
|
|
20
|
+
path = require('node:path'),
|
|
21
|
+
{ createUpdatesService } = require('../src/services/updates'),
|
|
22
|
+
{ version: installed } = require('../package.json');
|
|
23
|
+
|
|
24
|
+
const NEWER = installed.replace(/\d+$/, (n) => String(Number(n) + 1));
|
|
25
|
+
|
|
26
|
+
async function setup(){
|
|
27
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-updates-')),
|
|
28
|
+
pathUnit = path.join(dataDir, 'fake.path'),
|
|
29
|
+
clock = { t: Date.parse('2026-09-28T12:00:00Z') };
|
|
30
|
+
fs.writeFileSync(pathUnit, '');
|
|
31
|
+
const updates = createUpdatesService({
|
|
32
|
+
config: { dataDir, updates: {} },
|
|
33
|
+
pathUnit,
|
|
34
|
+
fetchLatest: async () => NEWER,
|
|
35
|
+
now: () => clock.t
|
|
36
|
+
});
|
|
37
|
+
await updates.checkNow();
|
|
38
|
+
const helperSays = (state, message) => fs.writeFileSync(
|
|
39
|
+
path.join(dataDir, 'update-status.json'),
|
|
40
|
+
JSON.stringify({ version: NEWER, state, message, at: new Date(clock.t).toISOString() })
|
|
41
|
+
);
|
|
42
|
+
return { updates, dataDir, clock, helperSays };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('a request is written for the root helper and shows as pending', async () => {
|
|
46
|
+
const { updates, dataDir } = await setup();
|
|
47
|
+
assert.equal(updates.requestCoordinatorUpdate('alice'), NEWER);
|
|
48
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(dataDir, 'update-request.json'), 'utf8')).version, NEWER);
|
|
49
|
+
assert.equal(updates.status().coordinatorPending, NEWER);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a request nobody picks up stops pending after the pickup timeout, with the reason', async () => {
|
|
53
|
+
const { updates, clock } = await setup();
|
|
54
|
+
updates.requestCoordinatorUpdate('alice');
|
|
55
|
+
clock.t += 30_000;
|
|
56
|
+
assert.equal(updates.status().coordinatorPending, NEWER); // still within the timeout
|
|
57
|
+
clock.t += 31_000;
|
|
58
|
+
const s = updates.status();
|
|
59
|
+
assert.equal(s.coordinatorPending, null);
|
|
60
|
+
assert.match(s.coordinatorUpdateError.message, /wasn't picked up.*thub-coordinator-update\.path/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a helper failure ends the wait at once with its message', async () => {
|
|
64
|
+
const { updates, clock, helperSays } = await setup();
|
|
65
|
+
updates.requestCoordinatorUpdate('alice');
|
|
66
|
+
clock.t += 2000;
|
|
67
|
+
helperSays('installing');
|
|
68
|
+
assert.equal(updates.status().coordinatorPending, NEWER);
|
|
69
|
+
helperSays('failed', 'npm i -g failed (exit 1)');
|
|
70
|
+
const s = updates.status();
|
|
71
|
+
assert.equal(s.coordinatorPending, null);
|
|
72
|
+
assert.deepEqual([s.coordinatorUpdateError.version, s.coordinatorUpdateError.message], [NEWER, 'npm i -g failed (exit 1)']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('an install that runs far too long times out; a normal one keeps pending', async () => {
|
|
76
|
+
const { updates, clock, helperSays } = await setup();
|
|
77
|
+
updates.requestCoordinatorUpdate('alice');
|
|
78
|
+
helperSays('installing');
|
|
79
|
+
clock.t += 5 * 60_000;
|
|
80
|
+
assert.equal(updates.status().coordinatorPending, NEWER);
|
|
81
|
+
clock.t += 16 * 60_000;
|
|
82
|
+
assert.match(updates.status().coordinatorUpdateError.message, /"installing" for over 20 min/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a stale failure from an earlier attempt is not mistaken for the new one', async () => {
|
|
86
|
+
const { updates, clock, helperSays } = await setup();
|
|
87
|
+
helperSays('failed', 'old failure');
|
|
88
|
+
clock.t += 60_000;
|
|
89
|
+
updates.requestCoordinatorUpdate('alice'); // removes the old status
|
|
90
|
+
assert.equal(updates.status().coordinatorPending, NEWER);
|
|
91
|
+
assert.equal(updates.status().coordinatorUpdateError, null);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('on startup, a finished update\'s status is cleared; a failure is kept for the dashboard', async () => {
|
|
95
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-updates-')),
|
|
96
|
+
statusFile = path.join(dataDir, 'update-status.json'),
|
|
97
|
+
make = () => createUpdatesService({ config: { dataDir, updates: {} }, fetchLatest: async () => installed });
|
|
98
|
+
|
|
99
|
+
fs.writeFileSync(statusFile, JSON.stringify({ version: installed, state: 'installing', at: new Date().toISOString() }));
|
|
100
|
+
make().start();
|
|
101
|
+
assert.equal(fs.existsSync(statusFile), false);
|
|
102
|
+
|
|
103
|
+
fs.writeFileSync(statusFile, JSON.stringify({ version: NEWER, state: 'failed', at: new Date().toISOString() }));
|
|
104
|
+
make().start();
|
|
105
|
+
assert.equal(fs.existsSync(statusFile), true);
|
|
106
|
+
});
|
package/views/jobs/list.pug
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
extends ../layout
|
|
2
2
|
include ../mixins/status-badge
|
|
3
|
+
include ../mixins/list-controls
|
|
4
|
+
include ../mixins/confirm-modal
|
|
3
5
|
|
|
4
6
|
block content
|
|
5
7
|
.d-flex.justify-content-between.align-items-center.mb-3
|
|
6
8
|
h1.h4.mb-0 Jobs
|
|
7
9
|
if canManage
|
|
8
10
|
.btn-group
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
form.ms-1(method="post" action="/jobs/clean-history" onsubmit="return confirm('Permanently delete all finished jobs and their logs/artifacts? This cannot be undone.')")
|
|
12
|
-
button.btn.btn-sm.btn-outline-danger(type="submit") Clean history
|
|
11
|
+
button.btn.btn-sm.btn-outline-warning(type="button" data-bs-toggle="modal" data-bs-target="#reset-queue-modal") Reset queue
|
|
12
|
+
button.btn.btn-sm.btn-outline-danger.ms-1(type="button" data-bs-toggle="modal" data-bs-target="#clean-history-modal") Clean history
|
|
13
13
|
|
|
14
14
|
form.row.g-2.mb-3(method="get")
|
|
15
15
|
.col-auto
|
|
@@ -22,17 +22,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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
+sortHeader(list, 'id', 'Job')
|
|
34
|
+
+sortHeader(list, 'source', 'Source')
|
|
35
|
+
+sortHeader(list, 'user', 'User')
|
|
36
|
+
+sortHeader(list, 'state', 'State')
|
|
37
|
+
+sortHeader(list, 'resource', 'Resource')
|
|
38
|
+
+sortHeader(list, 'created', 'Created', 'desc')
|
|
39
|
+
+sortHeader(list, 'duration', 'Duration', 'desc')
|
|
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
|
|
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.
|
package/views/layout.pug
CHANGED
|
@@ -52,7 +52,14 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
52
52
|
if user && user.role === 'admin' && updates.coordinatorUpdate
|
|
53
53
|
li.nav-item.d-flex.align-items-center.me-3
|
|
54
54
|
if updates.coordinatorPending
|
|
55
|
-
|
|
55
|
+
//- public/js/update-watch.js polls /updates/status and
|
|
56
|
+
//- reloads the page once the new version is up (or the
|
|
57
|
+
//- update failed, to show why).
|
|
58
|
+
button.btn.btn-sm.btn-warning#coordinator-update-pending(
|
|
59
|
+
type="button"
|
|
60
|
+
disabled
|
|
61
|
+
data-current-version=coordinatorVersion
|
|
62
|
+
)
|
|
56
63
|
span.spinner-border.spinner-border-sm.me-1(aria-hidden="true")
|
|
57
64
|
= `Updating app to ${updates.coordinatorPending}…`
|
|
58
65
|
else
|
|
@@ -104,12 +111,16 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
104
111
|
)
|
|
105
112
|
i.bi.bi-box-arrow-right
|
|
106
113
|
//- Flash messages as toasts (public/js/toasts.js): errors stay until
|
|
107
|
-
//- closed, warnings close after 30 s, info/success after 10 s
|
|
114
|
+
//- closed, warnings close after 30 s, info/success after 10 s, with a
|
|
115
|
+
//- countdown bar along the bottom (paused on hover). Rendered
|
|
108
116
|
//- visible (.show), so they're readable even if the script fails.
|
|
109
117
|
if messages && messages.length
|
|
110
118
|
.toast-container.position-fixed.end-0.p-3(style="top: 3.5rem; z-index: 1090;")
|
|
111
119
|
each m in messages
|
|
112
|
-
-
|
|
120
|
+
//- Error-level aliases map to danger (never auto-closes); anything
|
|
121
|
+
//- else unknown is shown as info.
|
|
122
|
+
- const alias = { error: 'danger', critical: 'danger', fatal: 'danger' }[m.type] || m.type
|
|
123
|
+
- const type = ['danger', 'warning', 'info', 'success'].includes(alias) ? alias : 'info'
|
|
113
124
|
- const icon = { danger: 'x-octagon-fill', warning: 'exclamation-triangle-fill', info: 'info-circle-fill', success: 'check-circle-fill' }[type]
|
|
114
125
|
.toast.show.border-0(
|
|
115
126
|
class=`text-bg-${type}`
|
|
@@ -136,4 +147,6 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
136
147
|
script(src="/js/tooltips.js")
|
|
137
148
|
script(src="/js/toasts.js")
|
|
138
149
|
script(src="/js/copy-to-clipboard.js")
|
|
150
|
+
if user && user.role === 'admin' && updates.coordinatorPending
|
|
151
|
+
script(src="/js/update-watch.js")
|
|
139
152
|
block scripts
|
|
@@ -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
|
package/views/resources/list.pug
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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,
|
|
52
|
+
+resourceActions(r, list.pageUrl())
|
|
48
53
|
|
|
49
54
|
each r in resources
|
|
50
|
-
+resourceCard(r, groupsById,
|
|
55
|
+
+resourceCard(r, groupsById, list.pageUrl())
|
|
51
56
|
|
|
52
57
|
if user && user.role === 'admin'
|
|
53
58
|
+removeResourceModal()
|