@andrian.yablonskyy/thub-coordinator 1.0.38 → 1.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @file packages/coordinator/test/logs.test.js
3
+ * @description Tests: job log paging for the dashboard viewer, the full SSE replay, and the raw log (RAW button)
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
+ // A Coordinator on a random port, an admin session, and one job with `n`
25
+ // log lines ("line 1".."line n").
26
+ async function setup(t, n){
27
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-logs-')),
28
+ file = path.join(dir, 'coordinator.json');
29
+ fs.writeFileSync(file, JSON.stringify({
30
+ dataDir: path.join(dir, 'data'), sessionSecret: 'test-secret-test-secret', updates: { checkIntervalMin: 0 }
31
+ }));
32
+ const config = loadConfig(file),
33
+ services = buildServices(config),
34
+ server = createApp(config, services).listen(0, '127.0.0.1');
35
+ t.after(() => new Promise((r) => {
36
+ server.closeAllConnections?.();
37
+ server.close(r);
38
+ }));
39
+ await new Promise((r) => server.on('listening', r));
40
+ const base = `http://127.0.0.1:${server.address().port}`;
41
+
42
+ services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
43
+ const login = await fetch(`${base}/login`, {
44
+ method: 'POST', body: new URLSearchParams({ username: 'admin', password: 'pw' }), redirect: 'manual'
45
+ }),
46
+ cookie = login.headers.get('set-cookie').split(';')[0],
47
+ { agent } = services.agents.create({ name: 'ci', kind: 'ci' });
48
+ services.registry.registerAuto({ clientId: 'c1', name: 'lab-sw-01', type: 'sw', labels: [] });
49
+ const job = services.jobs.create({ agentId: agent.id, source: 'ci', spec: { target: { type: 'sw' }, command: './t.sh' } });
50
+ for (let i = 0; i < n; i += 500){
51
+ services.logs.appendBatch(job.id, Array.from({ length: Math.min(500, n - i) }, (_, k) => ({ stream: 'runner', line: `line ${i + k + 1}` })));
52
+ }
53
+ const finish = () => services.db.prepare('UPDATE jobs SET state = ?, finished_at = ? WHERE id = ?').run('PASSED', new Date().toISOString(), job.id);
54
+ return { base, cookie, services, job, finish, get: (p) => fetch(`${base}${p}`, { headers: { cookie } }) };
55
+ }
56
+
57
+ test('log pages: the end first, then earlier pages until the start', async (t) => {
58
+ const { get, job } = await setup(t, 2500),
59
+ last = await (await get(`/jobs/${job.id}/logs?limit=1000`)).json();
60
+ assert.equal(last.total, 2500);
61
+ assert.equal(last.hasMore, true);
62
+ assert.equal(last.lines.length, 1000);
63
+ assert.equal(last.lines[0].line, 'line 1501');
64
+ assert.equal(last.lines.at(-1).line, 'line 2500');
65
+
66
+ const earlier = await (await get(`/jobs/${job.id}/logs?limit=1000&before=${last.lines[0].seq}`)).json();
67
+ assert.deepEqual([earlier.lines[0].line, earlier.lines.at(-1).line, earlier.hasMore], ['line 501', 'line 1500', true]);
68
+
69
+ const first = await (await get(`/jobs/${job.id}/logs?limit=1000&before=${earlier.lines[0].seq}`)).json();
70
+ assert.deepEqual([first.lines.length, first.lines[0].line, first.hasMore], [500, 'line 1', false]);
71
+
72
+ // Limits are clamped: never more than 5000 a request.
73
+ assert.equal((await (await get(`/jobs/${job.id}/logs?limit=999999`)).json()).lines.length, 2500);
74
+ });
75
+
76
+ test('log stream replays every line, not just the first 500', async (t) => {
77
+ const { get, job, finish } = await setup(t, 1234);
78
+ finish();
79
+ const text = await (await get(`/jobs/${job.id}/stream`)).text(),
80
+ lines = [...text.matchAll(/^event: log$/gm)].length;
81
+ assert.equal(lines, 1234);
82
+ assert.match(text, /"line":"line 1234"/);
83
+ assert.match(text, /event: end/);
84
+
85
+ // Resuming after a seq replays only what follows it.
86
+ const resumed = await (await get(`/jobs/${job.id}/stream?after=1200`)).text();
87
+ assert.equal([...resumed.matchAll(/^event: log$/gm)].length, 34);
88
+ });
89
+
90
+ test('RAW: the whole log as plain text, for as long as the job exists', async (t) => {
91
+ const { get, job, finish, services } = await setup(t, 6001),
92
+ res = await get(`/jobs/${job.id}/log.txt`),
93
+ text = await res.text();
94
+ assert.equal(res.status, 200);
95
+ assert.match(res.headers.get('content-type'), /^text\/plain; charset=utf-8/);
96
+ assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
97
+ assert.match(res.headers.get('content-disposition'), new RegExp(`inline; filename="${job.id}.log"`));
98
+ const rows = text.trimEnd().split('\n');
99
+ assert.equal(rows.length, 6001); // more than one 5000-line page
100
+ assert.match(rows[0], /^\[\d{4}-\d\d-\d\dT[^\]]+\] \[runner\] line 1$/);
101
+ assert.match(rows.at(-1), /\] \[runner\] line 6001$/);
102
+
103
+ // Still complete once the job has finished — no retention of its own
104
+ // (log lines go with the job), and no artifact to fall back to.
105
+ finish();
106
+ assert.equal((await (await get(`/jobs/${job.id}/log.txt`)).text()).trimEnd().split('\n').length, 6001);
107
+ services.db.prepare('DELETE FROM job_logs WHERE job_id = ?').run(job.id);
108
+ assert.equal(await (await get(`/jobs/${job.id}/log.txt`)).text(), '');
109
+
110
+ assert.equal((await get('/jobs/M-99999/log.txt')).status, 404);
111
+ });
112
+
113
+ test('artifacts are gone: old Clients\' uploads are discarded, old Agents get none, the store is removed', async (t) => {
114
+ const { base, services, job } = await setup(t, 3),
115
+ { resourceId, resourceToken } = services.registry.registerAuto({ clientId: 'c9', name: 'lab-hw-09', type: 'hw', labels: [] }),
116
+ { token: agentToken } = services.agents.create({ name: 'old-agent', kind: 'cli' });
117
+ services.db.prepare('UPDATE jobs SET resource_id = ? WHERE id = ?').run(resourceId, job.id);
118
+
119
+ // An old Client still uploads its results after the job: accepted (so the
120
+ // job doesn't end in ERROR), stored nowhere.
121
+ const form = new FormData();
122
+ form.append('files', new Blob(['<testsuite tests="1"/>']), 'results.xml');
123
+ const upload = await fetch(`${base}/api/v1/jobs/${job.id}/artifacts`, {
124
+ method: 'POST', headers: { authorization: `Bearer ${resourceToken}` }, body: form
125
+ });
126
+ assert.equal(upload.status, 201);
127
+ assert.deepEqual(await upload.json(), { artifacts: [] });
128
+ assert.equal(fs.existsSync(path.join(path.dirname(services.db.name), 'artifacts')), false);
129
+
130
+ // An old Agent's `thub status` still asks: none.
131
+ const listed = await fetch(`${base}/api/v1/jobs/${job.id}/artifacts`, { headers: { authorization: `Bearer ${agentToken}` } });
132
+ assert.deepEqual(await listed.json(), { artifacts: [] });
133
+
134
+ // No table, no download route.
135
+ assert.equal(services.db.prepare('SELECT COUNT(*) AS n FROM sqlite_master WHERE name = ?').get('artifacts').n, 0);
136
+ assert.equal((await fetch(`${base}/artifacts/download/x.y.z`, { redirect: 'manual' })).status, 302); // just the login redirect
137
+ });
138
+
139
+ test('upgrade: a leftover <dataDir>/artifacts is deleted on startup', (t) => {
140
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-legacy-')),
141
+ file = path.join(dir, 'coordinator.json'),
142
+ legacy = path.join(dir, 'data', 'artifacts', 'M-00001');
143
+ fs.mkdirSync(legacy, { recursive: true });
144
+ fs.writeFileSync(path.join(legacy, 'console.log'), 'old');
145
+ fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 } }));
146
+ const log = t.mock.method(console, 'log', () => {});
147
+ buildServices(loadConfig(file));
148
+ assert.equal(fs.existsSync(path.join(dir, 'data', 'artifacts')), false);
149
+ assert.match(log.mock.calls[0].arguments[0], /no longer stores job artifacts/);
150
+ });
@@ -25,8 +25,8 @@ const test = require('node:test'),
25
25
  { createRegistryService } = require('../src/services/registry'),
