@andrian.yablonskyy/thub-coordinator 1.0.17 → 1.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/js/remove-resource.js +45 -0
- package/src/api/admin.js +15 -0
- package/src/api/agent.js +9 -2
- package/src/api/resource.js +6 -0
- package/src/db/migrations/013_add_job_resource_name.sql +4 -0
- package/src/db/migrations/014_add_remove_requested_at.sql +5 -0
- package/src/db/migrations/015_add_job_duration.sql +9 -0
- package/src/db/migrations/016_add_list_prefs.sql +5 -0
- package/src/services/admin-users.js +27 -2
- package/src/services/heartbeat.js +23 -2
- package/src/services/jobs.js +108 -8
- package/src/services/list-prefs.js +92 -0
- package/src/services/registry.js +40 -2
- package/src/web/routes.js +101 -14
- package/test/list-prefs.test.js +89 -0
- package/test/scheduler.test.js +106 -0
- package/views/index.pug +5 -1
- package/views/jobs/list.pug +77 -13
- package/views/jobs/show.pug +5 -2
- package/views/mixins/confirm-modal.pug +26 -0
- package/views/mixins/list-controls.pug +61 -0
- package/views/mixins/resource.pug +77 -1
- package/views/mixins/status-badge.pug +8 -1
- package/views/resources/list.pug +18 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.0.
|
|
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,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/public/js/remove-resource.js
|
|
3
|
+
* @description Dashboard: warning modal for removing a resource that is running a job (stop job, then remove)
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
(function (){
|
|
17
|
+
const modalEl = document.getElementById('remove-resource-modal');
|
|
18
|
+
if (!modalEl){
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const field = (name) => modalEl.querySelector(`[data-field="${name}"]`);
|
|
22
|
+
|
|
23
|
+
document.querySelectorAll('[data-remove-resource]').forEach((btn) => {
|
|
24
|
+
btn.addEventListener('click', () => {
|
|
25
|
+
const { removeResource: id, removeName: name, removeJob: job, removeReturn: returnTo } = btn.dataset;
|
|
26
|
+
field('name').textContent = name;
|
|
27
|
+
field('job').textContent = job;
|
|
28
|
+
field('job').href = `/jobs/${encodeURIComponent(job)}`;
|
|
29
|
+
field('job-text').textContent = job;
|
|
30
|
+
field('form').action = `/resources/${encodeURIComponent(id)}/remove`;
|
|
31
|
+
field('return').value = returnTo || '/resources';
|
|
32
|
+
|
|
33
|
+
// Clicked from inside a resource card: close that first — Bootstrap
|
|
34
|
+
// doesn't stack modals.
|
|
35
|
+
const card = btn.closest('.modal');
|
|
36
|
+
if (card){
|
|
37
|
+
card.addEventListener('hidden.bs.modal', () => bootstrap.Modal.getOrCreateInstance(modalEl).show(), { once: true });
|
|
38
|
+
bootstrap.Modal.getInstance(card)?.hide();
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
bootstrap.Modal.getOrCreateInstance(modalEl).show();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
})();
|
package/src/api/admin.js
CHANGED
|
@@ -60,6 +60,21 @@ function createAdminRouter({ services }){
|
|
|
60
60
|
res.json({ resourceToken: token });
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
// Removes a resource; 409 while it runs a job unless ?stopJob=1, which
|
|
64
|
+
// cancels the job and removes the resource once the Client confirms
|
|
65
|
+
// (`pending: true`). Queued jobs pinned to it are canceled on removal.
|
|
66
|
+
router.delete('/resources/:id', (req, res, next) => {
|
|
67
|
+
try {
|
|
68
|
+
const { resource, pending, stoppedJob, canceledJobs } = services.jobs.removeResource(req.params.id, {
|
|
69
|
+
stopJob: req.query.stopJob === '1'
|
|
70
|
+
});
|
|
71
|
+
res.json({ removed: resource.id, pending, stoppedJob, canceledJobs });
|
|
72
|
+
}
|
|
73
|
+
catch (err){
|
|
74
|
+
next(err);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
63
78
|
// Cancels every QUEUED/ASSIGNED/PREPARING/RUNNING job (§13.1).
|
|
64
79
|
router.post('/jobs/reset-queue', (req, res) => {
|
|
65
80
|
const canceled = services.jobs.resetQueue();
|
package/src/api/agent.js
CHANGED
|
@@ -28,7 +28,11 @@ function createAgentRouter({ services, config }){
|
|
|
28
28
|
|
|
29
29
|
router.post('/jobs', auth, (req, res, next) => {
|
|
30
30
|
try {
|
|
31
|
-
|
|
31
|
+
// The job source (A-/M- id series, default priority) is the agent
|
|
32
|
+
// token's admin-assigned kind alone — never taken from the request,
|
|
33
|
+
// so it can't be spoofed or mis-detected on the Agent side.
|
|
34
|
+
const source = req.agent.kind,
|
|
35
|
+
job = services.jobs.create({ agentId: req.agent.id, source, spec: req.body });
|
|
32
36
|
res.status(201).json({ jobId: job.id, state: job.state, webUrl: `${config.publicUrl}/jobs/${job.id}` });
|
|
33
37
|
}
|
|
34
38
|
catch (err){
|
|
@@ -42,7 +46,10 @@ function createAgentRouter({ services, config }){
|
|
|
42
46
|
return res.status(404).json({ error: 'Unknown job' });
|
|
43
47
|
}
|
|
44
48
|
const resource = job.resource_id ? services.registry.get(job.resource_id) : null;
|
|
45
|
-
res.json({
|
|
49
|
+
res.json({
|
|
50
|
+
...job,
|
|
51
|
+
resource: resource ? { id: resource.id, name: resource.name } : job.resource_name ? { id: null, name: job.resource_name } : null
|
|
52
|
+
});
|
|
46
53
|
});
|
|
47
54
|
|
|
48
55
|
router.get('/jobs', auth, (req, res) => {
|
package/src/api/resource.js
CHANGED
|
@@ -103,6 +103,12 @@ function createResourceRouter({ services, config }){
|
|
|
103
103
|
if (updateTo){
|
|
104
104
|
commands.push({ command: 'self-update', version: updateTo });
|
|
105
105
|
}
|
|
106
|
+
// Removal pending (jobs.removeResource): once the Client reports its
|
|
107
|
+
// job stopped, finish it. This response still goes out; the next
|
|
108
|
+
// heartbeat gets 401, the token being gone with the resource.
|
|
109
|
+
if (req.resource.remove_requested_at && !activeJobId){
|
|
110
|
+
services.jobs.completeRemoval(req.params.id, { by: 'removal-confirmed' });
|
|
111
|
+
}
|
|
106
112
|
res.json({ serverTime: new Date().toISOString(), commands });
|
|
107
113
|
}
|
|
108
114
|
catch (err){
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
-- Name of the resource a job ran on, kept once that resource is removed
|
|
2
|
+
-- from the dashboard (registry.remove): jobs.resource_id is then NULLed
|
|
3
|
+
-- (it's a foreign key), and this is what the job's history shows instead.
|
|
4
|
+
ALTER TABLE jobs ADD COLUMN resource_name TEXT;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- Set when an admin removes a resource that is running a job: the job is
|
|
2
|
+
-- canceled right away, and the resource itself is deleted once its Client
|
|
3
|
+
-- confirms the job stopped (next heartbeat with no active job) or after a
|
|
4
|
+
-- timeout (heartbeat.js). Meanwhile the scheduler never assigns it work.
|
|
5
|
+
ALTER TABLE resources ADD COLUMN remove_requested_at TEXT;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- How long a finished job ran (README §9): seconds from started_at (RUNNING)
|
|
2
|
+
-- to finished_at, computed once when it reaches a terminal state
|
|
3
|
+
-- (jobs.setState) and shown instead of the finish time. NULL for a job
|
|
4
|
+
-- that never ran (e.g. canceled while queued). finished_at itself stays:
|
|
5
|
+
-- log/artifact retention is keyed on it. Backfilled for existing jobs.
|
|
6
|
+
ALTER TABLE jobs ADD COLUMN duration_sec INTEGER;
|
|
7
|
+
UPDATE jobs
|
|
8
|
+
SET duration_sec = MAX(0, CAST(ROUND((julianday(finished_at) - julianday(started_at)) * 86400) AS INTEGER))
|
|
9
|
+
WHERE finished_at IS NOT NULL AND started_at IS NOT NULL;
|
|
@@ -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
|
};
|
|
@@ -47,10 +47,31 @@ function createHeartbeatMonitor(db, { bus, events, registry, jobs, config }){
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
// Resources an admin removed while they ran a job (jobs.removeResource):
|
|
51
|
+
// normally deleted by the heartbeat route once the Client reports the job
|
|
52
|
+
// stopped; this covers a Client that's gone (OUT_OF_SERVICE) or never
|
|
53
|
+
// confirms — after the time a cancel plus the runner's kill grace needs.
|
|
54
|
+
function sweepRemovals(){
|
|
55
|
+
const timeoutMs = Math.max(60, 6 * config.heartbeat.intervalSec) * 1000;
|
|
56
|
+
for (const r of registry.pendingRemovals()){
|
|
57
|
+
if (r.status === RESOURCE_STATES.OUT_OF_SERVICE || Date.now() - new Date(r.remove_requested_at).getTime() >= timeoutMs){
|
|
58
|
+
try {
|
|
59
|
+
jobs.completeRemoval(r.id, { by: 'removal-timeout' });
|
|
60
|
+
}
|
|
61
|
+
catch (err){
|
|
62
|
+
console.error(`removing ${r.name}: ${err.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const timer = setInterval(() => {
|
|
69
|
+
sweepOnce();
|
|
70
|
+
sweepRemovals();
|
|
71
|
+
}, config.heartbeat.sweepIntervalSec * 1000);
|
|
51
72
|
timer.unref?.();
|
|
52
73
|
|
|
53
|
-
return { sweepOnce, stop: () => clearInterval(timer) };
|
|
74
|
+
return { sweepOnce, sweepRemovals, stop: () => clearInterval(timer) };
|
|
54
75
|
}
|
|
55
76
|
|
|
56
77
|
module.exports = { createHeartbeatMonitor };
|
package/src/services/jobs.js
CHANGED
|
@@ -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){
|
|
@@ -30,7 +31,10 @@ function rowToJob(row){
|
|
|
30
31
|
// developer's own machine — separate series (each own counter row) rather
|
|
31
32
|
// than a shared counter with just a different letter, so e.g. A-00042
|
|
32
33
|
// doesn't imply 41 manual runs happened first.
|
|
33
|
-
const JOB_ID_PREFIXES = { ci: 'A', cli: 'M' }
|
|
34
|
+
const JOB_ID_PREFIXES = { ci: 'A', cli: 'M' },
|
|
35
|
+
// Default priority when the spec doesn't set one (§7.1): a developer's
|
|
36
|
+
// manual run jumps ahead of CI's queue.
|
|
37
|
+
DEFAULT_PRIORITY = { ci: 50, cli: 60 };
|
|
34
38
|
|
|
35
39
|
function nextJobId(db, source){
|
|
36
40
|
const prefix = JOB_ID_PREFIXES[source];
|
|
@@ -76,6 +80,44 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
76
80
|
return db.prepare(sql).all(...params).map(rowToJob);
|
|
77
81
|
}
|
|
78
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
|
+
|
|
79
121
|
function countQueuedForAgent(agentId){
|
|
80
122
|
return db
|
|
81
123
|
.prepare('SELECT COUNT(*) AS n FROM jobs WHERE agent_id = ? AND state IN (\'QUEUED\',\'ASSIGNED\')')
|
|
@@ -85,7 +127,9 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
85
127
|
// Agent: POST /jobs (§6.1, §4.3). Rejects unsatisfiable label requests
|
|
86
128
|
// immediately (§5.4) instead of letting them starve in the queue.
|
|
87
129
|
function create({ agentId, source, spec: rawSpec }){
|
|
88
|
-
const { valid, spec, errors } = validateJobSpec(
|
|
130
|
+
const { valid, spec, errors } = validateJobSpec(
|
|
131
|
+
rawSpec?.priority === undefined ? { ...rawSpec, priority: DEFAULT_PRIORITY[source] } : rawSpec
|
|
132
|
+
);
|
|
89
133
|
if (!valid){
|
|
90
134
|
throw Object.assign(new Error(`Invalid job spec: ${errors.join('; ')}`), { status: 400 });
|
|
91
135
|
}
|
|
@@ -152,8 +196,10 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
152
196
|
params.push(now);
|
|
153
197
|
}
|
|
154
198
|
if (TERMINAL_JOB_STATES.has(state)){
|
|
155
|
-
|
|
156
|
-
|
|
199
|
+
// Duration is computed once, here, and stored — never derived at
|
|
200
|
+
// display time. Measured from started_at (RUNNING), like the timeout.
|
|
201
|
+
fields.push('finished_at = ?', 'duration_sec = ?');
|
|
202
|
+
params.push(now, job.started_at ? Math.max(0, Math.round((Date.parse(now) - Date.parse(job.started_at)) / 1000)) : null);
|
|
157
203
|
}
|
|
158
204
|
if (extra.message !== undefined){
|
|
159
205
|
fields.push('message = ?');
|
|
@@ -168,9 +214,11 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
168
214
|
params.push(JSON.stringify(extra.summary));
|
|
169
215
|
}
|
|
170
216
|
if (state === JOB_STATES.QUEUED){
|
|
171
|
-
// Requeue: drop the previous assignment
|
|
172
|
-
|
|
173
|
-
|
|
217
|
+
// Requeue: drop the previous assignment (and its start time, so the
|
|
218
|
+
// next attempt's duration/timeout count from its own start) so the
|
|
219
|
+
// scheduler treats it fresh.
|
|
220
|
+
fields.push('resource_id = ?', 'assigned_at = ?', 'started_at = ?', 'attempt = attempt + 1');
|
|
221
|
+
params.push(null, null, null);
|
|
174
222
|
}
|
|
175
223
|
|
|
176
224
|
params.push(id);
|
|
@@ -213,6 +261,44 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
213
261
|
return setState(id, JOB_STATES.CANCELED);
|
|
214
262
|
}
|
|
215
263
|
|
|
264
|
+
// Queued jobs pinned to a resource (`target.client`) can never run once it's
|
|
265
|
+
// removed, so they're canceled rather than left in the queue forever.
|
|
266
|
+
function cancelPinnedTo(resourceId, message){
|
|
267
|
+
const queued = db.prepare('SELECT id, spec FROM jobs WHERE state = ?').all(JOB_STATES.QUEUED)
|
|
268
|
+
.filter((row) => JSON.parse(row.spec).target?.client === resourceId);
|
|
269
|
+
for (const { id }of queued){
|
|
270
|
+
setState(id, JOB_STATES.CANCELED, { message });
|
|
271
|
+
}
|
|
272
|
+
return queued.map((row) => row.id);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Admin "Remove" (dashboard, DELETE /admin/resources/:id). A resource
|
|
276
|
+
// that's running a job needs `stopJob`: the job is canceled at once, but
|
|
277
|
+
// the Client only hears that in its next heartbeat's commands — so the
|
|
278
|
+
// resource (and with it the Client's token) is deleted only after that
|
|
279
|
+
// (completeRemoval, from the heartbeat route or the sweeper's timeout).
|
|
280
|
+
function removeResource(resourceId, { by, stopJob = false } = {}){
|
|
281
|
+
const r = registry.get(resourceId);
|
|
282
|
+
if (!r){
|
|
283
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
284
|
+
}
|
|
285
|
+
const job = activeForResource(resourceId);
|
|
286
|
+
if (job && !stopJob){
|
|
287
|
+
throw Object.assign(new Error(`${r.name} is running job ${job.id}`), { status: 409, jobId: job.id });
|
|
288
|
+
}
|
|
289
|
+
if (job){
|
|
290
|
+
cancel(job.id, { isAdmin: true });
|
|
291
|
+
registry.requestRemoval(resourceId, { by });
|
|
292
|
+
return { resource: r, pending: true, stoppedJob: job.id, canceledJobs: [] };
|
|
293
|
+
}
|
|
294
|
+
return { resource: r, pending: false, stoppedJob: null, canceledJobs: completeRemoval(resourceId, { by }) };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function completeRemoval(resourceId, { by } = {}){
|
|
298
|
+
const r = registry.remove(resourceId, { by });
|
|
299
|
+
return cancelPinnedTo(resourceId, `Client ${r.name} was removed`);
|
|
300
|
+
}
|
|
301
|
+
|
|
216
302
|
// The job a resource is working on (ASSIGNED through RUNNING), if any —
|
|
217
303
|
// for the resource card's Cancel button.
|
|
218
304
|
function activeForResource(resourceId){
|
|
@@ -316,6 +402,15 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
316
402
|
return active.length;
|
|
317
403
|
}
|
|
318
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
|
+
|
|
319
414
|
// Admin: "clean the queue" — permanently delete finished jobs (and their
|
|
320
415
|
// logs/artifacts, on disk and in the DB), for when the history itself,
|
|
321
416
|
// not just active work, needs clearing out. Unlike the nightly retention
|
|
@@ -359,9 +454,13 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
359
454
|
return {
|
|
360
455
|
get,
|
|
361
456
|
list,
|
|
457
|
+
page,
|
|
362
458
|
create,
|
|
363
459
|
setState,
|
|
364
460
|
cancel,
|
|
461
|
+
cancelPinnedTo,
|
|
462
|
+
removeResource,
|
|
463
|
+
completeRemoval,
|
|
365
464
|
activeForResource,
|
|
366
465
|
applyResult,
|
|
367
466
|
requeueUnacked,
|
|
@@ -370,6 +469,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
370
469
|
checkAssignAcks,
|
|
371
470
|
reconcileOnStartup,
|
|
372
471
|
resetQueue,
|
|
472
|
+
countActiveAndFinished,
|
|
373
473
|
cleanHistory
|
|
374
474
|
};
|
|
375
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/registry.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
16
|
const { v4: uuid } = require('uuid'),
|
|
17
|
-
{ RESOURCE_STATES, BUSY_SOURCES, compareVersions } = require('@andrian.yablonskyy/thub-common'),
|
|
17
|
+
{ RESOURCE_STATES, BUSY_SOURCES, ACTIVE_JOB_STATES, compareVersions } = require('@andrian.yablonskyy/thub-common'),
|
|
18
18
|
{ generateToken, hashToken } = require('./tokens');
|
|
19
19
|
|
|
20
20
|
function rowToResource(row){
|
|
@@ -474,9 +474,44 @@ function createRegistryService(db, { bus, events }){
|
|
|
474
474
|
(!resourceId || r.id === resourceId);
|
|
475
475
|
}
|
|
476
476
|
|
|
477
|
+
// Admin: remove a resource from the registry (dashboard "Remove"). Refused
|
|
478
|
+
// while it holds an active job — cancel that first. Its finished jobs stay,
|
|
479
|
+
// keeping the resource's name (jobs.resource_name) in place of the id.
|
|
480
|
+
// A Client that's still running reappears when it next re-registers.
|
|
481
|
+
function remove(resourceId, { by } = {}){
|
|
482
|
+
const r = get(resourceId);
|
|
483
|
+
if (!r){
|
|
484
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
485
|
+
}
|
|
486
|
+
const placeholders = [...ACTIVE_JOB_STATES].map(() => '?').join(','),
|
|
487
|
+
active = db
|
|
488
|
+
.prepare(`SELECT id FROM jobs WHERE resource_id = ? AND state IN (${placeholders}) LIMIT 1`)
|
|
489
|
+
.get(resourceId, ...ACTIVE_JOB_STATES);
|
|
490
|
+
if (active){
|
|
491
|
+
throw Object.assign(new Error(`${r.name} is working on job ${active.id} — cancel it first`), { status: 409 });
|
|
492
|
+
}
|
|
493
|
+
db.transaction(() => {
|
|
494
|
+
db.prepare('UPDATE jobs SET resource_name = ?, resource_id = NULL WHERE resource_id = ?').run(r.name, resourceId);
|
|
495
|
+
db.prepare('DELETE FROM resources WHERE id = ?').run(resourceId);
|
|
496
|
+
})();
|
|
497
|
+
events.record('resource', resourceId, 'resource.removed', { name: r.name, by });
|
|
498
|
+
return r;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Step one of removing a resource that's running a job (jobs.removeResource):
|
|
502
|
+
// it takes no new work from here on and is deleted once the job stopped.
|
|
503
|
+
function requestRemoval(resourceId, { by } = {}){
|
|
504
|
+
db.prepare('UPDATE resources SET remove_requested_at = ? WHERE id = ?').run(new Date().toISOString(), resourceId);
|
|
505
|
+
events.record('resource', resourceId, 'resource.removal_requested', { by });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function pendingRemovals(){
|
|
509
|
+
return db.prepare('SELECT * FROM resources WHERE remove_requested_at IS NOT NULL').all().map(rowToResource);
|
|
510
|
+
}
|
|
511
|
+
|
|
477
512
|
function findIdleCandidates(type, labels, groupId, resourceId){
|
|
478
513
|
const rows = db
|
|
479
|
-
.prepare('SELECT * FROM resources WHERE status = ? AND type = ?')
|
|
514
|
+
.prepare('SELECT * FROM resources WHERE status = ? AND type = ? AND remove_requested_at IS NULL')
|
|
480
515
|
.all(RESOURCE_STATES.IDLE, type)
|
|
481
516
|
.map(rowToResource);
|
|
482
517
|
return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
@@ -504,6 +539,9 @@ function createRegistryService(db, { bus, events }){
|
|
|
504
539
|
requestUpdateAll,
|
|
505
540
|
pendingUpdate,
|
|
506
541
|
assignToJob,
|
|
542
|
+
remove,
|
|
543
|
+
requestRemoval,
|
|
544
|
+
pendingRemovals,
|
|
507
545
|
findIdleCandidates,
|
|
508
546
|
everSatisfiable
|
|
509
547
|
};
|