@andrian.yablonskyy/thub-coordinator 1.0.39 → 1.0.41
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 +23 -0
- package/config.json +4 -0
- package/package.json +2 -2
- package/public/js/actions.js +58 -0
- package/public/js/settings.js +66 -0
- package/src/api/agent.js +27 -15
- package/src/api/resource.js +5 -3
- package/src/config.js +17 -1
- package/src/db/migrations/024_add_settings.sql +10 -0
- package/src/db/migrations/025_add_job_artifacts.sql +4 -0
- package/src/dev/virtual.js +9 -5
- package/src/login-guard.js +111 -0
- package/src/rate-limit.js +137 -0
- package/src/security.js +106 -0
- package/src/server.js +17 -3
- package/src/services/admin-users.js +32 -19
- package/src/services/heartbeat.js +23 -3
- package/src/services/job-artifacts.js +91 -0
- package/src/services/jobs.js +13 -2
- package/src/services/registry.js +34 -0
- package/src/services/settings.js +345 -0
- package/src/web/routes.js +157 -10
- package/test/admin-users.test.js +16 -16
- package/test/agent-visibility.test.js +85 -0
- package/test/groups-tab.test.js +97 -0
- package/test/job-artifacts.test.js +115 -0
- package/test/login-guard.test.js +139 -0
- package/test/logs.test.js +1 -1
- package/test/rate-limit.test.js +106 -0
- package/test/security.test.js +108 -0
- package/test/settings.test.js +206 -0
- package/views/admin/settings.pug +133 -0
- package/views/groups/list.pug +1 -1
- package/views/help/_agent-cli.pug +1 -1
- package/views/help/_agent-setup.pug +4 -1
- package/views/help/_client-setup.pug +1 -1
- package/views/help/_coordinator.pug +9 -1
- package/views/help/_env.pug +1 -0
- package/views/help/_troubleshooting.pug +3 -1
- package/views/jobs/list.pug +3 -3
- package/views/jobs/show.pug +36 -0
- package/views/layout.pug +4 -1
- package/views/mixins/client-config.pug +65 -3
- package/views/mixins/list-controls.pug +1 -1
- package/views/mixins/resource.pug +1 -8
|
@@ -0,0 +1,108 @@
|
|
|
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, /connect-src 'self' https:\/\/cdn\.jsdelivr\.net(;|$)/); // DevTools: Bootstrap's source maps
|
|
56
|
+
assert.match(csp, /object-src 'none'/);
|
|
57
|
+
}
|
|
58
|
+
// Behind the (loopback, trusted) TLS proxy.
|
|
59
|
+
const tls = await fetch(`${base}/login`, { headers: { 'X-Forwarded-Proto': 'https' } });
|
|
60
|
+
assert.match(tls.headers.get('strict-transport-security'), /^max-age=\d+$/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('same-origin check: POSTs from other sites are refused; own pages, publicUrl and non-browser clients pass', async (t) => {
|
|
64
|
+
const { base, services } = await start(t, { publicUrl: 'http://thub.lab.example' }),
|
|
65
|
+
post = (url, headers = {}) => fetch(`${base}${url}`, {
|
|
66
|
+
method: 'POST', headers, body: new URLSearchParams({ username: 'admin', password: 'pw' }), redirect: 'manual'
|
|
67
|
+
}),
|
|
68
|
+
|
|
69
|
+
// A forged cross-site form post (login CSRF here; any dashboard action alike).
|
|
70
|
+
evil = await post('/login', { Origin: 'https://evil.example' });
|
|
71
|
+
assert.equal(evil.status, 403);
|
|
72
|
+
assert.match(await evil.text(), /came from https:\/\/evil\.example, not from this dashboard \(http:\/\/thub\.lab\.example\)/);
|
|
73
|
+
assert.equal((await post('/login', { Origin: 'null' })).status, 403); // sandboxed iframe, data: URL
|
|
74
|
+
assert.equal((await post('/login', { Referer: 'https://evil.example/page' })).status, 403); // no Origin: Referer decides
|
|
75
|
+
assert.equal((await post('/login', { Origin: 'http://thub.lab.example:8443' })).status, 403); // another port is another origin
|
|
76
|
+
|
|
77
|
+
// The dashboard's own pages: the address it was opened at, or publicUrl
|
|
78
|
+
// (a reverse proxy that rewrites Host).
|
|
79
|
+
assert.equal((await post('/login', { Origin: base })).status, 302);
|
|
80
|
+
assert.equal((await post('/login', { Origin: 'http://thub.lab.example' })).status, 302);
|
|
81
|
+
assert.equal((await post('/login', { Referer: `${base}/login` })).status, 302);
|
|
82
|
+
// Neither header: not a browser page (curl, a script) — nothing to forge.
|
|
83
|
+
assert.equal((await post('/login')).status, 302);
|
|
84
|
+
|
|
85
|
+
// Reading is never blocked.
|
|
86
|
+
assert.equal((await fetch(`${base}/login`, { headers: { Origin: 'https://evil.example' } })).status, 200);
|
|
87
|
+
|
|
88
|
+
// The session-authenticated admin API is covered too (JSON answer)…
|
|
89
|
+
const admin = await fetch(`${base}/api/v1/admin/jobs/reset-queue`, { method: 'POST', headers: { Origin: 'https://evil.example' } });
|
|
90
|
+
assert.equal(admin.status, 403);
|
|
91
|
+
assert.match((await admin.json()).error, /Blocked/);
|
|
92
|
+
// …the bearer-token Agent API isn't: a browser never attaches that token by itself.
|
|
93
|
+
const { token } = services.agents.create({ name: 'ci', kind: 'ci' }),
|
|
94
|
+
agent = await fetch(`${base}/api/v1/jobs`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { Origin: 'https://evil.example', authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({})
|
|
98
|
+
});
|
|
99
|
+
assert.notEqual(agent.status, 403); // 400 invalid spec — got past the check
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('templates carry no inline event handlers (the CSP would block them)', () => {
|
|
103
|
+
const views = path.join(__dirname, '..', 'views'),
|
|
104
|
+
files = fs.readdirSync(views, { recursive: true }).filter((f) => f.endsWith('.pug'));
|
|
105
|
+
for (const f of files){
|
|
106
|
+
assert.doesNotMatch(fs.readFileSync(path.join(views, f), 'utf8'), /\bon[a-z]+="/, f);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
@@ -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")
|
package/views/groups/list.pug
CHANGED
|
@@ -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 <id>` (§13.1).
|
|
12
|
-
|
|
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 <jobId> [--json] Status; follow the log if running, verdict and test counts if done
|
|
6
6
|
thub cancel <jobId> Cancel a job
|
|
7
7
|
thub resources [--json] Clients and their status
|
|
8
|
-
thub jobs [--mine] [--state <s>] [--json] Recent jobs
|
|
8
|
+
thub jobs [--mine] [--state <s>] [--json] Recent jobs (a cli token: only its own)
|
|
9
9
|
thub config set <url|token|group|user> <value>
|
|
10
10
|
thub check-update Compare with the latest published Agent
|
|
11
11
|
thub self-update [--to <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
|
|
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],
|
package/views/help/_env.pug
CHANGED
|
@@ -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
|
package/views/jobs/list.pug
CHANGED
|
@@ -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"
|
|
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"
|
|
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.
|
package/views/jobs/show.pug
CHANGED
|
@@ -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)
|