26
26
  { createAgentsService } = require('../src/services/agents'),
27
27
  { createGroupsService } = require('../src/services/groups'),
28
- { createArtifactsService } = require('../src/services/artifacts'),
29
28
  { createJobsService } = require('../src/services/jobs'),
29
+ { createLogsService } = require('../src/services/logs'),
30
30
  { createScheduler } = require('../src/services/scheduler'),
31
31
  { createHeartbeatMonitor } = require('../src/services/heartbeat'),
32
32
  { JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common');
@@ -42,22 +42,19 @@ function buildTestServices(overrides = {}){
42
42
  registry = createRegistryService(db, { bus, events }),
43
43
  agents = createAgentsService(db, { events }),
44
44
  groups = createGroupsService(db, { events, registry }),
45
- artifactsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-test-artifacts-')),
46
45
  config = {
47
46
  scheduler: { assignAckTimeoutSec: 15, requeueOnLost: true, maxQueuedPerAgent: 20, tickIntervalSec: 3600 },
48
47
  jobs: { defaultTimeoutSec: 1800, maxTimeoutSec: 14400 },
49
48
  heartbeat: { intervalSec: 10, missedLimit: 3, sweepIntervalSec: 3600 },
50
- artifactsDir,
51
49
  sessionSecret: 'test-secret',
52
50
  publicUrl: 'http://localhost:8080',
53
- artifacts: { linkTtlHours: 1 },
54
51
  ...overrides
55
52
  },
56
- artifacts = createArtifactsService(db, { config }),
57
- jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
53
+ logs = createLogsService(db, { bus }),
54
+ jobs = createJobsService(db, { bus, events, registry, config }),
58
55
  scheduler = createScheduler(db, { bus, events, registry, config }),
59
56
  heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config });
