@andrian.yablonskyy/thub-coordinator 1.0.39 → 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 (45) hide show
  1. package/bin/thub-admin.js +23 -0
  2. package/config.json +4 -0
  3. package/package.json +2 -2
  4. package/public/js/actions.js +58 -0
  5. package/public/js/settings.js +66 -0
  6. package/src/api/agent.js +27 -15
  7. package/src/api/resource.js +5 -3
  8. package/src/config.js +17 -1
  9. package/src/db/migrations/024_add_settings.sql +10 -0
  10. package/src/db/migrations/025_add_job_artifacts.sql +4 -0
  11. package/src/dev/virtual.js +9 -5
  12. package/src/login-guard.js +111 -0
  13. package/src/rate-limit.js +137 -0
  14. package/src/security.js +104 -0
  15. package/src/server.js +17 -3
  16. package/src/services/admin-users.js +32 -19
  17. package/src/services/heartbeat.js +23 -3
  18. package/src/services/job-artifacts.js +91 -0
  19. package/src/services/jobs.js +13 -2
  20. package/src/services/registry.js +34 -0
  21. package/src/services/settings.js +345 -0
  22. package/src/web/routes.js +157 -10
  23. package/test/admin-users.test.js +16 -16
  24. package/test/agent-visibility.test.js +85 -0
  25. package/test/groups-tab.test.js +97 -0
  26. package/test/job-artifacts.test.js +115 -0
  27. package/test/login-guard.test.js +139 -0
  28. package/test/logs.test.js +1 -1
  29. package/test/rate-limit.test.js +106 -0
  30. package/test/security.test.js +107 -0
  31. package/test/settings.test.js +206 -0
  32. package/views/admin/settings.pug +133 -0
  33. package/views/groups/list.pug +1 -1
  34. package/views/help/_agent-cli.pug +1 -1
  35. package/views/help/_agent-setup.pug +4 -1
  36. package/views/help/_client-setup.pug +1 -1
  37. package/views/help/_coordinator.pug +9 -1
  38. package/views/help/_env.pug +1 -0
  39. package/views/help/_troubleshooting.pug +3 -1
  40. package/views/jobs/list.pug +3 -3
  41. package/views/jobs/show.pug +36 -0
  42. package/views/layout.pug +4 -1
  43. package/views/mixins/client-config.pug +65 -3
  44. package/views/mixins/list-controls.pug +1 -1
  45. package/views/mixins/resource.pug +1 -8
@@ -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,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
@@ -5,7 +5,7 @@
5
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
@@ -30,6 +30,7 @@
30
30
  "heartbeat": { "intervalSec": 10, "missedLimit": 3, "sweepIntervalSec": 5 },
31
31
  "scheduler": { "assignAckTimeoutSec": 15, "requeueOnLost": true, "maxQueuedPerAgent": 20, "tickIntervalSec": 10 },
32
32
  "jobs": { "defaultTimeoutSec": 1800, "maxTimeoutSec": 14400 },
33
+ "rateLimit": { "requestsPerMinute": 500, "loginPerMinute": 10 },
33
34
  "retention": { "jobRetention": "forever" },
34
35
  "updates": { "checkIntervalMin": 15, "registry": null }
35
36
  }
@@ -60,10 +61,13 @@
60
61
  td The shared secret Clients self-register with. Unset or #[code null] turns registration off (#[code 503]).
61
62
  tr
62
63
  td: code heartbeat.*
63
- td Clients heartbeat every #[code intervalSec]. After #[code missedLimit] misses a resource goes #[code OUT_OF_SERVICE] and its job becomes #[code LOST].
64
+ td Clients heartbeat every #[code intervalSec] — the Coordinator tells them, so it's set here only (and on Settings). After #[code missedLimit] misses a resource goes #[code OUT_OF_SERVICE] and its job becomes #[code LOST]; #[code sweepIntervalSec] is how often that's checked.
64
65
  tr
65
66
  td: code scheduler.*
66
67
  td #[code assignAckTimeoutSec]: how long an assignment waits for the Client to accept. #[code requeueOnLost]: retry a #[code LOST] job once. #[code maxQueuedPerAgent]: queue limit per agent.
68
+ tr
69
+ td: code rateLimit.*
70
+ td Requests per minute per caller (agent/resource token, dashboard user, else IP; default 500), and sign-in attempts per IP (default 10). Past it: #[code 429] with #[code Retry-After]. #[code 0] turns a limit off.
67
71
  tr
