@andrian.yablonskyy/thub-coordinator 1.0.37 → 1.0.39
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/bin/thub-admin.js +3 -5
- package/config.json +1 -6
- 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 +289 -45
- package/public/js/remove-resource.js +5 -3
- package/public/js/resource-card.js +14 -13
- package/public/js/tooltips.js +3 -1
- package/scripts/install-default-config.js +1 -1
- package/scripts/install-target.js +2 -2
- package/src/api/admin.js +1 -1
- package/src/api/agent.js +4 -10
- package/src/api/resource.js +6 -11
- package/src/api/sse.js +13 -10
- package/src/config.js +16 -9
- package/src/db/migrations/022_add_sessions.sql +10 -0
- package/src/db/migrations/023_drop_artifacts.sql +4 -0
- package/src/dev/virtual.js +1 -1
- package/src/server.js +55 -21
- package/src/services/cleanup.js +2 -2
- package/src/services/jobs.js +33 -19
- package/src/services/list-prefs.js +4 -0
- package/src/services/live.js +124 -0
- package/src/services/logs.js +12 -14
- package/src/services/retention.js +5 -14
- package/src/services/search.js +59 -0
- package/src/services/session-store.js +127 -0
- package/src/web/routes.js +205 -34
- package/test/cleanup.test.js +1 -5
- package/test/list-prefs.test.js +6 -0
- package/test/live.test.js +153 -0
- package/test/logs.test.js +150 -0
- package/test/scheduler.test.js +10 -12
- package/test/search.test.js +97 -0
- package/test/session.test.js +132 -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 +137 -0
- package/views/help/_docker.pug +124 -0
- package/views/help/_env.pug +127 -0
- package/views/help/_git.pug +130 -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 +44 -0
- package/views/help/index.pug +67 -0
- package/views/index.pug +3 -2
- package/views/jobs/list.pug +8 -5
- package/views/jobs/show.pug +0 -10
- package/views/layout.pug +24 -1
- package/views/mixins/list-controls.pug +36 -0
- package/views/mixins/log-viewer.pug +39 -3
- package/views/mixins/resource.pug +1 -1
- package/views/resources/list.pug +7 -4
- package/src/services/artifacts.js +0 -149
package/src/web/routes.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
const fs = require('node:fs'),
|
|
17
17
|
os = require('node:os'),
|
|
18
18
|
path = require('node:path'),
|
|
19
|
+
{ once } = require('node:events'),
|
|
19
20
|
express = require('express'),
|
|
20
21
|
multer = require('multer'),
|
|
21
22
|
{ requireAdminSession, requireAdminRole } = require('../auth'),
|
|
@@ -25,11 +26,16 @@ const fs = require('node:fs'),
|
|
|
25
26
|
nextCronRun
|
|
26
27
|
} = require('@andrian.yablonskyy/thub-common'),
|
|
27
28
|
{ retentionCutoff, JOB_RETENTION } = require('../services/cleanup'),
|
|
28
|
-
listPrefs = require('../services/list-prefs')
|
|
29
|
+
listPrefs = require('../services/list-prefs'),
|
|
30
|
+
search = require('../services/search');
|
|
29
31
|
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
// Job log viewer: lines per page it loads (and the most one request may ask for).
|
|
33
|
+
const LOG_PAGE = 1000,
|
|
34
|
+
LOG_PAGE_MAX = 5000,
|
|
35
|
+
|
|
36
|
+
// Resources page sort keys (list-prefs.js) -> comparators. Empty values
|
|
37
|
+
// (never heartbeated, no version reported) sort last in both directions.
|
|
38
|
+
RESOURCE_SORT_VALUE = {
|
|
33
39
|
name: (r) => r.name,
|
|
34
40
|
type: (r) => r.type,
|
|
35
41
|
version: (r) => r.client_version,
|
|
@@ -38,6 +44,15 @@ const RESOURCE_SORT_VALUE = {
|
|
|
38
44
|
},
|
|
39
45
|
compareResourceValues = (sort, a, b) =>
|
|
40
46
|
sort === 'version' ? compareVersions(a, b) : String(a).localeCompare(String(b), undefined, { numeric: true }),
|
|
47
|
+
// Agents page (list-prefs `agents`): never-used / never-reported last.
|
|
48
|
+
AGENT_SORT_VALUE = {
|
|
49
|
+
name: (a) => a.name,
|
|
50
|
+
kind: (a) => a.kind,
|
|
51
|
+
version: (a) => a.version,
|
|
52
|
+
created: (a) => a.created_at,
|
|
53
|
+
used: (a) => a.last_used_at,
|
|
54
|
+
status: (a) => (a.revoked_at ? 'revoked' : 'active')
|
|
55
|
+
},
|
|
41
56
|
|
|
42
57
|
// §10.1: avatar uploads are small, single images — a hard size cap and an
|
|
43
58
|
// allow-list of image mimetypes, same spirit as the join-key/token checks
|
|
@@ -144,7 +159,7 @@ function createWebRouter({ services, config }){
|
|
|
144
159
|
// error toast (never auto-closes) — on a page view, not the polling
|
|
145
160
|
// endpoint or a POST whose redirect would drop it.
|
|
146
161
|
const updateError = res.locals.updates.coordinatorUpdateError;
|
|
147
|
-
if (updateError && user?.role === 'admin' && req.method === 'GET' && req.path !== '/updates/status' &&
|
|
162
|
+
if (updateError && user?.role === 'admin' && req.method === 'GET' && req.path !== '/updates/status' && !req.thubPassive &&
|
|
148
163
|
req.session.seenUpdateError !== updateError.at){
|
|
149
164
|
req.session.seenUpdateError = updateError.at;
|
|
150
165
|
res.locals.messages = [
|
|
@@ -156,7 +171,9 @@ function createWebRouter({ services, config }){
|
|
|
156
171
|
res.locals.nextCronRun = nextCronRun;
|
|
157
172
|
res.locals.formatDateTime = formatDateTime;
|
|
158
173
|
res.locals.currentPath = req.originalUrl;
|
|
159
|
-
|
|
174
|
+
// A live-update re-fetch (server.js, req.thubPassive) renders the page
|
|
175
|
+
// only for its data: flash messages stay for the next real page view.
|
|
176
|
+
if (req.session && !req.thubPassive){
|
|
160
177
|
req.session.flash = [];
|
|
161
178
|
}
|
|
162
179
|
|
|
@@ -181,21 +198,45 @@ function createWebRouter({ services, config }){
|
|
|
181
198
|
// so a mid-session profile change takes effect immediately, and so
|
|
182
199
|
// `rolling: true` (server.js) actually extends by *this* user's chosen
|
|
183
200
|
// duration each time, not whatever the session happened to start with.
|
|
184
|
-
if (req.session && user){
|
|
201
|
+
if (req.session && user && !req.thubPassive){
|
|
185
202
|
req.session.cookie.maxAge = user.sessionTimeoutMin * 60 * 1000;
|
|
186
203
|
}
|
|
204
|
+
if (req.thubPassive){
|
|
205
|
+
res.set('Cache-Control', 'no-store');
|
|
206
|
+
}
|
|
187
207
|
|
|
188
208
|
next();
|
|
189
209
|
});
|
|
190
210
|
|
|
211
|
+
// A Secure-only session cookie (server.js cookieSecure, §12) is never
|
|
212
|
+
// sent back over plain HTTP, so signing in would just loop back here.
|
|
213
|
+
// Say why instead — usually a proxy not passing X-Forwarded-Proto.
|
|
214
|
+
function httpsProblem(req){
|
|
215
|
+
if (req.session.cookie.secure !== true || req.secure){
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
type: 'danger',
|
|
220
|
+
sticky: true,
|
|
221
|
+
text: `Signing in needs HTTPS: this Coordinator's session cookie is sent only over HTTPS (publicUrl is ${config.publicUrl}), ` +
|
|
222
|
+
'but this request reached it as plain HTTP. Open the dashboard through that https:// address; behind a reverse proxy, ' +
|
|
223
|
+
'make it send "X-Forwarded-Proto: https" and check trustProxy. To allow plain HTTP, set session.secureCookie to false.'
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
191
227
|
router.get('/login', (req, res) => {
|
|
192
228
|
if (req.session.user){
|
|
193
229
|
return res.redirect('/');
|
|
194
230
|
}
|
|
195
|
-
|
|
231
|
+
const problem = httpsProblem(req);
|
|
232
|
+
res.render('login', { title: 'Sign in', ...(problem ? { messages: [problem] } : {}) });
|
|
196
233
|
});
|
|
197
234
|
|
|
198
235
|
router.post('/login', (req, res) => {
|
|
236
|
+
const problem = httpsProblem(req);
|
|
237
|
+
if (problem){
|
|
238
|
+
return res.status(400).render('login', { title: 'Sign in', messages: [problem] });
|
|
239
|
+
}
|
|
199
240
|
const { username, password } = req.body,
|
|
200
241
|
user = services.adminUsers.verify(username, password);
|
|
201
242
|
if (!user){
|
|
@@ -308,6 +349,54 @@ function createWebRouter({ services, config }){
|
|
|
308
349
|
res.redirect('/profile');
|
|
309
350
|
});
|
|
310
351
|
|
|
352
|
+
// Live updates (services/live.js, public/js/live.js): which topics
|
|
353
|
+
// changed — `resources`, `agents`, `jobs` — never the data itself; the
|
|
354
|
+
// page re-fetches itself for that, with the same session and templates.
|
|
355
|
+
// A passive request (server.js): it doesn't extend the idle timeout, and
|
|
356
|
+
// it ends with `session-ended` once the session expires or logs out.
|
|
357
|
+
const LIVE_PING_MS = 25_000,
|
|
358
|
+
LIVE_SESSION_CHECK_MS = 15_000;
|
|
359
|
+
router.get('/live', (req, res) => {
|
|
360
|
+
res.writeHead(200, {
|
|
361
|
+
'Content-Type': 'text/event-stream',
|
|
362
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
363
|
+
Connection: 'keep-alive',
|
|
364
|
+
'X-Accel-Buffering': 'no'
|
|
365
|
+
});
|
|
366
|
+
res.flushHeaders?.();
|
|
367
|
+
const write = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
368
|
+
// retry: how soon EventSource reconnects after the connection drops.
|
|
369
|
+
res.write('retry: 5000\n\n');
|
|
370
|
+
write('hello', { topics: services.live.TOPICS });
|
|
371
|
+
|
|
372
|
+
const unsubscribe = services.live.subscribe((topics) => write('changed', { topics })),
|
|
373
|
+
// Comment lines keep proxies from timing the idle stream out.
|
|
374
|
+
ping = setInterval(() => res.write(': ping\n\n'), LIVE_PING_MS),
|
|
375
|
+
sessionCheck = setInterval(() => {
|
|
376
|
+
req.sessionStore.get(req.sessionID, (err, sess) => {
|
|
377
|
+
if (!err && !sess?.user){
|
|
378
|
+
write('session-ended', {});
|
|
379
|
+
res.end();
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
}, LIVE_SESSION_CHECK_MS);
|
|
383
|
+
req.on('close', () => {
|
|
384
|
+
unsubscribe();
|
|
385
|
+
clearInterval(ping);
|
|
386
|
+
clearInterval(sessionCheck);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Help (views/help/): product guide and setup reference for every
|
|
391
|
+
// signed-in user. Examples use this Coordinator's URL — the configured
|
|
392
|
+
// publicUrl, unless that's still the localhost default and the page was
|
|
393
|
+
// opened from elsewhere.
|
|
394
|
+
router.get('/help', (req, res) => {
|
|
395
|
+
const origin = `${req.protocol}://${req.get('host')}`,
|
|
396
|
+
coordinatorUrl = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/?$/.test(config.publicUrl || '') ? origin : config.publicUrl;
|
|
397
|
+
res.render('help/index', { title: 'Help', active: 'help', coordinatorUrl: String(coordinatorUrl).replace(/\/+$/, '') });
|
|
398
|
+
});
|
|
399
|
+
|
|
311
400
|
router.get('/', (req, res) => {
|
|
312
401
|
const resources = withActiveJobs(services.registry.list()),
|
|
313
402
|
queueLength = services.jobs.list({ state: JOB_STATES.QUEUED, limit: 1000 }).length,
|
|
@@ -317,15 +406,21 @@ function createWebRouter({ services, config }){
|
|
|
317
406
|
.filter((j) => j.created_at >= dayAgo).length,
|
|
318
407
|
onlineCount = resources.filter((r) => r.status !== RESOURCE_STATES.OUT_OF_SERVICE && r.status !== RESOURCE_STATES.REGISTERED).length,
|
|
319
408
|
groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g]));
|
|
320
|
-
res.render('index', { title: 'Overview', active: 'overview', resources, queueLength, jobsLast24h, onlineCount, groupsById });
|
|
409
|
+
res.render('index', { title: 'Overview', active: 'overview', liveTopics: 'resources jobs', resources, queueLength, jobsLast24h, onlineCount, groupsById });
|
|
321
410
|
});
|
|
322
411
|
|
|
323
412
|
router.get('/resources', (req, res) => {
|
|
324
413
|
const groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g])),
|
|
325
|
-
|
|
414
|
+
q = search.normalizeQuery(req.query.q),
|
|
415
|
+
terms = search.parseTerms(q),
|
|
416
|
+
view = listView(req, res, 'resources', { q }),
|
|
326
417
|
{ sort, dir, size } = view.prefs,
|
|
327
418
|
value = RESOURCE_SORT_VALUE[sort],
|
|
328
|
-
sorted = services.registry.list().
|
|
419
|
+
sorted = services.registry.list().filter((r) => search.matches([
|
|
420
|
+
r.id, r.name, r.reported_name, r.type, r.status, r.busy_source, r.busy_reason, r.client_version, r.remote_addr,
|
|
421
|
+
r.labels, r.group_ids, r.group_ids.map((id) => groupsById[id]?.name),
|
|
422
|
+
r.host_info?.hostname, (r.host_info?.addresses || []).map((a) => a.address)
|
|
423
|
+
], terms)).sort((a, b) => {
|
|
329
424
|
const va = value(a),
|
|
330
425
|
vb = value(b);
|
|
331
426
|
if (va == null || vb == null){
|
|
@@ -338,9 +433,10 @@ function createWebRouter({ services, config }){
|
|
|
338
433
|
res.render('resources/list', {
|
|
339
434
|
title: 'Resources',
|
|
340
435
|
active: 'resources',
|
|
436
|
+
liveTopics: 'resources',
|
|
341
437
|
resources: withActiveJobs(sorted.slice(pagination.offset, pagination.offset + pagination.limit)),
|
|
342
438
|
groupsById,
|
|
343
|
-
list: { ...view, pagination, pageUrl }
|
|
439
|
+
list: { ...view, pagination, pageUrl, q }
|
|
344
440
|
});
|
|
345
441
|
});
|
|
346
442
|
|
|
@@ -677,7 +773,11 @@ function createWebRouter({ services, config }){
|
|
|
677
773
|
}));
|
|
678
774
|
|
|
679
775
|
router.get('/jobs', (req, res) => {
|
|
680
|
-
const filters = {
|
|
776
|
+
const filters = {
|
|
777
|
+
state: req.query.state || undefined,
|
|
778
|
+
source: req.query.source || undefined,
|
|
779
|
+
q: search.normalizeQuery(req.query.q) || undefined
|
|
780
|
+
},
|
|
681
781
|
view = listView(req, res, 'jobs', filters),
|
|
682
782
|
{ rows, pagination } = services.jobs.page({ ...filters, ...view.prefs, page: req.query.page }),
|
|
683
783
|
jobs = rows.map((j) => ({
|
|
@@ -689,9 +789,10 @@ function createWebRouter({ services, config }){
|
|
|
689
789
|
res.render('jobs/list', {
|
|
690
790
|
title: 'Jobs',
|
|
691
791
|
active: 'jobs',
|
|
792
|
+
liveTopics: 'jobs',
|
|
692
793
|
jobs,
|
|
693
794
|
filters,
|
|
694
|
-
list: { ...view, pagination, pageUrl },
|
|
795
|
+
list: { ...view, pagination, pageUrl, q: filters.q },
|
|
695
796
|
// A row's Cancel comes back to this exact view (filters, sort, page).
|
|
696
797
|
returnTo: pageUrl(),
|
|
697
798
|
canManage: req.session.user.role === 'admin',
|
|
@@ -711,7 +812,7 @@ function createWebRouter({ services, config }){
|
|
|
711
812
|
});
|
|
712
813
|
|
|
713
814
|
// "Clean up database" (§9): everything before the given time — finished
|
|
714
|
-
// jobs with their logs
|
|
815
|
+
// jobs with their logs, and older history — then VACUUM. The
|
|
715
816
|
// time is dd/mm/yyyy HH:MM:SS in the user's own time zone.
|
|
716
817
|
router.post('/jobs/clean-history', requireAdminRole, (req, res) => {
|
|
717
818
|
const tz = req.session.user.timezone || 'UTC',
|
|
@@ -743,8 +844,7 @@ function createWebRouter({ services, config }){
|
|
|
743
844
|
return res.status(404).send('Not found');
|
|
744
845
|
}
|
|
745
846
|
job.resource = job.resource_id ? services.registry.get(job.resource_id) : null;
|
|
746
|
-
const
|
|
747
|
-
jobActive = ACTIVE_JOB_STATES.has(job.state);
|
|
847
|
+
const jobActive = ACTIVE_JOB_STATES.has(job.state);
|
|
748
848
|
res.render('jobs/show', {
|
|
749
849
|
title: job.id,
|
|
750
850
|
active: 'jobs',
|
|
@@ -754,7 +854,6 @@ function createWebRouter({ services, config }){
|
|
|
754
854
|
? services.registry.waitingReason(job.spec.target.type, job.spec.target.labels || [], job.spec.target.group,
|
|
755
855
|
job.spec.target.client)
|
|
756
856
|
: null,
|
|
757
|
-
artifacts,
|
|
758
857
|
jobActive,
|
|
759
858
|
canCancel: req.session.user.role === 'admin' && jobActive
|
|
760
859
|
});
|
|
@@ -772,15 +871,53 @@ function createWebRouter({ services, config }){
|
|
|
772
871
|
res.redirect(returnTo(req, `/jobs/${req.params.id}`));
|
|
773
872
|
});
|
|
774
873
|
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
874
|
+
// The log viewer's pages (public/js/log-viewer.js): the last `limit`
|
|
875
|
+
// lines first, then earlier ones (`before` = the oldest seq it has) as the
|
|
876
|
+
// reader scrolls up. `total` is how many lines the job has in all.
|
|
778
877
|
router.get('/jobs/:id/logs', (req, res) => {
|
|
779
|
-
|
|
878
|
+
const before = Number.parseInt(req.query.before, 10),
|
|
879
|
+
limit = Math.min(Math.max(Number.parseInt(req.query.limit, 10) || LOG_PAGE, 1), LOG_PAGE_MAX),
|
|
880
|
+
page = services.logs.pageBefore(req.params.id, before > 0 ? before : undefined, limit);
|
|
881
|
+
res.json({ ...page, total: services.logs.count(req.params.id) });
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
// RAW (log viewer): the whole log as plain text, for a new tab, a
|
|
885
|
+
// download or grep. Streamed in pages, waiting for the socket to drain,
|
|
886
|
+
// so a huge log never sits in memory. Log lines live as long as their job
|
|
887
|
+
// (§9), so this is always the complete log.
|
|
888
|
+
router.get('/jobs/:id/log.txt', async (req, res, next) => {
|
|
889
|
+
try {
|
|
890
|
+
const job = services.jobs.get(req.params.id);
|
|
891
|
+
if (!job){
|
|
892
|
+
return res.status(404).type('text/plain').send('Unknown job');
|
|
893
|
+
}
|
|
894
|
+
res.set({
|
|
895
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
896
|
+
// The log is whatever the job printed: never let a browser sniff it
|
|
897
|
+
// into HTML.
|
|
898
|
+
'X-Content-Type-Options': 'nosniff',
|
|
899
|
+
'Content-Disposition': `inline; filename="${job.id}.log"`,
|
|
900
|
+
'Cache-Control': 'no-store'
|
|
901
|
+
});
|
|
902
|
+
for (let seq = 0, page; (page = services.logs.listSince(job.id, seq, LOG_PAGE_MAX)).length;){
|
|
903
|
+
const chunk = page.map((l) => `[${l.ts}] [${l.stream}] ${l.line}\n`).join('');
|
|
904
|
+
seq = page.at(-1).seq;
|
|
905
|
+
if (!res.write(chunk)){
|
|
906
|
+
await once(res, 'drain');
|
|
907
|
+
}
|
|
908
|
+
if (res.destroyed){
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
res.end();
|
|
913
|
+
}
|
|
914
|
+
catch (err){
|
|
915
|
+
next(err);
|
|
916
|
+
}
|
|
780
917
|
});
|
|
781
918
|
|
|
782
919
|
router.get('/jobs/:id/stream', (req, res) => {
|
|
783
|
-
attachJobStream(req, res, { jobId: req.params.id, services
|
|
920
|
+
attachJobStream(req, res, { jobId: req.params.id, services });
|
|
784
921
|
});
|
|
785
922
|
|
|
786
923
|
// Resource groups (§13.1) — their own page since they're a distinct
|
|
@@ -788,14 +925,19 @@ function createWebRouter({ services, config }){
|
|
|
788
925
|
// Client config) and Agents. Viewable by anyone logged in; only admins
|
|
789
926
|
// can create/rename/delete.
|
|
790
927
|
router.get('/groups', (req, res) => {
|
|
791
|
-
const
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
928
|
+
const q = search.normalizeQuery(req.query.q),
|
|
929
|
+
terms = search.parseTerms(q),
|
|
930
|
+
resources = services.registry.list(),
|
|
931
|
+
all = services.groups.list(),
|
|
932
|
+
groups = all
|
|
933
|
+
.filter((g) => search.matches([g.id, g.name, g.comment], terms))
|
|
934
|
+
.map((g) => ({ ...g, resourceCount: resources.filter((r) => r.group_ids.includes(g.id)).length }));
|
|
795
935
|
res.render('groups/list', {
|
|
796
936
|
title: 'Groups',
|
|
797
937
|
active: 'groups',
|
|
798
938
|
groups,
|
|
939
|
+
q,
|
|
940
|
+
totalCount: all.length,
|
|
799
941
|
canManage: req.session.user.role === 'admin'
|
|
800
942
|
});
|
|
801
943
|
});
|
|
@@ -816,13 +958,42 @@ function createWebRouter({ services, config }){
|
|
|
816
958
|
res.redirect('/groups');
|
|
817
959
|
});
|
|
818
960
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
})
|
|
961
|
+
// Agents page: searched (?q=), sorted and paged like Resources (§10.1).
|
|
962
|
+
// Also what registering one renders, with its token shown once.
|
|
963
|
+
function renderAgents(req, res, extra = {}){
|
|
964
|
+
const q = search.normalizeQuery(req.query.q),
|
|
965
|
+
terms = search.parseTerms(q),
|
|
966
|
+
view = listView(req, res, 'agents', { q }),
|
|
967
|
+
{ sort, dir, size } = view.prefs,
|
|
968
|
+
value = AGENT_SORT_VALUE[sort],
|
|
969
|
+
sorted = services.agents.list()
|
|
970
|
+
.filter((a) => search.matches([a.id, a.name, a.kind, a.version, a.revoked_at ? 'revoked' : 'active'], terms))
|
|
971
|
+
.sort((a, b) => {
|
|
972
|
+
const va = value(a),
|
|
973
|
+
vb = value(b);
|
|
974
|
+
if (va == null || vb == null){
|
|
975
|
+
return (va == null) - (vb == null);
|
|
976
|
+
}
|
|
977
|
+
return (dir === 'asc' ? 1 : -1) * compareResourceValues(sort, va, vb) || b.created_at.localeCompare(a.created_at);
|
|
978
|
+
}),
|
|
979
|
+
pagination = listPrefs.paginate(sorted.length, size, req.query.page),
|
|
980
|
+
pageUrl = (changes) => view.urlFor({ page: pagination.page, ...changes });
|
|
981
|
+
res.render('admin/agents', {
|
|
982
|
+
title: 'Agents',
|
|
983
|
+
active: 'agents',
|
|
984
|
+
liveTopics: 'agents',
|
|
985
|
+
agents: sorted.slice(pagination.offset, pagination.offset + pagination.limit),
|
|
986
|
+
q,
|
|
987
|
+
list: { ...view, pagination, pageUrl, q },
|
|
988
|
+
...extra
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
router.get('/admin/agents', requireAdminRole, (req, res) => renderAgents(req, res));
|
|
822
993
|
|
|
823
994
|
router.post('/admin/agents', requireAdminRole, (req, res) => {
|
|
824
|
-
const {
|
|
825
|
-
|
|
995
|
+
const { token } = services.agents.create({ name: req.body.name, kind: req.body.kind });
|
|
996
|
+
renderAgents(req, res, { newToken: token });
|
|
826
997
|
});
|
|
827
998
|
|
|
828
999
|
router.post('/admin/agents/:id/rename', requireAdminRole, (req, res) => {
|
|
@@ -836,12 +1007,12 @@ function createWebRouter({ services, config }){
|
|
|
836
1007
|
catch (err){
|
|
837
1008
|
flash(req, 'danger', err.message);
|
|
838
1009
|
}
|
|
839
|
-
res.redirect('/admin/agents');
|
|
1010
|
+
res.redirect(returnTo(req, '/admin/agents'));
|
|
840
1011
|
});
|
|
841
1012
|
|
|
842
1013
|
router.post('/admin/agents/:id/revoke', requireAdminRole, (req, res) => {
|
|
843
1014
|
services.agents.revoke(req.params.id);
|
|
844
|
-
res.redirect('/admin/agents');
|
|
1015
|
+
res.redirect(returnTo(req, '/admin/agents'));
|
|
845
1016
|
});
|
|
846
1017
|
|
|
847
1018
|
return router;
|
package/test/cleanup.test.js
CHANGED
|
@@ -23,7 +23,6 @@ const test = require('node:test'),
|
|
|
23
23
|
{ createEventsService } = require('../src/services/events'),
|
|
24
24
|
{ createRegistryService } = require('../src/services/registry'),
|
|
25
25
|
{ createAgentsService } = require('../src/services/agents'),
|
|
26
|
-
{ createArtifactsService } = require('../src/services/artifacts'),
|
|
27
26
|
{ createJobsService } = require('../src/services/jobs'),
|
|
28
27
|
{ createCleanupService, retentionCutoff } = require('../src/services/cleanup'),
|
|
29
28
|
{ JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
@@ -35,8 +34,6 @@ function setup(jobRetention = 'forever'){
|
|
|
35
34
|
config = {
|
|
36
35
|
dataDir,
|
|
37
36
|
dbPath: path.join(dataDir, 'thub.db'),
|
|
38
|
-
artifactsDir: path.join(dataDir, 'artifacts'),
|
|
39
|
-
artifacts: { linkTtlHours: 1 },
|
|
40
37
|
sessionSecret: 's',
|
|
41
38
|
publicUrl: 'http://x',
|
|
42
39
|
scheduler: { maxQueuedPerAgent: 1000 },
|
|
@@ -48,8 +45,7 @@ function setup(jobRetention = 'forever'){
|
|
|
48
45
|
events = createEventsService(db),
|
|
49
46
|
registry = createRegistryService(db, { bus, events }),
|
|
50
47
|
{ agent } = createAgentsService(db, { events }).create({ name: 'ci', kind: 'ci' }),
|
|
51
|
-
|
|
52
|
-
jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
|
|
48
|
+
jobs = createJobsService(db, { bus, events, registry, config }),
|
|
53
49
|
cleanup = createCleanupService(db, { jobs, config, now: () => NOW });
|
|
54
50
|
registry.registerAuto({ clientId: 'c', name: 'lab', type: 'sw', labels: [] });
|
|
55
51
|
|
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
|
+
});
|