60
- return { db, bus, registry, agents, groups, artifacts, jobs, scheduler, heartbeatMonitor, artifactsDir };
57
+ return { db, bus, registry, agents, groups, logs, jobs, scheduler, heartbeatMonitor };
61
58
  }
62
59
 
63
60
  function registerResource(registry, { name, type, labels = [], groups = [], clientId } = {}){
@@ -206,15 +203,16 @@ test('admin resetQueue cancels every active job and leaves finished ones alone',
206
203
  assert.equal(jobs.get(finished.id).state, JOB_STATES.PASSED); // untouched
207
204
  });
208
205
 
209
- test('admin cleanHistory deletes finished jobs, their artifacts on disk, and leaves active jobs alone', () => {
210
- const { registry, agents, artifacts, jobs, artifactsDir } = buildTestServices(),
206
+ test('admin cleanHistory deletes finished jobs with their log lines, and leaves active jobs alone', () => {
207
+ const { db, registry, agents, logs, jobs } = buildTestServices(),
211
208
  { agent } = agents.create({ name: 'ci', kind: 'ci' });
212
209
  registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: [] });
213
210
 
214
211
  const finished = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
215
212
  jobs.setState(finished.id, JOB_STATES.PASSED);
216
- artifacts.storeGenerated(finished.id, 'console.log', Buffer.from('hello'), 'text/plain');
217
- assert.ok(fs.existsSync(path.join(artifactsDir, finished.id)));
213
+ logs.appendBatch(finished.id, [{ stream: 'runner', line: 'hello' }]);
214
+ const logLines = (id) => db.prepare('SELECT COUNT(*) AS n FROM job_logs WHERE job_id = ?').get(id).n;
215
+ assert.equal(logLines(finished.id), 1);
218
216
 
219
217
  const active = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() }),
