@andrian.yablonskyy/thub-coordinator 1.0.37 → 1.0.38
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 +2 -2
- package/public/css/thub.css +129 -0
- package/public/js/config-import.js +37 -33
- package/public/js/copy-to-clipboard.js +21 -18
- package/public/js/help.js +93 -0
- package/public/js/list-search.js +51 -0
- package/public/js/live.js +287 -0
- package/public/js/log-viewer.js +123 -11
- package/public/js/remove-resource.js +5 -3
- package/public/js/resource-card.js +14 -13
- package/public/js/tooltips.js +3 -1
- package/src/server.js +20 -2
- package/src/services/jobs.js +29 -12
- package/src/services/list-prefs.js +4 -0
- package/src/services/live.js +124 -0
- package/src/services/search.js +59 -0
- package/src/web/routes.js +130 -21
- package/test/list-prefs.test.js +6 -0
- package/test/live.test.js +153 -0
- package/test/search.test.js +97 -0
- package/test/views.test.js +30 -0
- package/views/admin/agents.pug +28 -10
- package/views/groups/list.pug +14 -1
- package/views/help/_agent-cli.pug +103 -0
- package/views/help/_agent-setup.pug +72 -0
- package/views/help/_ci.pug +44 -0
- package/views/help/_client-machines.pug +87 -0
- package/views/help/_client-setup.pug +122 -0
- package/views/help/_coordinator.pug +134 -0
- package/views/help/_docker.pug +124 -0
- package/views/help/_env.pug +127 -0
- package/views/help/_git.pug +129 -0
- package/views/help/_mixins.pug +24 -0
- package/views/help/_overview.pug +118 -0
- package/views/help/_quick-start.pug +37 -0
- package/views/help/_troubleshooting.pug +42 -0
- package/views/help/index.pug +67 -0
- package/views/index.pug +3 -2
- package/views/jobs/list.pug +6 -3
- package/views/layout.pug +24 -1
- package/views/mixins/list-controls.pug +36 -0
- package/views/mixins/log-viewer.pug +25 -3
- package/views/mixins/resource.pug +1 -1
- package/views/resources/list.pug +7 -4
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/services/live.js
|
|
3
|
+
* @description Dashboard live updates: detects when what a list page shows (resources, agents, jobs) has changed and notifies subscribers
|
|
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 crypto = require('node:crypto'),
|
|
17
|
+
{ ACTIVE_JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
18
|
+
|
|
19
|
+
// How the dashboard stays current (README §10): the page holds one
|
|
20
|
+
// GET /live stream that only says *which* topics changed; it then re-fetches
|
|
21
|
+
// itself and swaps the parts that differ (public/js/live.js). Rather than
|
|
22
|
+
// every place that changes a resource or agent having to announce it, a
|
|
23
|
+
// fingerprint of each topic is recomputed and compared — right away on bus
|
|
24
|
+
// events (a job starting or finishing shows up at once) and every
|
|
25
|
+
// `intervalMs` as the catch-all for everything else (heartbeats, the
|
|
26
|
+
// sweeper, maintenance, renames, update requests, …).
|
|
27
|
+
const TOPICS = ['resources', 'agents', 'jobs'],
|
|
28
|
+
BUS_EVENTS = ['job.queued', 'job.assigned', 'job.state', 'job.finished', 'resource.idle', 'resource.reregistered'];
|
|
29
|
+
|
|
30
|
+
function createLiveService(db, { bus, updates, intervalMs = 5000, debounceMs = 250 } = {}){
|
|
31
|
+
const subscribers = new Set(),
|
|
32
|
+
activeStates = [...ACTIVE_JOB_STATES],
|
|
33
|
+
hash = (value) => crypto.createHash('sha1').update(JSON.stringify(value)).digest('base64'),
|
|
34
|
+
// One cheap query per topic covering everything its pages display.
|
|
35
|
+
// Resources: every column (tens of rows) — last_heartbeat_at included,
|
|
36
|
+
// since the pages show it. Jobs: counts and latest times of the whole
|
|
37
|
+
// table (new, finished and deleted jobs) plus every active job's state.
|
|
38
|
+
queries = {
|
|
39
|
+
resources: () => [db.prepare('SELECT * FROM resources ORDER BY id').all(), updates?.status().latest],
|
|
40
|
+
agents: () => [
|
|
41
|
+
db.prepare('SELECT id, name, kind, version, update_to, last_used_at, revoked_at FROM agents ORDER BY id').all(),
|
|
42
|
+
updates?.status().latest
|
|
43
|
+
],
|
|
44
|
+
jobs: () => [
|
|
45
|
+
db.prepare('SELECT COUNT(*) AS n, MAX(created_at) AS created, MAX(finished_at) AS finished FROM jobs').get(),
|
|
46
|
+
db.prepare(
|
|
47
|
+
`SELECT id, state, resource_id, message FROM jobs WHERE state IN (${activeStates.map(() => '?').join(',')}) ORDER BY id`
|
|
48
|
+
).all(...activeStates)
|
|
49
|
+
]
|
|
50
|
+
};
|
|
51
|
+
let fingerprints = null,
|
|
52
|
+
timer = null,
|
|
53
|
+
pending = null;
|
|
54
|
+
|
|
55
|
+
function compute(){
|
|
56
|
+
return Object.fromEntries(TOPICS.map((t) => [t, hash(queries[t]())]));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Compare with the last fingerprints; tell every subscriber what changed.
|
|
60
|
+
function check(){
|
|
61
|
+
pending = null;
|
|
62
|
+
if (!subscribers.size){
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const next = compute(),
|
|
66
|
+
changed = fingerprints ? TOPICS.filter((t) => next[t] !== fingerprints[t]) : [];
|
|
67
|
+
fingerprints = next;
|
|
68
|
+
if (changed.length){
|
|
69
|
+
for (const fn of subscribers){
|
|
70
|
+
try {
|
|
71
|
+
fn(changed);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// one broken subscriber (a closed stream) mustn't stop the others
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return changed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Bus events arrive in bursts (state + log + finished): check once.
|
|
82
|
+
function soon(){
|
|
83
|
+
if (subscribers.size && !pending){
|
|
84
|
+
pending = setTimeout(check, debounceMs);
|
|
85
|
+
pending.unref?.();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function start(){
|
|
90
|
+
fingerprints = compute();
|
|
91
|
+
timer = setInterval(check, intervalMs);
|
|
92
|
+
timer.unref?.();
|
|
93
|
+
for (const e of BUS_EVENTS){
|
|
94
|
+
bus.on(e, soon);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stop(){
|
|
99
|
+
clearInterval(timer);
|
|
100
|
+
clearTimeout(pending);
|
|
101
|
+
timer = pending = null;
|
|
102
|
+
fingerprints = null;
|
|
103
|
+
for (const e of BUS_EVENTS){
|
|
104
|
+
bus.off(e, soon);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Nothing runs while no dashboard is open.
|
|
109
|
+
function subscribe(fn){
|
|
110
|
+
subscribers.add(fn);
|
|
111
|
+
if (subscribers.size === 1){
|
|
112
|
+
start();
|
|
113
|
+
}
|
|
114
|
+
return () => {
|
|
115
|
+
if (subscribers.delete(fn) && !subscribers.size){
|
|
116
|
+
stop();
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { subscribe, check, subscriberCount: () => subscribers.size, TOPICS };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { createLiveService, TOPICS };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/services/search.js
|
|
3
|
+
* @description Dashboard search (?q=): splits the query into words; a record matches when every word occurs in one of its fields
|
|
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 MAX_QUERY = 200,
|
|
17
|
+
MAX_TERMS = 10;
|
|
18
|
+
|
|
19
|
+
// " Lab-HW nucleo " -> ['lab-hw', 'nucleo']. Case-insensitive, words in
|
|
20
|
+
// any order; capped so a pasted wall of text can't build a huge query.
|
|
21
|
+
function parseTerms(q){
|
|
22
|
+
if (typeof q !== 'string'){
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return [...new Set(q.slice(0, MAX_QUERY).toLowerCase().split(/\s+/).filter(Boolean))].slice(0, MAX_TERMS);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The query as shown back in the search box (trimmed, capped).
|
|
29
|
+
function normalizeQuery(q){
|
|
30
|
+
return typeof q === 'string' ? q.slice(0, MAX_QUERY).trim() : '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// In-memory lists (resources, groups, agents): `fields` are the record's
|
|
34
|
+
// searchable values — strings, numbers, arrays of them, or null.
|
|
35
|
+
function matches(fields, terms){
|
|
36
|
+
if (!terms.length){
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
const text = fields.flat(Infinity).filter((v) => v !== null && v !== undefined && v !== '').join('\n').toLowerCase();
|
|
40
|
+
return terms.every((t) => text.includes(t));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// SQL lists (jobs): every term must match at least one of `expressions`
|
|
44
|
+
// (SQL expressions yielding text). LIKE is case-insensitive for ASCII in
|
|
45
|
+
// SQLite; % and _ in the query are matched literally.
|
|
46
|
+
function likeClause(expressions, terms){
|
|
47
|
+
if (!terms.length){
|
|
48
|
+
return { sql: '', params: [] };
|
|
49
|
+
}
|
|
50
|
+
const one = `(${expressions.map((e) => `COALESCE(${e}, '') LIKE ? ESCAPE '\\'`).join(' OR ')})`,
|
|
51
|
+
params = [];
|
|
52
|
+
for (const t of terms){
|
|
53
|
+
const pattern = `%${t.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
54
|
+
params.push(...expressions.map(() => pattern));
|
|
55
|
+
}
|
|
56
|
+
return { sql: terms.map(() => one).join(' AND '), params };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { parseTerms, normalizeQuery, matches, likeClause, MAX_QUERY };
|
package/src/web/routes.js
CHANGED
|
@@ -25,7 +25,8 @@ const fs = require('node:fs'),
|
|
|
25
25
|
nextCronRun
|
|
26
26
|
} = require('@andrian.yablonskyy/thub-common'),
|
|
27
27
|
{ retentionCutoff, JOB_RETENTION } = require('../services/cleanup'),
|
|
28
|
-
listPrefs = require('../services/list-prefs')
|
|
28
|
+
listPrefs = require('../services/list-prefs'),
|
|
29
|
+
search = require('../services/search');
|
|
29
30
|
|
|
30
31
|
// Resources page sort keys (list-prefs.js) -> comparators. Empty values
|
|
31
32
|
// (never heartbeated, no version reported) sort last in both directions.
|
|
@@ -38,6 +39,15 @@ const RESOURCE_SORT_VALUE = {
|
|
|
38
39
|
},
|
|
39
40
|
compareResourceValues = (sort, a, b) =>
|
|
40
41
|
sort === 'version' ? compareVersions(a, b) : String(a).localeCompare(String(b), undefined, { numeric: true }),
|
|
42
|
+
// Agents page (list-prefs `agents`): never-used / never-reported last.
|
|
43
|
+
AGENT_SORT_VALUE = {
|
|
44
|
+
name: (a) => a.name,
|
|
45
|
+
kind: (a) => a.kind,
|
|
46
|
+
version: (a) => a.version,
|
|
47
|
+
created: (a) => a.created_at,
|
|
48
|
+
used: (a) => a.last_used_at,
|
|
49
|
+
status: (a) => (a.revoked_at ? 'revoked' : 'active')
|
|
50
|
+
},
|
|
41
51
|
|
|
42
52
|
// §10.1: avatar uploads are small, single images — a hard size cap and an
|
|
43
53
|
// allow-list of image mimetypes, same spirit as the join-key/token checks
|
|
@@ -144,7 +154,7 @@ function createWebRouter({ services, config }){
|
|
|
144
154
|
// error toast (never auto-closes) — on a page view, not the polling
|
|
145
155
|
// endpoint or a POST whose redirect would drop it.
|
|
146
156
|
const updateError = res.locals.updates.coordinatorUpdateError;
|
|
147
|
-
if (updateError && user?.role === 'admin' && req.method === 'GET' && req.path !== '/updates/status' &&
|
|
157
|
+
if (updateError && user?.role === 'admin' && req.method === 'GET' && req.path !== '/updates/status' && !req.thubPassive &&
|
|
148
158
|
req.session.seenUpdateError !== updateError.at){
|
|
149
159
|
req.session.seenUpdateError = updateError.at;
|
|
150
160
|
res.locals.messages = [
|
|
@@ -156,7 +166,9 @@ function createWebRouter({ services, config }){
|
|
|
156
166
|
res.locals.nextCronRun = nextCronRun;
|
|
157
167
|
res.locals.formatDateTime = formatDateTime;
|
|
158
168
|
res.locals.currentPath = req.originalUrl;
|
|
159
|
-
|
|
169
|
+
// A live-update re-fetch (server.js, req.thubPassive) renders the page
|
|
170
|
+
// only for its data: flash messages stay for the next real page view.
|
|
171
|
+
if (req.session && !req.thubPassive){
|
|
160
172
|
req.session.flash = [];
|
|
161
173
|
}
|
|
162
174
|
|
|
@@ -181,9 +193,12 @@ function createWebRouter({ services, config }){
|
|
|
181
193
|
// so a mid-session profile change takes effect immediately, and so
|
|
182
194
|
// `rolling: true` (server.js) actually extends by *this* user's chosen
|
|
183
195
|
// duration each time, not whatever the session happened to start with.
|
|
184
|
-
if (req.session && user){
|
|
196
|
+
if (req.session && user && !req.thubPassive){
|
|
185
197
|
req.session.cookie.maxAge = user.sessionTimeoutMin * 60 * 1000;
|
|
186
198
|
}
|
|
199
|
+
if (req.thubPassive){
|
|
200
|
+
res.set('Cache-Control', 'no-store');
|
|
201
|
+
}
|
|
187
202
|
|
|
188
203
|
next();
|
|
189
204
|
});
|
|
@@ -308,6 +323,54 @@ function createWebRouter({ services, config }){
|
|
|
308
323
|
res.redirect('/profile');
|
|
309
324
|
});
|
|
310
325
|
|
|
326
|
+
// Live updates (services/live.js, public/js/live.js): which topics
|
|
327
|
+
// changed — `resources`, `agents`, `jobs` — never the data itself; the
|
|
328
|
+
// page re-fetches itself for that, with the same session and templates.
|
|
329
|
+
// A passive request (server.js): it doesn't extend the idle timeout, and
|
|
330
|
+
// it ends with `session-ended` once the session expires or logs out.
|
|
331
|
+
const LIVE_PING_MS = 25_000,
|
|
332
|
+
LIVE_SESSION_CHECK_MS = 15_000;
|
|
333
|
+
router.get('/live', (req, res) => {
|
|
334
|
+
res.writeHead(200, {
|
|
335
|
+
'Content-Type': 'text/event-stream',
|
|
336
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
337
|
+
Connection: 'keep-alive',
|
|
338
|
+
'X-Accel-Buffering': 'no'
|
|
339
|
+
});
|
|
340
|
+
res.flushHeaders?.();
|
|
341
|
+
const write = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
342
|
+
// retry: how soon EventSource reconnects after the connection drops.
|
|
343
|
+
res.write('retry: 5000\n\n');
|
|
344
|
+
write('hello', { topics: services.live.TOPICS });
|
|
345
|
+
|
|
346
|
+
const unsubscribe = services.live.subscribe((topics) => write('changed', { topics })),
|
|
347
|
+
// Comment lines keep proxies from timing the idle stream out.
|
|
348
|
+
ping = setInterval(() => res.write(': ping\n\n'), LIVE_PING_MS),
|
|
349
|
+
sessionCheck = setInterval(() => {
|
|
350
|
+
req.sessionStore.get(req.sessionID, (err, sess) => {
|
|
351
|
+
if (!err && !sess?.user){
|
|
352
|
+
write('session-ended', {});
|
|
353
|
+
res.end();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
}, LIVE_SESSION_CHECK_MS);
|
|
357
|
+
req.on('close', () => {
|
|
358
|
+
unsubscribe();
|
|
359
|
+
clearInterval(ping);
|
|
360
|
+
clearInterval(sessionCheck);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Help (views/help/): product guide and setup reference for every
|
|
365
|
+
// signed-in user. Examples use this Coordinator's URL — the configured
|
|
366
|
+
// publicUrl, unless that's still the localhost default and the page was
|
|
367
|
+
// opened from elsewhere.
|
|
368
|
+
router.get('/help', (req, res) => {
|
|
369
|
+
const origin = `${req.protocol}://${req.get('host')}`,
|
|
370
|
+
coordinatorUrl = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/?$/.test(config.publicUrl || '') ? origin : config.publicUrl;
|
|
371
|
+
res.render('help/index', { title: 'Help', active: 'help', coordinatorUrl: String(coordinatorUrl).replace(/\/+$/, '') });
|
|
372
|
+
});
|
|
373
|
+
|
|
311
374
|
router.get('/', (req, res) => {
|
|
312
375
|
const resources = withActiveJobs(services.registry.list()),
|
|
313
376
|
queueLength = services.jobs.list({ state: JOB_STATES.QUEUED, limit: 1000 }).length,
|
|
@@ -317,15 +380,21 @@ function createWebRouter({ services, config }){
|
|
|
317
380
|
.filter((j) => j.created_at >= dayAgo).length,
|
|
318
381
|
onlineCount = resources.filter((r) => r.status !== RESOURCE_STATES.OUT_OF_SERVICE && r.status !== RESOURCE_STATES.REGISTERED).length,
|
|
319
382
|
groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g]));
|
|
320
|
-
res.render('index', { title: 'Overview', active: 'overview', resources, queueLength, jobsLast24h, onlineCount, groupsById });
|
|
383
|
+
res.render('index', { title: 'Overview', active: 'overview', liveTopics: 'resources jobs', resources, queueLength, jobsLast24h, onlineCount, groupsById });
|
|
321
384
|
});
|
|
322
385
|
|
|
323
386
|
router.get('/resources', (req, res) => {
|
|
324
387
|
const groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g])),
|
|
325
|
-
|
|
388
|
+
q = search.normalizeQuery(req.query.q),
|
|
389
|
+
terms = search.parseTerms(q),
|
|
390
|
+
view = listView(req, res, 'resources', { q }),
|
|
326
391
|
{ sort, dir, size } = view.prefs,
|
|
327
392
|
value = RESOURCE_SORT_VALUE[sort],
|
|
328
|
-
sorted = services.registry.list().
|
|
393
|
+
sorted = services.registry.list().filter((r) => search.matches([
|
|
394
|
+
r.id, r.name, r.reported_name, r.type, r.status, r.busy_source, r.busy_reason, r.client_version, r.remote_addr,
|
|
395
|
+
r.labels, r.group_ids, r.group_ids.map((id) => groupsById[id]?.name),
|
|
396
|
+
r.host_info?.hostname, (r.host_info?.addresses || []).map((a) => a.address)
|
|
397
|
+
], terms)).sort((a, b) => {
|
|
329
398
|
const va = value(a),
|
|
330
399
|
vb = value(b);
|
|
331
400
|
if (va == null || vb == null){
|
|
@@ -338,9 +407,10 @@ function createWebRouter({ services, config }){
|
|
|
338
407
|
res.render('resources/list', {
|
|
339
408
|
title: 'Resources',
|
|
340
409
|
active: 'resources',
|
|
410
|
+
liveTopics: 'resources',
|
|
341
411
|
resources: withActiveJobs(sorted.slice(pagination.offset, pagination.offset + pagination.limit)),
|
|
342
412
|
groupsById,
|
|
343
|
-
list: { ...view, pagination, pageUrl }
|
|
413
|
+
list: { ...view, pagination, pageUrl, q }
|
|
344
414
|
});
|
|
345
415
|
});
|
|
346
416
|
|
|
@@ -677,7 +747,11 @@ function createWebRouter({ services, config }){
|
|
|
677
747
|
}));
|
|
678
748
|
|
|
679
749
|
router.get('/jobs', (req, res) => {
|
|
680
|
-
const filters = {
|
|
750
|
+
const filters = {
|
|
751
|
+
state: req.query.state || undefined,
|
|
752
|
+
source: req.query.source || undefined,
|
|
753
|
+
q: search.normalizeQuery(req.query.q) || undefined
|
|
754
|
+
},
|
|
681
755
|
view = listView(req, res, 'jobs', filters),
|
|
682
756
|
{ rows, pagination } = services.jobs.page({ ...filters, ...view.prefs, page: req.query.page }),
|
|
683
757
|
jobs = rows.map((j) => ({
|
|
@@ -689,9 +763,10 @@ function createWebRouter({ services, config }){
|
|
|
689
763
|
res.render('jobs/list', {
|
|
690
764
|
title: 'Jobs',
|
|
691
765
|
active: 'jobs',
|
|
766
|
+
liveTopics: 'jobs',
|
|
692
767
|
jobs,
|
|
693
768
|
filters,
|
|
694
|
-
list: { ...view, pagination, pageUrl },
|
|
769
|
+
list: { ...view, pagination, pageUrl, q: filters.q },
|
|
695
770
|
// A row's Cancel comes back to this exact view (filters, sort, page).
|
|
696
771
|
returnTo: pageUrl(),
|
|
697
772
|
canManage: req.session.user.role === 'admin',
|
|
@@ -788,14 +863,19 @@ function createWebRouter({ services, config }){
|
|
|
788
863
|
// Client config) and Agents. Viewable by anyone logged in; only admins
|
|
789
864
|
// can create/rename/delete.
|
|
790
865
|
router.get('/groups', (req, res) => {
|
|
791
|
-
const
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
866
|
+
const q = search.normalizeQuery(req.query.q),
|
|
867
|
+
terms = search.parseTerms(q),
|
|
868
|
+
resources = services.registry.list(),
|
|
869
|
+
all = services.groups.list(),
|
|
870
|
+
groups = all
|
|
871
|
+
.filter((g) => search.matches([g.id, g.name, g.comment], terms))
|
|
872
|
+
.map((g) => ({ ...g, resourceCount: resources.filter((r) => r.group_ids.includes(g.id)).length }));
|
|
795
873
|
res.render('groups/list', {
|
|
796
874
|
title: 'Groups',
|
|
797
875
|
active: 'groups',
|
|
798
876
|
groups,
|
|
877
|
+
q,
|
|
878
|
+
totalCount: all.length,
|
|
799
879
|
canManage: req.session.user.role === 'admin'
|
|
800
880
|
});
|
|
801
881
|
});
|
|
@@ -816,13 +896,42 @@ function createWebRouter({ services, config }){
|
|
|
816
896
|
res.redirect('/groups');
|
|
817
897
|
});
|
|
818
898
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
})
|
|
899
|
+
// Agents page: searched (?q=), sorted and paged like Resources (§10.1).
|
|
900
|
+
// Also what registering one renders, with its token shown once.
|
|
901
|
+
function renderAgents(req, res, extra = {}){
|
|
902
|
+
const q = search.normalizeQuery(req.query.q),
|
|
903
|
+
terms = search.parseTerms(q),
|
|
904
|
+
view = listView(req, res, 'agents', { q }),
|
|
905
|
+
{ sort, dir, size } = view.prefs,
|
|
906
|
+
value = AGENT_SORT_VALUE[sort],
|
|
907
|
+
sorted = services.agents.list()
|
|
908
|
+
.filter((a) => search.matches([a.id, a.name, a.kind, a.version, a.revoked_at ? 'revoked' : 'active'], terms))
|
|
909
|
+
.sort((a, b) => {
|
|
910
|
+
const va = value(a),
|
|
911
|
+
vb = value(b);
|
|
912
|
+
if (va == null || vb == null){
|
|
913
|
+
return (va == null) - (vb == null);
|
|
914
|
+
}
|
|
915
|
+
return (dir === 'asc' ? 1 : -1) * compareResourceValues(sort, va, vb) || b.created_at.localeCompare(a.created_at);
|
|
916
|
+
}),
|
|
917
|
+
pagination = listPrefs.paginate(sorted.length, size, req.query.page),
|
|
918
|
+
pageUrl = (changes) => view.urlFor({ page: pagination.page, ...changes });
|
|
919
|
+
res.render('admin/agents', {
|
|
920
|
+
title: 'Agents',
|
|
921
|
+
active: 'agents',
|
|
922
|
+
liveTopics: 'agents',
|
|
923
|
+
agents: sorted.slice(pagination.offset, pagination.offset + pagination.limit),
|
|
924
|
+
q,
|
|
925
|
+
list: { ...view, pagination, pageUrl, q },
|
|
926
|
+
...extra
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
router.get('/admin/agents', requireAdminRole, (req, res) => renderAgents(req, res));
|
|
822
931
|
|
|
823
932
|
router.post('/admin/agents', requireAdminRole, (req, res) => {
|
|
824
|
-
const {
|
|
825
|
-
|
|
933
|
+
const { token } = services.agents.create({ name: req.body.name, kind: req.body.kind });
|
|
934
|
+
renderAgents(req, res, { newToken: token });
|
|
826
935
|
});
|
|
827
936
|
|
|
828
937
|
router.post('/admin/agents/:id/rename', requireAdminRole, (req, res) => {
|
|
@@ -836,12 +945,12 @@ function createWebRouter({ services, config }){
|
|
|
836
945
|
catch (err){
|
|
837
946
|
flash(req, 'danger', err.message);
|
|
838
947
|
}
|
|
839
|
-
res.redirect('/admin/agents');
|
|
948
|
+
res.redirect(returnTo(req, '/admin/agents'));
|
|
840
949
|
});
|
|
841
950
|
|
|
842
951
|
router.post('/admin/agents/:id/revoke', requireAdminRole, (req, res) => {
|
|
843
952
|
services.agents.revoke(req.params.id);
|
|
844
|
-
res.redirect('/admin/agents');
|
|
953
|
+
res.redirect(returnTo(req, '/admin/agents'));
|
|
845
954
|
});
|
|
846
955
|
|
|
847
956
|
return router;
|
package/test/list-prefs.test.js
CHANGED
|
@@ -87,3 +87,9 @@ test('jobs.page sorts and pages in SQL, with empty values last', () => {
|
|
|
87
87
|
assert.equal(second.pagination.pages, 2);
|
|
88
88
|
assert.equal(jobs.page({ state: 'PASSED' }).pagination.total, 0);
|
|
89
89
|
});
|
|
90
|
+
|
|
91
|
+
test('agents list: sortable columns, newest first by default', () => {
|
|
92
|
+
assert.deepEqual(normalize('agents'), { size: 25, sort: 'created', dir: 'desc' });
|
|
93
|
+
assert.deepEqual(normalize('agents', { size: 10, sort: 'used', dir: 'asc' }), { size: 10, sort: 'used', dir: 'asc' });
|
|
94
|
+
assert.equal(normalize('agents', { sort: 'token' }).sort, 'created'); // not a column: default
|
|
95
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/live.test.js
|
|
3
|
+
* @description Tests: dashboard live updates — change detection per topic, the GET /live stream, and passive re-fetches that leave the session alone
|
|
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
|
+
{ EventEmitter } = require('node:events'),
|
|
22
|
+
{ openDb } = require('../src/db'),
|
|
23
|
+
{ createEventsService } = require('../src/services/events'),
|
|
24
|
+
{ createRegistryService } = require('../src/services/registry'),
|
|
25
|
+
{ createAgentsService } = require('../src/services/agents'),
|
|
26
|
+
{ createLiveService } = require('../src/services/live'),
|
|
27
|
+
{ loadConfig } = require('../src/config'),
|
|
28
|
+
{ buildServices, createApp } = require('../src/server');
|
|
29
|
+
|
|
30
|
+
test('live: reports exactly the topics whose data changed, only while subscribed', async () => {
|
|
31
|
+
const db = openDb(':memory:'),
|
|
32
|
+
bus = new EventEmitter(),
|
|
33
|
+
events = createEventsService(db),
|
|
34
|
+
registry = createRegistryService(db, { bus, events }),
|
|
35
|
+
agents = createAgentsService(db, { events }),
|
|
36
|
+
live = createLiveService(db, { bus, intervalMs: 60_000, debounceMs: 10 }),
|
|
37
|
+
seen = [];
|
|
38
|
+
|
|
39
|
+
assert.deepEqual(live.check(), []); // nobody listening: nothing computed
|
|
40
|
+
const unsubscribe = live.subscribe((topics) => seen.push(topics));
|
|
41
|
+
assert.equal(live.subscriberCount(), 1);
|
|
42
|
+
assert.deepEqual(live.check(), []); // nothing changed yet
|
|
43
|
+
|
|
44
|
+
registry.registerAuto({ clientId: 'c1', name: 'lab-hw-01', type: 'hw', labels: [] });
|
|
45
|
+
assert.deepEqual(live.check(), ['resources']);
|
|
46
|
+
agents.create({ name: 'ci', kind: 'ci' });
|
|
47
|
+
assert.deepEqual(live.check(), ['agents']);
|
|
48
|
+
assert.deepEqual(live.check(), []);
|
|
49
|
+
|
|
50
|
+
// A bus event triggers a check by itself (debounced), without the timer.
|
|
51
|
+
db.prepare('INSERT INTO jobs (id, agent_id, source, state, spec, priority, attempt, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
|
52
|
+
.run('M-00001', agents.list()[0].id, 'cli', 'QUEUED', '{}', 60, 0, new Date().toISOString());
|
|
53
|
+
bus.emit('job.queued', { jobId: 'M-00001' });
|
|
54
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
55
|
+
assert.deepEqual(seen.at(-1), ['jobs']);
|
|
56
|
+
|
|
57
|
+
unsubscribe();
|
|
58
|
+
assert.equal(live.subscriberCount(), 0);
|
|
59
|
+
assert.equal(bus.listenerCount('job.queued'), 0); // stopped listening
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// --- over HTTP: /live and passive re-fetches ------------------------------
|
|
63
|
+
|
|
64
|
+
function startApp(t){
|
|
65
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-live-')),
|
|
66
|
+
file = path.join(dir, 'coordinator.json');
|
|
67
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
68
|
+
dataDir: path.join(dir, 'data'), sessionSecret: 'test-secret-test-secret', updates: { checkIntervalMin: 0 }
|
|
69
|
+
}));
|
|
70
|
+
const config = loadConfig(file),
|
|
71
|
+
services = buildServices(config),
|
|
72
|
+
server = createApp(config, services).listen(0, '127.0.0.1');
|
|
73
|
+
services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
|
|
74
|
+
t.after(() => new Promise((r) => {
|
|
75
|
+
server.closeAllConnections?.();
|
|
76
|
+
server.close(r);
|
|
77
|
+
}));
|
|
78
|
+
return new Promise((r) => server.on('listening', () => r({ base: `http://127.0.0.1:${server.address().port}`, services })));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const expiresOf = (res) => {
|
|
82
|
+
const m = /Expires=([^;]+)/.exec(res.headers.get('set-cookie') || '');
|
|
83
|
+
return m ? Date.parse(m[1]) : null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
async function login(base){
|
|
87
|
+
const res = await fetch(`${base}/login`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
body: new URLSearchParams({ username: 'admin', password: 'pw' }),
|
|
90
|
+
redirect: 'manual'
|
|
91
|
+
});
|
|
92
|
+
return { cookie: res.headers.get('set-cookie').split(';')[0], expires: expiresOf(res) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test('live: /live streams hello and changes; passive re-fetches keep the idle timeout and flash', async (t) => {
|
|
96
|
+
const { base, services } = await startApp(t),
|
|
97
|
+
{ cookie, expires } = await login(base);
|
|
98
|
+
assert.ok(expires);
|
|
99
|
+
|
|
100
|
+
// The stream: hello, then `changed` when a resource appears.
|
|
101
|
+
const ctrl = new AbortController(),
|
|
102
|
+
stream = await fetch(`${base}/live`, { headers: { cookie }, signal: ctrl.signal }),
|
|
103
|
+
reader = stream.body.getReader(),
|
|
104
|
+
decoder = new TextDecoder();
|
|
105
|
+
assert.equal(stream.headers.get('content-type'), 'text/event-stream');
|
|
106
|
+
let text = '';
|
|
107
|
+
const until = async (re) => {
|
|
108
|
+
while (!re.test(text)){
|
|
109
|
+
const { value, done } = await reader.read();
|
|
110
|
+
if (done){
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
text += decoder.decode(value);
|
|
114
|
+
}
|
|
115
|
+
return re.exec(text);
|
|
116
|
+
};
|
|
117
|
+
assert.ok(await until(/event: hello/));
|
|
118
|
+
assert.equal(services.live.subscriberCount(), 1);
|
|
119
|
+
services.registry.registerAuto({ clientId: 'c1', name: 'lab-sw-01', type: 'sw', labels: [] });
|
|
120
|
+
services.live.check();
|
|
121
|
+
assert.match((await until(/event: changed\ndata: (.*)\n/))[1], /"resources"/);
|
|
122
|
+
ctrl.abort();
|
|
123
|
+
|
|
124
|
+
// A flash waiting for the next page view (a real request: it sets the
|
|
125
|
+
// expiry the passive one below must leave alone)…
|
|
126
|
+
const flashing = await fetch(`${base}/profile/password`, {
|
|
127
|
+
method: 'POST', headers: { cookie }, body: new URLSearchParams({ currentPassword: 'wrong', newPassword: 'a', confirmPassword: 'b' }),
|
|
128
|
+
redirect: 'manual'
|
|
129
|
+
}),
|
|
130
|
+
lastActive = expiresOf(flashing);
|
|
131
|
+
assert.ok(lastActive >= expires);
|
|
132
|
+
// Wait past a whole second, so a refreshed expiry would differ.
|
|
133
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
134
|
+
|
|
135
|
+
// …survives a passive re-fetch, which also doesn't push the expiry out.
|
|
136
|
+
const passive = await fetch(`${base}/resources`, { headers: { cookie, 'X-Thub-Live': '1' } });
|
|
137
|
+
assert.equal(passive.status, 200);
|
|
138
|
+
assert.match(passive.headers.get('cache-control'), /no-store/);
|
|
139
|
+
const passiveExpires = expiresOf(passive);
|
|
140
|
+
assert.ok(passiveExpires === null || passiveExpires === lastActive, 'passive request must not extend the session');
|
|
141
|
+
assert.match(await passive.text(), /data-live="rows"/);
|
|
142
|
+
|
|
143
|
+
// A real page view gets the flash, and extends the session as before.
|
|
144
|
+
const real = await fetch(`${base}/profile`, { headers: { cookie } });
|
|
145
|
+
assert.ok(expiresOf(real) > lastActive);
|
|
146
|
+
assert.match(await real.text(), /data-thub-toast="danger"/);
|
|
147
|
+
|
|
148
|
+
// Signed out: the stream is refused (EventSource then stops), and a
|
|
149
|
+
// passive re-fetch is redirected to the login page.
|
|
150
|
+
await fetch(`${base}/logout`, { method: 'POST', headers: { cookie }, redirect: 'manual' });
|
|
151
|
+
assert.equal((await fetch(`${base}/live`, { headers: { cookie, accept: 'text/event-stream' } })).status, 401);
|
|
152
|
+
assert.equal((await fetch(`${base}/resources`, { headers: { cookie, 'X-Thub-Live': '1' }, redirect: 'manual' })).status, 302);
|
|
153
|
+
});
|