@andrian.yablonskyy/thub-coordinator 1.0.38 → 1.0.40

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.
Files changed (64) hide show
  1. package/bin/thub-admin.js +26 -5
  2. package/config.json +5 -6
  3. package/package.json +2 -2
  4. package/public/js/actions.js +58 -0
  5. package/public/js/log-viewer.js +188 -56
  6. package/public/js/settings.js +66 -0
  7. package/scripts/install-default-config.js +1 -1
  8. package/scripts/install-target.js +2 -2
  9. package/src/api/admin.js +1 -1
  10. package/src/api/agent.js +28 -22
  11. package/src/api/resource.js +11 -14
  12. package/src/api/sse.js +13 -10
  13. package/src/config.js +33 -10
  14. package/src/db/migrations/022_add_sessions.sql +10 -0
  15. package/src/db/migrations/023_drop_artifacts.sql +4 -0
  16. package/src/db/migrations/024_add_settings.sql +10 -0
  17. package/src/db/migrations/025_add_job_artifacts.sql +4 -0
  18. package/src/dev/virtual.js +10 -6
  19. package/src/login-guard.js +111 -0
  20. package/src/rate-limit.js +137 -0
  21. package/src/security.js +104 -0
  22. package/src/server.js +53 -23
  23. package/src/services/admin-users.js +32 -19
  24. package/src/services/cleanup.js +2 -2
  25. package/src/services/heartbeat.js +23 -3
  26. package/src/services/job-artifacts.js +91 -0
  27. package/src/services/jobs.js +17 -9
  28. package/src/services/logs.js +12 -14
  29. package/src/services/registry.js +34 -0
  30. package/src/services/retention.js +5 -14
  31. package/src/services/session-store.js +127 -0
  32. package/src/services/settings.js +345 -0
  33. package/src/web/routes.js +230 -21
  34. package/test/admin-users.test.js +16 -16
  35. package/test/agent-visibility.test.js +85 -0
  36. package/test/cleanup.test.js +1 -5
  37. package/test/groups-tab.test.js +97 -0
  38. package/test/job-artifacts.test.js +115 -0
  39. package/test/login-guard.test.js +139 -0
  40. package/test/logs.test.js +150 -0
  41. package/test/rate-limit.test.js +106 -0
  42. package/test/scheduler.test.js +10 -12
  43. package/test/security.test.js +107 -0
  44. package/test/session.test.js +132 -0
  45. package/test/settings.test.js +206 -0
  46. package/views/admin/settings.pug +133 -0
  47. package/views/groups/list.pug +1 -1
  48. package/views/help/_agent-cli.pug +2 -2
  49. package/views/help/_agent-setup.pug +4 -1
  50. package/views/help/_client-setup.pug +1 -1
  51. package/views/help/_coordinator.pug +18 -7
  52. package/views/help/_docker.pug +1 -1
  53. package/views/help/_env.pug +2 -1
  54. package/views/help/_git.pug +3 -2
  55. package/views/help/_overview.pug +2 -2
  56. package/views/help/_troubleshooting.pug +4 -0
  57. package/views/jobs/list.pug +5 -5
  58. package/views/jobs/show.pug +33 -7
  59. package/views/layout.pug +4 -1
  60. package/views/mixins/client-config.pug +65 -3
  61. package/views/mixins/list-controls.pug +1 -1
  62. package/views/mixins/log-viewer.pug +14 -0
  63. package/views/mixins/resource.pug +1 -8
  64. package/src/services/artifacts.js +0 -149
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @file packages/coordinator/test/security.test.js
3
+ * @description Tests: security headers on every response, and the same-origin (CSRF) check on the dashboard's POSTs
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
+ { loadConfig } = require('../src/config'),
22
+ { buildServices, createApp } = require('../src/server');
23
+
24
+ async function start(t, extra = {}){
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-sec-')),
26
+ file = path.join(dir, 'coordinator.json');
27
+ fs.writeFileSync(file, JSON.stringify({
28
+ dataDir: path.join(dir, 'data'), sessionSecret: 'test-secret-test-secret', updates: { checkIntervalMin: 0 }, ...extra
29
+ }));
30
+ const config = loadConfig(file),
31
+ services = buildServices(config),
32
+ server = createApp(config, services).listen(0, '127.0.0.1');
33
+ t.after(() => new Promise((r) => {
34
+ server.closeAllConnections?.();
35
+ server.close(r);
36
+ }));
37
+ await new Promise((r) => server.on('listening', r));
38
+ services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
39
+ return { base: `http://127.0.0.1:${server.address().port}`, services };
40
+ }
41
+
42
+ test('security headers on pages, the API and static files; HSTS only over HTTPS', async (t) => {
43
+ const { base } = await start(t);
44
+ for (const url of ['/login', '/api/v1/resources', '/css/thub.css']){
45
+ const res = await fetch(`${base}${url}`),
46
+ csp = res.headers.get('content-security-policy');
47
+ assert.equal(res.headers.get('x-frame-options'), 'DENY', url);
48
+ assert.equal(res.headers.get('x-content-type-options'), 'nosniff', url);
49
+ assert.equal(res.headers.get('referrer-policy'), 'same-origin', url);
50
+ assert.equal(res.headers.get('x-powered-by'), null, url);
51
+ assert.equal(res.headers.get('strict-transport-security'), null, url); // plain HTTP
52
+ assert.match(csp, /default-src 'self'/);
53
+ assert.match(csp, /script-src 'self' https:\/\/cdn\.jsdelivr\.net(;|$)/); // no 'unsafe-inline' for scripts
54
+ assert.match(csp, /frame-ancestors 'none'/);
55
+ assert.match(csp, /object-src 'none'/);
56
+ }
57
+ // Behind the (loopback, trusted) TLS proxy.
58
+ const tls = await fetch(`${base}/login`, { headers: { 'X-Forwarded-Proto': 'https' } });
59
+ assert.match(tls.headers.get('strict-transport-security'), /^max-age=\d+$/);
60
+ });
61
+
62
+ test('same-origin check: POSTs from other sites are refused; own pages, publicUrl and non-browser clients pass', async (t) => {
63
+ const { base, services } = await start(t, { publicUrl: 'http://thub.lab.example' }),
64
+ post = (url, headers = {}) => fetch(`${base}${url}`, {
65
+ method: 'POST', headers, body: new URLSearchParams({ username: 'admin', password: 'pw' }), redirect: 'manual'
66
+ }),
67
+
68
+ // A forged cross-site form post (login CSRF here; any dashboard action alike).
69
+ evil = await post('/login', { Origin: 'https://evil.example' });
70
+ assert.equal(evil.status, 403);
71
+ assert.match(await evil.text(), /came from https:\/\/evil\.example, not from this dashboard \(http:\/\/thub\.lab\.example\)/);
72
+ assert.equal((await post('/login', { Origin: 'null' })).status, 403); // sandboxed iframe, data: URL
73
+ assert.equal((await post('/login', { Referer: 'https://evil.example/page' })).status, 403); // no Origin: Referer decides
74
+ assert.equal((await post('/login', { Origin: 'http://thub.lab.example:8443' })).status, 403); // another port is another origin
75
+
76
+ // The dashboard's own pages: the address it was opened at, or publicUrl
77
+ // (a reverse proxy that rewrites Host).
78
+ assert.equal((await post('/login', { Origin: base })).status, 302);
79
+ assert.equal((await post('/login', { Origin: 'http://thub.lab.example' })).status, 302);
80
+ assert.equal((await post('/login', { Referer: `${base}/login` })).status, 302);
81
+ // Neither header: not a browser page (curl, a script) — nothing to forge.
82
+ assert.equal((await post('/login')).status, 302);
83
+
84
+ // Reading is never blocked.
85
+ assert.equal((await fetch(`${base}/login`, { headers: { Origin: 'https://evil.example' } })).status, 200);
86
+
87
+ // The session-authenticated admin API is covered too (JSON answer)…
88
+ const admin = await fetch(`${base}/api/v1/admin/jobs/reset-queue`, { method: 'POST', headers: { Origin: 'https://evil.example' } });
89
+ assert.equal(admin.status, 403);
90
+ assert.match((await admin.json()).error, /Blocked/);
91
+ // …the bearer-token Agent API isn't: a browser never attaches that token by itself.
92
+ const { token } = services.agents.create({ name: 'ci', kind: 'ci' }),
93
+ agent = await fetch(`${base}/api/v1/jobs`, {
94
+ method: 'POST',
95
+ headers: { Origin: 'https://evil.example', authorization: `Bearer ${token}`, 'content-type': 'application/json' },
96
+ body: JSON.stringify({})
97
+ });
98
+ assert.notEqual(agent.status, 403); // 400 invalid spec — got past the check
99
+ });
100
+
101
+ test('templates carry no inline event handlers (the CSP would block them)', () => {
102
+ const views = path.join(__dirname, '..', 'views'),
103
+ files = fs.readdirSync(views, { recursive: true }).filter((f) => f.endsWith('.pug'));
104
+ for (const f of files){
105
+ assert.doesNotMatch(fs.readFileSync(path.join(views, f), 'utf8'), /\bon[a-z]+="/, f);
106
+ }
107
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @file packages/coordinator/test/session.test.js
3
+ * @description Tests: dashboard sessions — the SQLite session store, sessions surviving a restart, and the Secure cookie flag
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
+ { promisify } = require('node:util'),
22
+ { openDb } = require('../src/db'),
23
+ { SqliteSessionStore } = require('../src/services/session-store'),
24
+ { loadConfig } = require('../src/config'),
25
+ { buildServices, createApp, cookieSecure } = require('../src/server');
26
+
27
+ test('session store: get/set/touch/destroy, expired sessions dropped and pruned', async () => {
28
+ let now = Date.parse('2026-09-30T12:00:00Z');
29
+ const store = new SqliteSessionStore(openDb(':memory:'), { pruneIntervalMs: 0, now: () => now }),
30
+ get = promisify(store.get.bind(store)),
31
+ set = promisify(store.set.bind(store)),
32
+ touch = promisify(store.touch.bind(store)),
33
+ destroy = promisify(store.destroy.bind(store)),
34
+ length = promisify(store.length.bind(store)),
35
+ sess = (minutes) => ({ cookie: { expires: new Date(now + minutes * 60_000).toISOString() }, user: { id: 'u1' } });
36
+
37
+ await set('a', sess(60));
38
+ await set('b', sess(5));
39
+ assert.deepEqual((await get('a')).user, { id: 'u1' });
40
+ assert.equal(await get('nope'), null);
41
+ assert.equal(await length(), 2);
42
+
43
+ now += 10 * 60_000; // b's 5 minutes are up
44
+ assert.equal(await get('b'), null); // expired: gone on read
45
+ await touch('a', sess(60)); // activity moves a's expiry out
46
+ now += 55 * 60_000;
47
+ assert.ok(await get('a'));
48
+
49
+ await set('c', sess(1));
50
+ now += 2 * 60_000;
51
+ assert.equal(store.prune(), 1); // c, never read again, swept
52
+ assert.equal(await length(), 1);
53
+
54
+ await destroy('a'); // logout
55
+ assert.equal(await get('a'), null);
56
+ });
57
+
58
+ test('cookieSecure: Secure for an https publicUrl, per-request otherwise, or as configured', () => {
59
+ assert.equal(cookieSecure({ publicUrl: 'https://thub.example.com', session: { secureCookie: 'auto' } }), true);
60
+ assert.equal(cookieSecure({ publicUrl: 'http://localhost:8080', session: { secureCookie: 'auto' } }), 'auto');
61
+ assert.equal(cookieSecure({ publicUrl: 'https://thub.example.com', session: { secureCookie: false } }), false);
62
+ assert.equal(cookieSecure({ publicUrl: 'http://lab', session: { secureCookie: true } }), true);
63
+ });
64
+
65
+ // --- over HTTP ------------------------------------------------------------
66
+
67
+ function writeConfig(dir, extra = {}){
68
+ const file = path.join(dir, 'coordinator.json');
69
+ fs.writeFileSync(file, JSON.stringify({
70
+ dataDir: path.join(dir, 'data'), sessionSecret: 'test-secret-test-secret', updates: { checkIntervalMin: 0 }, ...extra
71
+ }));
72
+ return file;
73
+ }
74
+
75
+ async function start(t, file){
76
+ const config = loadConfig(file),
77
+ services = buildServices(config),
78
+ server = createApp(config, services).listen(0, '127.0.0.1');
79
+ await new Promise((r) => server.on('listening', r));
80
+ const stop = () => new Promise((r) => {
81
+ server.closeAllConnections?.();
82
+ server.close(r);
83
+ });
84
+ t.after(stop);
85
+ return { base: `http://127.0.0.1:${server.address().port}`, services, stop };
86
+ }
87
+
88
+ const login = (base, headers = {}) => fetch(`${base}/login`, {
89
+ method: 'POST', headers, body: new URLSearchParams({ username: 'admin', password: 'pw' }), redirect: 'manual'
90
+ });
91
+
92
+ test('a signed-in session survives a Coordinator restart', async (t) => {
93
+ const file = writeConfig(fs.mkdtempSync(path.join(os.tmpdir(), 'thub-sess-'))),
94
+ first = await start(t, file);
95
+ first.services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
96
+ const cookie = (await login(first.base)).headers.get('set-cookie').split(';')[0];
97
+ assert.equal((await fetch(`${first.base}/jobs`, { headers: { cookie }, redirect: 'manual' })).status, 200);
98
+ await first.stop();
99
+
100
+ const second = await start(t, file); // same database, new process state
101
+ assert.equal((await fetch(`${second.base}/jobs`, { headers: { cookie }, redirect: 'manual' })).status, 200);
102
+
103
+ await fetch(`${second.base}/logout`, { method: 'POST', headers: { cookie }, redirect: 'manual' });
104
+ assert.equal((await fetch(`${second.base}/jobs`, { headers: { cookie }, redirect: 'manual' })).status, 302);
105
+ });
106
+
107
+ test('https publicUrl: the session cookie is Secure; plain HTTP gets an explanation, not a login loop', async (t) => {
108
+ const file = writeConfig(fs.mkdtempSync(path.join(os.tmpdir(), 'thub-sess-')), { publicUrl: 'https://thub.example.com' }),
109
+ { base, services } = await start(t, file);
110
+ services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
111
+
112
+ // Behind the (loopback, trusted) reverse proxy terminating TLS.
113
+ const viaProxy = await login(base, { 'X-Forwarded-Proto': 'https' }),
114
+ setCookie = viaProxy.headers.get('set-cookie');
115
+ assert.equal(viaProxy.status, 302);
116
+ assert.match(setCookie, /; Secure/);
117
+ assert.match(setCookie, /; HttpOnly/);
118
+ assert.match(setCookie, /; SameSite=Lax/);
119
+
120
+ // The proxy forgot X-Forwarded-Proto: no cookie could come back — say so.
121
+ const plain = await login(base);
122
+ assert.equal(plain.status, 400);
123
+ assert.equal(plain.headers.get('set-cookie'), null);
124
+ assert.match(await plain.text(), /Signing in needs HTTPS/);
125
+ assert.match(await (await fetch(`${base}/login`)).text(), /X-Forwarded-Proto: https/);
126
+ });
127
+
128
+ test('config: session.secureCookie must be "auto", true or false', () => {
129
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-sess-'));
130
+ assert.equal(loadConfig(writeConfig(dir)).session.secureCookie, 'auto');
131
+ assert.throws(() => loadConfig(writeConfig(dir, { session: { secureCookie: 'yes' } })), /session\.secureCookie must be/);
132
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * @file packages/coordinator/test/settings.test.js
3
+ * @description Tests: Coordinator settings from the dashboard — precedence, validation, applying now vs after a restart, the page, thub-admin reset
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
+ { spawnSync } = require('node:child_process'),
22
+ { loadConfig } = require('../src/config'),
23
+ { buildServices, createApp } = require('../src/server');
24
+
25
+ function configFile(extra = {}){
26
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-settings-')),
27
+ file = path.join(dir, 'coordinator.json');
28
+ fs.writeFileSync(file, JSON.stringify({
29
+ dataDir: path.join(dir, 'data'), sessionSecret: 'test-secret-test-secret', updates: { checkIntervalMin: 0 },
30
+ publicUrl: 'http://thub.lab', jobs: { defaultTimeoutSec: 1800, maxTimeoutSec: 14400 }, ...extra
31
+ }));
32
+ return file;
33
+ }
34
+
35
+ const byKey = (list) => Object.fromEntries(list.map((s) => [s.key, s]));
36
+
37
+ test('settings: dashboard values override the file, "now" ones apply at once, "restart" ones at the next start', () => {
38
+ const file = configFile(),
39
+ config = loadConfig(file),
40
+ { settings } = buildServices(config);
41
+
42
+ assert.equal(byKey(settings.list())['jobs.maxTimeoutSec'].source, 'file');
43
+ const result = settings.save(
44
+ { changes: { 'jobs.maxTimeoutSec': '3600', 'scheduler.tickIntervalSec': '30', 'scheduler.requeueOnLost': 'false' } },
45
+ { by: 'alice' }
46
+ );
47
+ assert.deepEqual(result.now.sort(), ['jobs.maxTimeoutSec', 'scheduler.requeueOnLost']);
48
+ assert.deepEqual(result.restart, ['scheduler.tickIntervalSec']);
49
+
50
+ // Applied now: every service reads the same config object.
51
+ assert.equal(config.jobs.maxTimeoutSec, 3600);
52
+ assert.equal(config.scheduler.requeueOnLost, false);
53
+ // Not yet: shown as pending.
54
+ assert.equal(config.scheduler.tickIntervalSec, 10);
55
+ const list = byKey(settings.list());
56
+ assert.equal(list['scheduler.tickIntervalSec'].pendingRestart, true);
57
+ assert.equal(list['jobs.maxTimeoutSec'].source, 'dashboard');
58
+ assert.equal(list['jobs.maxTimeoutSec'].updatedBy, 'alice');
59
+
60
+ // The next start (same database) runs with them all.
61
+ const config2 = loadConfig(file),
62
+ { settings: settings2 } = buildServices(config2);
63
+ assert.equal(config2.scheduler.tickIntervalSec, 30);
64
+ assert.equal(config2.jobs.maxTimeoutSec, 3600);
65
+ assert.equal(byKey(settings2.list())['scheduler.tickIntervalSec'].pendingRestart, false);
66
+
67
+ // Reset: back to the config file's value.
68
+ settings2.save({ reset: ['jobs.maxTimeoutSec'] });
69
+ assert.equal(config2.jobs.maxTimeoutSec, 14400);
70
+ assert.equal(byKey(settings2.list())['jobs.maxTimeoutSec'].source, 'file');
71
+ });
72
+
73
+ test('settings: validated all-or-nothing; env-locked ones can\'t change; bad stored values are skipped at startup', (t) => {
74
+ const file = configFile(),
75
+ config = loadConfig(file),
76
+ { settings, db } = buildServices(config);
77
+
78
+ assert.throws(() => settings.save({ changes: { 'jobs.maxTimeoutSec': '600', publicUrl: 'not a url' } }), /Public URL: must be a URL/);
79
+ assert.equal(config.jobs.maxTimeoutSec, 14400); // nothing saved
80
+ assert.throws(() => settings.save({ changes: { 'jobs.defaultTimeoutSec': '7200', 'jobs.maxTimeoutSec': '3600' } }), /longer than the maximum/);
81
+ assert.throws(() => settings.save({ changes: { clientJoinKey: 'short' } }), /at least 16 characters/);
82
+ assert.throws(() => settings.save({ changes: { 'retention.jobRetention': '5y' } }), /must be one of/);
83
+ settings.save({ changes: { publicUrl: 'https://thub.example.com/', clientJoinKey: 'jk_0123456789abcdef' } });
84
+ assert.equal(config.publicUrl, 'https://thub.example.com'); // trailing slash dropped
85
+ assert.equal(config.clientJoinKey, 'jk_0123456789abcdef');
86
+ settings.save({ changes: { clientJoinKey: '' } }); // cleared: registration off
87
+ assert.equal(config.clientJoinKey, null);
88
+
89
+ // A value an older version stored that this one rejects: ignored, warned.
90
+ db.prepare('INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)').run('retention.jobRetention', '"5y"', 'x');
91
+ const warn = t.mock.method(console, 'warn', () => {}),
92
+ config2 = loadConfig(file);
93
+ buildServices(config2);
94
+ assert.equal(config2.retention.jobRetention, 'forever');
95
+ assert.match(warn.mock.calls[0].arguments[0], /Ignoring dashboard setting retention\.jobRetention/);
96
+
97
+ // The environment wins, and the dashboard can't override it.
98
+ const prev = process.env.THUB_PUBLIC_URL;
99
+ process.env.THUB_PUBLIC_URL = 'https://from-env.example';
100
+ t.after(() => (prev === undefined ? delete process.env.THUB_PUBLIC_URL : (process.env.THUB_PUBLIC_URL = prev)));
101
+ const config3 = loadConfig(file),
102
+ { settings: settings3 } = buildServices(config3);
103
+ assert.equal(config3.publicUrl, 'https://from-env.example');
104
+ assert.equal(byKey(settings3.list()).publicUrl.source, 'env');
105
+ assert.deepEqual(settings3.save({ changes: { publicUrl: 'https://other.example' } }).changed, []);
106
+ assert.equal(config3.publicUrl, 'https://from-env.example');
107
+ });
108
+
109
+ test('settings page: admins only; saves; refuses a setting that would lock everyone out', async (t) => {
110
+ const config = loadConfig(configFile()),
111
+ services = buildServices(config),
112
+ server = createApp(config, services).listen(0, '127.0.0.1');
113
+ t.after(() => new Promise((r) => {
114
+ server.closeAllConnections?.();
115
+ server.close(r);
116
+ }));
117
+ await new Promise((r) => server.on('listening', r));
118
+ const base = `http://127.0.0.1:${server.address().port}`;
119
+ services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
120
+ services.adminUsers.create({ username: 'view', password: 'pw', role: 'viewer' });
121
+ const login = async (u) => (await fetch(`${base}/login`, {
122
+ method: 'POST', body: new URLSearchParams({ username: u, password: 'pw' }), redirect: 'manual'
123
+ })).headers.get('set-cookie').split(';')[0],
124
+ admin = await login('admin'),
125
+ viewer = await login('view'),
126
+ post = (body) => fetch(`${base}/admin/settings`, { method: 'POST', headers: { cookie: admin }, body: new URLSearchParams(body), redirect: 'manual' }),
127
+ page = async () => (await fetch(`${base}/admin/settings`, { headers: { cookie: admin } })).text();
128
+
129
+ assert.equal((await fetch(`${base}/admin/settings`, { headers: { cookie: viewer } })).status, 403);
130
+ const html = await page();
131
+ assert.match(html, /name="s\[jobs\.maxTimeoutSec\]"/);
132
+ assert.match(html, /href="\/admin\/settings"/); // navbar link
133
+ assert.doesNotMatch(html, /test-secret-test-secret/); // secrets never in the page
134
+
135
+ // A checkbox posts "false" then (checked) "true"; the last one wins.
136
+ await post([['s[jobs.maxTimeoutSec]', '7200'], ['s[scheduler.requeueOnLost]', 'false']]);
137
+ assert.equal(config.jobs.maxTimeoutSec, 7200);
138
+ assert.equal(config.scheduler.requeueOnLost, false);
139
+ assert.match(await page(), /Saved and applied: Maximum job timeout \(s\), Retry lost jobs\./);
140
+
141
+ // An empty secret field leaves the key alone; "clear" removes it.
142
+ await post({ 's[clientJoinKey]': 'jk_0123456789abcdef' });
143
+ await post({ 's[clientJoinKey]': '' });
144
+ assert.equal(config.clientJoinKey, 'jk_0123456789abcdef');
145
+ await post({ 'clear[clientJoinKey]': '1' });
146
+ assert.equal(config.clientJoinKey, null);
147
+
148
+ // Over plain HTTP, an HTTPS-only cookie would lock everyone out: refused.
149
+ await post({ 's[session.secureCookie]': 'true' });
150
+ assert.match(await page(), /you're connected over plain HTTP|you're connected over plain HTTP/);
151
+ assert.equal(services.settings.list().find((s) => s.key === 'session.secureCookie').source, 'file');
152
+
153
+ // Not under systemd: no restart.
154
+ assert.equal((await fetch(`${base}/admin/settings/restart`, { method: 'POST', headers: { cookie: admin }, redirect: 'manual' })).status, 302);
155
+ assert.match(await page(), /isn't running under systemd|isn't running under systemd/);
156
+ });
157
+
158
+ test('thub-admin settings reset: the way back from a bad setting', () => {
159
+ const file = configFile(),
160
+ config = loadConfig(file),
161
+ { settings } = buildServices(config);
162
+ settings.save({ changes: { 'jobs.maxTimeoutSec': '3600', 'scheduler.maxQueuedPerAgent': '5' } });
163
+ const run = (...args) => spawnSync(process.execPath, [path.join(__dirname, '..', 'bin', 'thub-admin.js'), 'settings', ...args], {
164
+ env: { ...process.env, THUB_COORDINATOR_CONFIG: file }, encoding: 'utf8'
165
+ });
166
+ assert.match(run('list').stdout, /jobs\.maxTimeoutSec = 3600/);
167
+ assert.match(run('reset', 'jobs.maxTimeoutSec').stdout, /Removed 1 dashboard setting/);
168
+ assert.match(run('reset').stdout, /Removed 1 dashboard setting/);
169
+ assert.match(run('list').stdout, /No settings changed from the dashboard/);
170
+ });
171
+
172
+ test('heartbeat interval from Settings: sent in every reply; lowering it gives Clients time to switch', async (t) => {
173
+ const config = loadConfig(configFile()),
174
+ services = buildServices(config),
175
+ { settings, registry, heartbeatMonitor, db } = services,
176
+ server = createApp(config, services).listen(0, '127.0.0.1');
177
+ t.after(() => new Promise((r) => {
178
+ server.closeAllConnections?.();
179
+ server.close(r);
180
+ }));
181
+ await new Promise((r) => server.on('listening', r));
182
+ const { resourceId, resourceToken } = registry.registerAuto({ clientId: 'c1', name: 'lab-1', type: 'sw', labels: [] }),
183
+ beat = () => fetch(`http://127.0.0.1:${server.address().port}/api/v1/resources/${resourceId}/heartbeat`, {
184
+ method: 'POST', headers: { authorization: `Bearer ${resourceToken}`, 'content-type': 'application/json' }, body: JSON.stringify({ state: 'idle' })
185
+ }).then((r) => r.json()),
186
+ status = () => registry.get(resourceId).status;
187
+
188
+ assert.equal((await beat()).heartbeatIntervalSec, 10);
189
+ settings.save({ changes: { 'heartbeat.intervalSec': '5' } });
190
+ assert.equal((await beat()).heartbeatIntervalSec, 5); // the next reply carries it
191
+
192
+ // Last heard from 25 s ago, still on the old 10 s interval: within 3 × 10 s.
193
+ const now = Date.now(),
194
+ lastBeat = (ageSec) => db.prepare('UPDATE resources SET last_heartbeat_at = ? WHERE id = ?').run(new Date(now - ageSec * 1000).toISOString(), resourceId);
195
+ lastBeat(25);
196
+ heartbeatMonitor.sweepOnce(now); // 3 × 5 s alone would call it gone
197
+ assert.notEqual(status(), 'OUT_OF_SERVICE');
198
+ // Once every Client has had (missedLimit + 1) old intervals to switch, the new one counts.
199
+ heartbeatMonitor.sweepOnce(now + 41_000);
200
+ assert.equal(status(), 'OUT_OF_SERVICE');
201
+
202
+ // missedLimit applies at once, too.
203
+ settings.save({ changes: { 'heartbeat.missedLimit': '6', 'heartbeat.sweepIntervalSec': '2' } });
204
+ assert.equal(config.heartbeat.missedLimit, 6);
205
+ assert.equal(config.heartbeat.sweepIntervalSec, 5); // a timer: after a restart
206
+ });
@@ -0,0 +1,133 @@
1
+ extends ../layout
2
+
3
+ //- One setting's input (services/settings.js SETTINGS: type text | number
4
+ //- | checkbox | select | secret). Names: s[<key>]; reset[] / clear[<key>].
5
+ mixin settingInput(s, locked)
6
+ - const id = `setting-${s.key.replace(/\./g, '-')}`
7
+ if s.type === 'checkbox'
8
+ .form-check.form-switch
9
+ input(type="hidden" name=`s[${s.key}]` value="false" disabled=locked)
10
+ input.form-check-input(id=id type="checkbox" name=`s[${s.key}]` value="true" checked=s.value === true disabled=locked)
11
+ label.form-check-label.visually-hidden(for=id)= s.label
12
+ else if s.type === 'select'
13
+ select.form-select.form-select-sm(id=id name=`s[${s.key}]` disabled=locked)
14
+ each o in s.options
15
+ option(value=o selected=String(s.value) === String(o))= s.labels && s.labels[o] ? `${o} — ${s.labels[o]}` : o
16
+ else if s.type === 'secret'
17
+ .input-group.input-group-sm
18
+ input.form-control(
19
+ id=id
20
+ type="password"
21
+ name=`s[${s.key}]`
22
+ autocomplete="new-password"
23
+ placeholder=s.value ? `set (…${String(s.value).slice(-4)}) — type to replace` : 'not set — registration is off'
24
+ disabled=locked
25
+ data-secret-input
26
+ )
27
+ button.btn.btn-outline-secondary(type="button" data-secret-generate=`#${id}` disabled=locked) Generate
28
+ button.btn.btn-outline-secondary(type="button" data-secret-show=`#${id}` disabled=locked aria-label="Show")
29
+ i.bi.bi-eye
30
+ if s.value && !locked
31
+ .form-check.mt-1
32
+ input.form-check-input(id=`${id}-clear` type="checkbox" name=`clear[${s.key}]` value="1")
33
+ label.form-check-label.small(for=`${id}-clear`) Clear it (turns self-registration off)
34
+ else
35
+ input.form-control.form-control-sm(
36
+ id=id
37
+ type=s.type === 'number' ? 'number' : 'text'
38
+ name=`s[${s.key}]`
39
+ value=s.value === null || s.value === undefined ? '' : String(s.value)
40
+ disabled=locked
41
+ )
42
+
43
+ block content
44
+ .d-flex.justify-content-between.align-items-center.mb-2
45
+ h1.h4.mb-0 Settings
46
+ p.text-body-secondary.small.
47
+ Coordinator options, saved in its database on top of the config file (#[code= readOnly.configPath]).
48
+ An environment variable still wins over both. Options marked #[span.badge.text-bg-secondary after restart]
49
+ are read only at startup.
50
+
51
+ if restarting
52
+ .alert.alert-info.d-flex.align-items-center.gap-2(data-restarting=restarting role="status")
53
+ span.spinner-border.spinner-border-sm(aria-hidden="true")
54
+ span Restarting the Coordinator… this page reloads by itself once it's back.
55
+ else if pendingRestart.length
56
+ .alert.alert-warning.d-flex.flex-wrap.align-items-center.gap-2
57
+ i.bi.bi-arrow-clockwise
58
+ span.me-auto
59
+ | Saved but not active until a restart: #{pendingRestart.map((s) => s.label).join(', ')}.
60
+ if canRestart
61
+ form(
62
+ method="post"
63
+ action="/admin/settings/restart"
64
+ data-confirm="Restart the Coordinator now? The dashboard and API are unavailable for a few seconds; Clients and Agents reconnect by themselves and running jobs carry on."
65
+ data-confirm-title="Restart the Coordinator?"
66
+ data-confirm-ok="Restart now"
67
+ data-confirm-tone="warning"
68
+ )
69
+ button.btn.btn-sm.btn-warning(type="submit") Restart now
70
+ else
71
+ span.small Restart it by hand: #[code sudo systemctl restart thub-coordinator].
72
+
73
+ form(method="post" action="/admin/settings")
74
+ each group in groups
75
+ .card.mb-3
76
+ .card-header.fw-semibold= group.name
77
+ ul.list-group.list-group-flush
78
+ each s in group.items
79
+ - const locked = s.source === 'env'
80
+ li.list-group-item
81
+ .row.g-2.align-items-start
82
+ .col-md-4
83
+ label.form-label.mb-0.fw-medium(for=`setting-${s.key.replace(/\./g, '-')}`)= s.label
84
+ .small.text-body-secondary
85
+ code.small= s.key
86
+ .d-flex.flex-wrap.gap-1.mt-1
87
+ if s.applies === 'now'
88
+ span.badge.text-bg-success applies now
89
+ else
90
+ span.badge.text-bg-secondary after restart
91
+ if s.pendingRestart
92
+ span.badge.text-bg-warning restart pending
93
+ if s.source === 'env'
94
+ span.badge.text-bg-dark(data-bs-toggle="tooltip" data-bs-title=`Set by the environment variable ${s.env}; unset it to change this here`)= `from ${s.env}`
95
+ else if s.source === 'dashboard'
96
+ span.badge.text-bg-info(data-bs-toggle="tooltip" data-bs-title=`Changed ${fmtDate(s.updatedAt)}${s.updatedBy ? ` by ${s.updatedBy}` : ''}`) from dashboard
97
+ else
98
+ span.badge.text-bg-light.border config file / default
99
+ .col-md-5
100
+ +settingInput(s, locked)
101
+ .form-text= s.help
102
+ .col-md-3
103
+ if s.source === 'dashboard'
104
+ .form-check.small
105
+ input.form-check-input(id=`reset-${s.key}` type="checkbox" name="reset" value=s.key)
106
+ label.form-check-label(for=`reset-${s.key}`)
107
+ | Back to the config file's value
108
+ if s.type !== 'secret'
109
+ | (
110
+ code= s.fileValue === null || s.fileValue === undefined ? 'unset' : String(s.fileValue)
111
+ | )
112
+ .d-flex.justify-content-end.gap-2.mb-4
113
+ a.btn.btn-outline-secondary(href="/admin/settings") Discard changes
114
+ button.btn.btn-primary(type="submit") Save settings
115
+
116
+ .card.mb-4
117
+ .card-header.fw-semibold Config file only
118
+ .card-body.small
119
+ p.text-body-secondary.
120
+ These change the address, storage or session signing the dashboard itself runs on, so they're set in the
121
+ config file (then #[code sudo systemctl restart thub-coordinator]).
122
+ dl.row.mb-0
123
+ dt.col-sm-3 listen
124
+ dd.col-sm-9: code= readOnly.listen
125
+ dt.col-sm-3 dataDir
126
+ dd.col-sm-9: code= readOnly.dataDir
127
+ dt.col-sm-3 sessionSecret
128
+ dd.col-sm-9 set (never shown)
129
+ p.small.text-body-secondary.mt-3.mb-0
130
+ | Locked out by a setting? On the Coordinator host: #[code thub-admin settings reset], then restart it.
131
+
132
+ block scripts
133
+ script(src="/js/settings.js")
@@ -9,7 +9,7 @@ block content
9
9
 
10
10
  p.text-body-secondary.small: i.
11
11
  A job can be constrained to run only on resources in a group with `thub run --group &lt;id&gt;` (§13.1).
12
- A resource joins a group by listing its id under `groups` in the Client's own config — membership isn't set here.
12
+ Which groups a Client is in is set on its resource card (Groups tab, admins), or as `groups` in the Client's own config — the tab writes it there.
13
13
 
14
14
  if totalCount
15
15
  .d-flex.flex-wrap.align-items-center.gap-2.mb-2
@@ -2,10 +2,10 @@
2
2
  h3.h6 Commands
3
3
  +code('thub --help').
4
4
  thub run [options] Submit a job and follow its log
5
- thub status &lt;jobId> [--json] Status; follow the log if running, artifact links if done
5
+ thub status &lt;jobId> [--json] Status; follow the log if running, verdict and test counts if done
6
6
  thub cancel &lt;jobId> Cancel a job
7
7
  thub resources [--json] Clients and their status
8
- thub jobs [--mine] [--state &lt;s>] [--json] Recent jobs
8
+ thub jobs [--mine] [--state &lt;s>] [--json] Recent jobs (a cli token: only its own)
9
9
  thub config set &lt;url|token|group|user> &lt;value>
10
10
  thub check-update Compare with the latest published Agent
11
11
  thub self-update [--to &lt;x.y.z>] Update with npm i -g
@@ -13,7 +13,7 @@
13
13
  h3.h6 Get a token
14
14
  p.small.
15
15
  An admin registers an agent on #[a(href="/admin/agents") Agents] (or with #[code thub-admin agent add]) and passes you
16
- its token, which is shown only once. The agent's #[strong kind] decides the job id prefix and the default priority:
16
+ its token, which is shown only once. The agent's #[strong kind] decides the job id prefix, the default priority and which jobs it can see:
17
17
  table.table.table-sm.small.w-auto
18
18
  thead
19
19
  tr
@@ -21,17 +21,20 @@
21
21
  th For
22
22
  th Job ids
23
23
  th Default priority
24
+ th Can see
24
25
  tbody
25
26
  tr
26
27
  td: code ci
27
28
  td Pipelines
28
29
  td: code A-00001…
29
30
  td 50
31
+ td every job (details, log)
30
32
  tr
31
33
  td: code cli
32
34
  td Developers
33
35
  td: code M-00001…
34
36
  td 60 (so a busy pipeline doesn't starve them)
37
+ td only the jobs it submitted
35
38
 
36
39
  h3.h6 Configure
37
40
  +code('Save the settings once').
@@ -83,7 +83,7 @@
83
83
  tr
84
84
  td: code groups
85
85
  td No
86
- td Group ids (from #[a(href="/groups") Groups]) this Client belongs to, for #[code thub run --group].
86
+ td Group ids (from #[a(href="/groups") Groups]) this Client belongs to, for #[code thub run --group]. Admins can also set them on the resource card (Groups tab), which writes them here.
87
87
  tr
88
88
  td: code varDir
89
89
  td No