220
218
 
@@ -222,7 +220,7 @@ test('admin cleanHistory deletes finished jobs, their artifacts on disk, and lea
222
220
 
223
221
  assert.equal(deleted, 1);
224
222
  assert.equal(jobs.get(finished.id), undefined);
225
- assert.equal(fs.existsSync(path.join(artifactsDir, finished.id)), false);
223
+ assert.equal(logLines(finished.id), 0);
226
224
  assert.ok(jobs.get(active.id)); // active job untouched
227
225
  });
228
226
 
@@ -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
+ });
@@ -2,7 +2,7 @@
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
8
  thub jobs [--mine] [--state &lt;s>] [--json] Recent jobs
@@ -11,7 +11,7 @@
11
11
  p.small.
12
12
  #[code sudo npm i -g] does the whole setup for the user who ran #[code sudo]. It creates
13
13
  #[code ~/.config/thub/coordinator.json] (with a random #[code sessionSecret]) and
14
- #[code ~/var/lib/thub] (database, artifacts, avatars). It installs, enables and starts
14
+ #[code ~/var/lib/thub] (database, avatars). It installs, enables and starts
15
15
  #[code thub-coordinator.service], plus the root helper behind the navbar's #[strong Update app] button.
16
16
 
17
17
  h3.h6 2. Configure
@@ -25,12 +25,12 @@
25
25
  "trustProxy": "loopback",
26
26
  "dataDir": "/home/thub/var/lib/thub",
27
27
  "sessionSecret": "&lt;long random string>",
28
+ "session": { "secureCookie": "auto" },
28
29
  "clientJoinKey": "&lt;output of: thub-admin join-key generate>",
29
30
  "heartbeat": { "intervalSec": 10, "missedLimit": 3, "sweepIntervalSec": 5 },
30
31
  "scheduler": { "assignAckTimeoutSec": 15, "requeueOnLost": true, "maxQueuedPerAgent": 20, "tickIntervalSec": 10 },
31
32
  "jobs": { "defaultTimeoutSec": 1800, "maxTimeoutSec": 14400 },
32
- "retention": { "logRetentionDays": 14, "artifactRetentionDays": 30, "jobRetention": "forever" },
33
- "artifacts": { "maxUploadMb": 512, "linkTtlHours": 168 },
33
+ "retention": { "jobRetention": "forever" },
34
34
  "updates": { "checkIntervalMin": 15, "registry": null }
35
35
  }
36
36
  .table-responsive
@@ -51,7 +51,10 @@
51
51
  td Which proxies' #[code X-Forwarded-For] to trust for a Client's external address: #[code loopback] (default), a CIDR such as #[code "10.0.0.0/8"], a hop count, or #[code false].
52
52
  tr
53
53
  td: code sessionSecret
54
- td Signs dashboard sessions and artifact download links. Keep it secret and never use the #[code change-me-…] placeholder.
54
+ td Signs dashboard session cookies. Keep it secret and never use the #[code change-me-…] placeholder.
55
+ tr
56
+ td: code session.secureCookie
57
+ td #[code "auto"] (default): the session cookie is Secure (HTTPS-only) whenever #[code publicUrl] is https://. #[code true]/#[code false] force it. Sessions are stored in the database and survive restarts.
55
58
  tr
56
59
  td: code clientJoinKey
57
60
  td The shared secret Clients self-register with. Unset or #[code null] turns registration off (#[code 503]).
@@ -66,7 +69,7 @@
66
69
  td Default and maximum job timeout, in seconds.
67
70
  tr
68
71
  td: code retention.*