68
72
  td: code jobs.*
69
73
  td Default and maximum job timeout, in seconds.
@@ -73,6 +77,10 @@
73
77
  tr
74
78
  td: code updates.*
75
79
  td How often to check npm for new versions (#[code 0] turns the periodic check off), and an optional private registry.
80
+ +note('info').
81
+ Most of these can also be changed by admins on the dashboard's #[a(href="/admin/settings") Settings] page — stored in the
82
+ database on top of this file; some apply at once, the rest after a restart (#[strong Restart now] under systemd). An environment
83
+ variable still wins. Locked out by a setting? #[code thub-admin settings reset] on the Coordinator host, then restart.
76
84
  p.small.
77
85
  Resolution order: #[code THUB_COORDINATOR_CONFIG] (a path) → #[code ~/.config/thub/coordinator.json] → the bundled
78
86
  default. These environment variables override the file: #[code THUB_LISTEN], #[code THUB_PUBLIC_URL],
@@ -9,6 +9,7 @@
9
9
  -
10
10
  const thubVars = [
11
11
  ['THUB_JOB_ID', 'The job id, e.g. M-00125'],
12
+ ['THUB_ARTIFACTS_FILE', 'Where the command may list the artifacts it published elsewhere: a JSON array of {"name", "size", "link", "timestamp"}, shown on the job page'],
12
13
  ['THUB_WORK_DIR', 'The directory the command runs in: the --git-repo checkout, else empty. JUnit XML in results/ or artifacts/ gives the test counts; deleted when the job ends'],
13
14
  ['THUB_SUITE', '--suite (default: default)'],
14
15
  ['THUB_GIT_COMMIT', 'The exact commit checked out for --git-repo'],
@@ -17,7 +17,9 @@
17
17
  ['Finding a job, resource, group or agent', 'Use the search box on its list page (press `/` to jump to it). Every word must match, e.g. `lab-hw-01 FAILED` or a git ref. Job search covers ids, users, resources, agents, commands, repos and `--meta`, but not `--env` values (secrets). On a job page, search the log above the log viewer: `Enter`/`Shift+Enter` step through matches.'],
18
18
  ['Lists don\'t update by themselves (navbar shows Offline or Paused)', '`Paused`: you\'re editing something on the page (a field, the inline rename, a dialog); it resumes when you\'re done. `Offline`: the browser can\'t hold the `/live` stream open, often a proxy buffering it (nginx: `proxy_buffering off;`); reloading the page always shows current data. `Session ended`: sign in again. Background updates never extend the session\'s idle timeout.'],
19
19
  ['Sign-in says it needs HTTPS', 'The session cookie is HTTPS-only because `publicUrl` is `https://`, but the request arrived as plain HTTP. Open the dashboard through its https:// address; behind a reverse proxy, make it send `X-Forwarded-Proto: https` (nginx: `proxy_set_header X-Forwarded-Proto $scheme;`) and check `trustProxy`. To allow plain HTTP, set `session.secureCookie` to `false`.'],
20
- ['Where are a job\'s test reports and output files?', 'The Coordinator keeps only the log and the result (verdict, exit code, JUnit test counts). Files the job creates stay in its workspace on the Client and are deleted when the job ends. To keep them, publish them from `--command`, e.g. `curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T results/report.xml "https://artifactory.example.com/qa/$THUB_JOB_ID/report.xml"` with `--env ART_TOKEN`. The full log is always available from the job page\'s Raw button.'],
20
+ ['Where are a job\'s test reports and output files?', 'The Coordinator keeps only the log and the result (verdict, exit code, JUnit test counts). Files the job creates stay in its workspace on the Client and are deleted when the job ends. To keep them, publish them from `--command`, e.g. `curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T results/report.xml "https://artifactory.example.com/qa/$THUB_JOB_ID/report.xml"` with `--env ART_TOKEN`. To show them on the job page and in `thub status`, list them in `$THUB_ARTIFACTS_FILE` as `[{"name", "size", "link", "timestamp"}]` — only that metadata is kept. The full log is always available from the job page\'s Raw button.'],
21
+ ['429 Too Many Requests', 'A caller went over `rateLimit.requestsPerMinute` (per agent or resource token, per dashboard user, else per IP; default 500). The Agent and Client wait for `Retry-After` and retry by themselves; a script calling the API should do the same. Raise the limit in the config file or on Settings if your normal traffic needs more (a Client streaming logs makes up to ~240 requests a minute). Too many sign-in attempts from one address: wait a minute (`rateLimit.loginPerMinute`).'],
22
+ ['Sign-in says “Too many failed sign-ins”', 'After repeated wrong passwords the Coordinator makes each further attempt wait, doubling each time: per address from the 5th failure (up to 15 min), per username from the 10th from any address (up to 5 min). Wait the time shown, or sign in from another address; a successful sign-in clears it, and an hour without failures forgets it. The Coordinator never tells whether a username exists.'],
21
23
  ['Locked out of the dashboard', 'Restart the Coordinator once with `THUB_BOOTSTRAP_ADMIN_PASSWORD=<new>` (resets the `admin` user), then unset it.']
22
24
  ]
23
25
  const withCode = (text) => text
@@ -15,12 +15,12 @@ block content
15
15
  .col-12.col-md-5.col-lg-4
16
16
  +searchBox(filters.q, 'Search jobs: id, user, resource, command, repo…', list.urlFor({ q: '' }))
17
17
  .col-auto
18
- select.form-select.form-select-sm(name="state" onchange="this.form.submit()")
18
+ select.form-select.form-select-sm(name="state" data-autosubmit)
19
19
  option(value="") any state
20
20
  each s in ['QUEUED','ASSIGNED','PREPARING','RUNNING','PASSED','FAILED','ERROR','TIMEOUT','CANCELED','LOST']
21
21
  option(value=s selected=filters.state === s)= s
22
22
  .col-auto
23
- select.form-select.form-select-sm(name="source" onchange="this.form.submit()")
23
+ select.form-select.form-select-sm(name="source" data-autosubmit)
24
24
  option(value="") any source
25
25
  option(value="ci" selected=filters.source === 'ci') ci
26
26
  option(value="cli" selected=filters.source === 'cli') cli
@@ -128,8 +128,8 @@ block content
128
128
  each preset in cleanupInfo.presets
129
129
  button.btn.btn-sm.btn-outline-secondary(
130
130
  type="button"
131
+ data-fill="#cleanup-before"
131
132
  data-value=preset.value
132
- onclick="document.getElementById('cleanup-before').value = this.dataset.value"
133
133
  )= preset.label
134
134
  p
135
135
  | Deletes finished jobs that ended before then, with their logs, and the resource, agent and group history logged before then — then compacts the database file.
@@ -49,5 +49,41 @@ block content
49
49
  dt.col-sm-2 Message
50
50
  dd.col-sm-10= job.message
51
51
 
52
+ //- What the job published elsewhere and listed in $THUB_ARTIFACTS_FILE
53
+ //- (README §7.3): metadata only; the links go to wherever the files are.
54
+ h2.h6.mt-4#artifacts Artifacts
55
+ if job.artifacts.length
56
+ .table-responsive
57
+ table.table.table-sm.align-middle.mb-0
58
+ thead
59
+ tr
60
+ th Name
61
+ th.text-end Size
62
+ th Time
63
+ th
64
+ tbody
65
+ each a in job.artifacts
66
+ tr
67
+ td
68
+ a(href=a.link target="_blank" rel="noopener noreferrer")= a.name
69
+ td.text-end.text-nowrap(title=a.size != null ? `${a.size} bytes` : null)= a.size != null ? fmtBytes(a.size) : '—'
70
+ td.text-nowrap= a.timestamp ? fmtDate(a.timestamp) : '—'
71
+ td.text-end
72
+ a.btn.btn-sm.btn-outline-secondary(
73
+ href=a.link
74
+ target="_blank"
75
+ rel="noopener noreferrer"
76
+ aria-label=`Download ${a.name}`
77
+ data-bs-toggle="tooltip"
78
+ data-bs-title=a.link
79
+ )
80
+ i.bi.bi-download
81
+ else if jobActive
82
+ p.small.text-body-secondary.mb-0 Listed here when the job finishes, if it reports any.
83
+ else
84
+ p.small.text-body-secondary.mb-0.
85
+ None reported. A job lists the files it published (to Artifactory, S3, a file server…) in
86
+ #[code $THUB_ARTIFACTS_FILE] as #[code [{"name", "size", "link", "timestamp"}]].
87
+
52
88
  h2.h6.mt-4 Log
53
89
  +logViewer(job.id, jobActive)