69
- td #[code jobRetention]: #[code 1w], #[code 2w], #[code 1m], #[code 3m], #[code 6m] or #[code forever]. Older finished jobs, their logs and artifacts are deleted hourly.
72
+ td #[code jobRetention]: #[code 1w], #[code 2w], #[code 1m], #[code 3m], #[code 6m] or #[code forever]. Older finished jobs and their logs are deleted hourly.
70
73
  tr
71
74
  td: code updates.*
72
75
  td How often to check npm for new versions (#[code 0] turns the periodic check off), and an optional private registry.
@@ -96,7 +99,7 @@
96
99
  proxy_http_version 1.1;
97
100
  proxy_buffering off; # SSE log streams
98
101
  proxy_read_timeout 1h;
99
- client_max_body_size 512m; # artifacts.maxUploadMb
102
+ client_max_body_size 10m; # log batches, avatars
100
103
  }
101
104
 
102
105
  h3.h6 4. Users, agents and the join key
@@ -106,7 +106,7 @@
106
106
  td Removes the container when the tests end.
107
107
  tr
108
108
  td: code -v "$THUB_WORK_DIR:/work" -w /work
109
- td The checkout and downloads, and the #[code results/] and #[code artifacts/] folders, which the Client uploads.
109
+ td The checkout and downloads, and the #[code results/] and #[code artifacts/] folders, where the Client reads JUnit XML for the test counts.
110
110
  tr
111
111
  td: code -e NAME
112
112
  td Passes a job variable (#[code --env], #[code THUB_*], #[code JOB_*]) into the container.
@@ -9,7 +9,7 @@
9
9
  -
10
10
  const thubVars = [
11
11
  ['THUB_JOB_ID', 'The job id, e.g. M-00125'],
12
- ['THUB_WORK_DIR', 'The directory the command runs in: the --git-repo checkout, else empty. results/ and artifacts/ in it are uploaded'],
12
+ ['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
13
  ['THUB_SUITE', '--suite (default: default)'],
14
14
  ['THUB_GIT_COMMIT', 'The exact commit checked out for --git-repo'],
15
15
  ['THUB_DOWNLOADS_DIR', 'Where the --download-file files are'],
@@ -20,8 +20,9 @@
20
20
  git remote add origin git@github.com:yourorg/firmware-tests.git
21
21
  git push -u origin main
22
22
  p.small.
23
- The exit code of #[code --command] is the verdict. JUnit files under #[code results/] or #[code artifacts/] are uploaded
24
- as artifacts and summed into the job's test counts. Any other file there is uploaded too.
23
+ The exit code of #[code --command] is the verdict. JUnit files under #[code results/] or #[code artifacts/] are summed into
24
+ the job's test counts. Nothing is uploaded: the workspace is deleted when the job ends, so publish anything you need to keep
25
+ from the command itself (e.g. #[code curl -T report.html "$ARTIFACTORY/…"] with a token passed as #[code --env]).
25
26
 
26
27
  h3.h6 2. Give Clients read access: SSH deploy key (recommended)
27
28
  p.small.
@@ -13,7 +13,7 @@
13
13
  | Coordinator
14
14
  p.card-text.small.mb-0.
15
15
  This service. It holds the job queue and the resource registry, and runs the scheduler and
16
- the heartbeat monitor. It stores logs and artifacts in SQLite plus a local directory,
16
+ the heartbeat monitor. It stores job state and logs in SQLite,
17
17
  and serves this dashboard. It's the only thing that needs a public HTTPS port.
18
18
  .col-md-4
19
19
  .card.h-100
@@ -41,7 +41,7 @@
41
41
  li The Coordinator queues it (#[code QUEUED]) and the scheduler assigns it to an idle Client whose type, labels and group match.
42
42
  li The Client accepts it, clones #[code --git-repo], downloads #[code --download-file] files and prepares the DUT (#[code PREPARING]).
43
43
  li It runs #[code --command] with #[code sh -c], and output streams live to the Agent and the dashboard (#[code RUNNING]).
44
- li The exit code is the verdict: #[code 0] = #[code PASSED], anything else = #[code FAILED]. JUnit files and artifacts are uploaded.
44
+ li The exit code is the verdict: #[code 0] = #[code PASSED], anything else = #[code FAILED]. The Client reports the JUnit test counts; the job's files stay on the Client and are deleted with its workspace.
45
45
  li The Agent exits with the verdict code, so a CI step passes or fails with it.
46
46
 
47
47
  h3.h6 Key design rules
@@ -16,6 +16,8 @@
16
16
  ['Live logs don\'t stream through the proxy', 'Turn off response buffering for the Coordinator (nginx: `proxy_buffering off;`, Caddy: `flush_interval -1`).'],
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
+ ['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.'],
19
21
  ['Locked out of the dashboard', 'Restart the Coordinator once with `THUB_BOOTSTRAP_ADMIN_PASSWORD=<new>` (resets the `admin` user), then unset it.']
20
22
  ]
21
23
  const withCode = (text) => text
@@ -87,7 +87,7 @@ block content
87
87
  |
88
88
  code CANCELED
89
89
  | .
90
- p.small.text-body-secondary.mb-0 Finished jobs, logs and artifacts are not touched.
90
+ p.small.text-body-secondary.mb-0 Finished jobs and their logs are not touched.
91
91
  else
92
92
  p.mb-0 There are no active jobs — nothing to cancel.
93
93
 
@@ -132,7 +132,7 @@ block content
132
132
  onclick="document.getElementById('cleanup-before').value = this.dataset.value"
133
133
  )= preset.label
134
134
  p
135
- | Deletes finished jobs that ended before then, with their logs and artifacts (files too), and the resource, agent and group history logged before then — then compacts the database file.
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.
136
136
  |
137
137
  strong This cannot be undone.
138
138
  | Active jobs are never touched.
@@ -49,15 +49,5 @@ block content
49
49
  dt.col-sm-2 Message
50
50
  dd.col-sm-10= job.message
51
51
 
52
- h2.h6.mt-4#artifacts Artifacts
53
- if artifacts.length
54
- ul.list-group
55
- each a in artifacts
56
- li.list-group-item.d-flex.justify-content-between
57
- span= a.name
58
- a(href=a.url) download
59
- else
60
- p.text-body-secondary No artifacts yet.
61
-
62
52
  h2.h6.mt-4 Log
63
53
  +logViewer(job.id, jobActive)
@@ -1,6 +1,7 @@
1
1
  mixin logViewer(jobId, jobActive)
2
2
  //- Stream filter + search (public/js/log-viewer.js): matches are
3
3
  //- highlighted; Enter / Shift+Enter or the arrows step through them.
4
+ //- The log loads from its end; earlier lines load as you scroll up.
4
5
  .d-flex.flex-wrap.align-items-center.gap-2.mb-2
5
6
  select#stream-filter.form-select.form-select-sm.w-auto(aria-label="Log stream")
6
7
  option(value="") all streams
@@ -28,6 +29,19 @@ mixin logViewer(jobId, jobActive)
28
29
  .form-check.form-check-inline.small.mb-0
29
30
  input#log-search-only.form-check-input(type="checkbox")
30
31
  label.form-check-label(for="log-search-only") Display only matching lines
32
+ a.btn.btn-sm.btn-outline-secondary.ms-auto(
33
+ href=`/jobs/${jobId}/log.txt`
34
+ target="_blank"
35
+ rel="noopener"
36
+ data-bs-toggle="tooltip"
37
+ data-bs-title="Open the full log as plain text in a new tab"
38
+ )
39
+ i.bi.bi-file-earmark-text.me-1
40
+ | Raw
41
+ //- "Showing the last 1,000 of 5,321 lines · Load all" (log-viewer.js).
42
+ .d-flex.flex-wrap.align-items-center.gap-2.small.text-body-secondary.mb-1(aria-live="polite")
43
+ span#log-info Loading log…
44
+ button#log-load-all.btn.btn-link.btn-sm.p-0.align-baseline(type="button" hidden) Load all
31
45
  pre#log-viewer.bg-body-tertiary.p-3.rounded.position-relative(
32
46
  style="height: 420px; overflow-y: auto;"
33
47
  data-job-id=jobId