@andrian.yablonskyy/thub-coordinator 1.0.38 → 1.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/bin/thub-admin.js +26 -5
  2. package/config.json +5 -6
  3. package/package.json +2 -2
  4. package/public/js/actions.js +58 -0
  5. package/public/js/log-viewer.js +188 -56
  6. package/public/js/settings.js +66 -0
  7. package/scripts/install-default-config.js +1 -1
  8. package/scripts/install-target.js +2 -2
  9. package/src/api/admin.js +1 -1
  10. package/src/api/agent.js +28 -22
  11. package/src/api/resource.js +11 -14
  12. package/src/api/sse.js +13 -10
  13. package/src/config.js +33 -10
  14. package/src/db/migrations/022_add_sessions.sql +10 -0
  15. package/src/db/migrations/023_drop_artifacts.sql +4 -0
  16. package/src/db/migrations/024_add_settings.sql +10 -0
  17. package/src/db/migrations/025_add_job_artifacts.sql +4 -0
  18. package/src/dev/virtual.js +10 -6
  19. package/src/login-guard.js +111 -0
  20. package/src/rate-limit.js +137 -0
  21. package/src/security.js +104 -0
  22. package/src/server.js +53 -23
  23. package/src/services/admin-users.js +32 -19
  24. package/src/services/cleanup.js +2 -2
  25. package/src/services/heartbeat.js +23 -3
  26. package/src/services/job-artifacts.js +91 -0
  27. package/src/services/jobs.js +17 -9
  28. package/src/services/logs.js +12 -14
  29. package/src/services/registry.js +34 -0
  30. package/src/services/retention.js +5 -14
  31. package/src/services/session-store.js +127 -0
  32. package/src/services/settings.js +345 -0
  33. package/src/web/routes.js +230 -21
  34. package/test/admin-users.test.js +16 -16
  35. package/test/agent-visibility.test.js +85 -0
  36. package/test/cleanup.test.js +1 -5
  37. package/test/groups-tab.test.js +97 -0
  38. package/test/job-artifacts.test.js +115 -0
  39. package/test/login-guard.test.js +139 -0
  40. package/test/logs.test.js +150 -0
  41. package/test/rate-limit.test.js +106 -0
  42. package/test/scheduler.test.js +10 -12
  43. package/test/security.test.js +107 -0
  44. package/test/session.test.js +132 -0
  45. package/test/settings.test.js +206 -0
  46. package/views/admin/settings.pug +133 -0
  47. package/views/groups/list.pug +1 -1
  48. package/views/help/_agent-cli.pug +2 -2
  49. package/views/help/_agent-setup.pug +4 -1
  50. package/views/help/_client-setup.pug +1 -1
  51. package/views/help/_coordinator.pug +18 -7
  52. package/views/help/_docker.pug +1 -1
  53. package/views/help/_env.pug +2 -1
  54. package/views/help/_git.pug +3 -2
  55. package/views/help/_overview.pug +2 -2
  56. package/views/help/_troubleshooting.pug +4 -0
  57. package/views/jobs/list.pug +5 -5
  58. package/views/jobs/show.pug +33 -7
  59. package/views/layout.pug +4 -1
  60. package/views/mixins/client-config.pug +65 -3
  61. package/views/mixins/list-controls.pug +1 -1
  62. package/views/mixins/log-viewer.pug +14 -0
  63. package/views/mixins/resource.pug +1 -8
  64. package/src/services/artifacts.js +0 -149
package/bin/thub-admin.js CHANGED
@@ -30,10 +30,10 @@ const { loadConfig } = require('../src/config'),
30
30
  { createAgentsService } = require('../src/services/agents'),
31
31
  { createGroupsService } = require('../src/services/groups'),
32
32
  { createAdminUsersService } = require('../src/services/admin-users'),
33
- { createArtifactsService } = require('../src/services/artifacts'),
34
33
  { createJobsService } = require('../src/services/jobs'),
35
34
  { createCleanupService } = require('../src/services/cleanup'),
36
35
  { generateToken } = require('../src/services/tokens'),
36
+ { createSettingsService } = require('../src/services/settings'),
37
37
  { PACKAGES, fetchLatestVersion, isNewer, isValidVersion, npmBin, formatDateTime, parseDateTime } = require('@andrian.yablonskyy/thub-common'),
38
38
  { spawnSync } = require('node:child_process'),
39
39
  { version: installedVersion } = require('../package.json');
@@ -46,12 +46,17 @@ function usage(){
46
46
  thub-admin resource maintenance <resourceId> --on|--off
47
47
  thub-admin jobs reset --yes Cancel every queued/assigned/preparing/running job
48
48
  thub-admin jobs clean --yes [--before "dd/mm/yyyy HH:MM:SS"]
49
- Permanently delete finished jobs (with logs and artifacts) and
49
+ Permanently delete finished jobs (with their logs) and
50
50
  history from before then (local time; default: now), then
51
51
  compact the database file
52
52
  thub-admin group add <name> [--comment <text>]
53
53
  thub-admin group list
54
54
  thub-admin group remove <groupId>
55
+ thub-admin settings list Settings changed from the dashboard (Settings page)
56
+ thub-admin settings reset [<key>...]
57
+ Drop them (all, or the named ones) — back to the config
58
+ file's values at the next restart; the way back if a
59
+ setting locked you out of the dashboard
55
60
  thub-admin check-update Compare this Coordinator with the latest published version
56
61
  thub-admin self-update [--to X.Y.Z]
57
62
  Update this Coordinator (sudo npm i -g; restarts the service)
@@ -134,10 +139,26 @@ function main(){
134
139
  registry = createRegistryService(db, { bus, events }),
135
140
  agents = createAgentsService(db, { events }),
136
141
  adminUsers = createAdminUsersService(db),
137
- artifacts = createArtifactsService(db, { config }),
138
- jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
142
+ jobs = createJobsService(db, { bus, events, registry, config }),
139
143
  groups = createGroupsService(db, { events, registry });
140
144
 
145
+ if (cmd === 'settings' && (sub === 'list' || sub === 'reset')){
146
+ const settings = createSettingsService(db, { config });
147
+ if (sub === 'reset'){
148
+ const n = settings.reset(rest);
149
+ console.log(`Removed ${n} dashboard setting(s). Restart the Coordinator to use the config file's values: sudo systemctl restart thub-coordinator`);
150
+ return;
151
+ }
152
+ const changed = settings.list().filter((s) => s.source === 'dashboard');
153
+ if (!changed.length){
154
+ console.log('No settings changed from the dashboard — everything comes from the config file (or the environment).');
155
+ }
156
+ for (const s of changed){
157
+ console.log(`${s.key} = ${s.type === 'secret' ? '(secret)' : JSON.stringify(s.value)} (${s.updatedBy || '?'}, ${s.updatedAt})`);
158
+ }
159
+ return;
160
+ }
161
+
141
162
  if (cmd === 'create-admin'){
142
163
  const { positional, flags } = parseFlags([sub, ...rest].filter(Boolean)),
143
164
  [username, password] = positional;
@@ -193,7 +214,7 @@ function main(){
193
214
  if (cmd === 'jobs' && sub === 'clean'){
194
215
  const { flags } = parseFlags(rest);
195
216
  if (!flags.yes){
196
- console.error('This permanently deletes finished jobs and their logs/artifacts. Re-run with --yes to confirm.');
217
+ console.error('This permanently deletes finished jobs and their logs. Re-run with --yes to confirm.');
197
218
  process.exit(4);
198
219
  }
199
220
  const before = flags.before ? parseDateTime(flags.before) : new Date();
package/config.json CHANGED
@@ -19,12 +19,11 @@
19
19
  "defaultTimeoutSec": 1800,
20
20
  "maxTimeoutSec": 14400
21
21
  },
22
- "retention": {
23
- "logRetentionDays": 14,
24
- "artifactRetentionDays": 30
22
+ "rateLimit": {
23
+ "requestsPerMinute": 500,
24
+ "loginPerMinute": 10
25
25
  },
26
- "artifacts": {
27
- "maxUploadMb": 512,
28
- "linkTtlHours": 168
26
+ "retention": {
27
+ "jobRetention": "forever"
29
28
  }
30
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.38",
3
+ "version": "1.0.40",
4
4
  "description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
5
5
  "main": "src/server.js",
6
6
  "engines": {
@@ -19,7 +19,7 @@
19
19
  "lint:fix": "eslint . --fix"
20
20
  },
21
21
  "dependencies": {
22
- "@andrian.yablonskyy/thub-common": "^1.0.32",
22
+ "@andrian.yablonskyy/thub-common": "^1.0.33",
23
23
  "better-sqlite3": "^12.4.1",
24
24
  "express": "^5.1.0",
25
25
  "express-session": "^1.18.2",
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @file packages/coordinator/public/js/actions.js
3
+ * @description Dashboard: small declarative behaviours (data-* attributes) that used to be inline on*= handlers
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
+ // The Content-Security-Policy (server.js) allows no inline script, so no
17
+ // onclick=/onchange=/onsubmit= in the templates: they declare what they
18
+ // want with these attributes instead. Delegated, so it also works for
19
+ // content swapped in by live updates (live.js).
20
+ (function (){
21
+ // <select data-navigate>: its option values are URLs to go to.
22
+ // <select data-autosubmit>: submit its form on change.
23
+ document.addEventListener('change', (e) => {
24
+ const el = e.target;
25
+ if (el.matches?.('select[data-navigate]')){
26
+ window.location.href = el.value;
27
+ }
28
+ else if (el.matches?.('[data-autosubmit]') && el.form){
29
+ el.form.submit();
30
+ }
31
+ });
32
+
33
+ // <button data-fill="#field" data-value="…">: put the value into #field.
34
+ document.addEventListener('click', (e) => {
35
+ const btn = e.target.closest('[data-fill]'),
36
+ target = btn && document.querySelector(btn.dataset.fill);
37
+ if (target){
38
+ target.value = btn.dataset.value;
39
+ }
40
+ });
41
+
42
+ // <form data-submit-busy>: disable its button and show a spinner while
43
+ // the request runs (e.g. the navbar's check for updates).
44
+ document.addEventListener('submit', (e) => {
45
+ const form = e.target;
46
+ if (!form.matches?.('form[data-submit-busy]') || e.defaultPrevented){
47
+ return;
48
+ }
49
+ const button = form.querySelector('button'),
50
+ icon = button?.querySelector('i');
51
+ if (button){
52
+ button.disabled = true;
53
+ }
54
+ if (icon){
55
+ icon.className = 'spinner-border spinner-border-sm';
56
+ }
57
+ });
58
+ })();
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file packages/coordinator/public/js/log-viewer.js
3
- * @description Dashboard: streams or fetches a job's log output into the log viewer pane
3
+ * @description Dashboard: a job's log in the log viewer pane — loaded from its end, earlier lines as you scroll up, live lines streamed
4
4
  *
5
5
  * @author Andrian Yablonskyy
6
6
  * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
@@ -21,7 +21,11 @@
21
21
  const jobId = pre.dataset.jobId,
22
22
  jobActive = pre.dataset.jobActive === 'true',
23
23
  filter = document.getElementById('stream-filter'),
24
- lines = [],
24
+ info = document.getElementById('log-info'),
25
+ loadAllButton = document.getElementById('log-load-all'),
26
+ PAGE = 1000, // lines per page (routes.js LOG_PAGE)
27
+ PAGE_ALL = 5000, // per request when loading everything
28
+ NEAR_TOP_PX = 150,
25
29
  // Same dd/mm/yyyy HH:MM:SS, 24-hour format and profile time zone as
26
30
  // every server-rendered date (thub-common formatDateTime).
27
31
  timeFormat = new Intl.DateTimeFormat('en-GB', {
@@ -35,6 +39,16 @@
35
39
  hourCycle: 'h23'
36
40
  });
37
41
 
42
+ // What's loaded, oldest first: a window ending at the log's end. `firstSeq`
43
+ // is where the next earlier page ends; `total` counts every line the job
44
+ // has (loaded or not), live lines included.
45
+ let lines = [],
46
+ firstSeq = null,
47
+ hasMore = false,
48
+ total = 0,
49
+ lastSeq = 0, // newest log line we have (for the live stream to follow on)
50
+ loading = false;
51
+
38
52
  function fmtTime(iso){
39
53
  const d = new Date(iso);
40
54
  if (Number.isNaN(d.getTime())){
@@ -44,8 +58,28 @@
44
58
  return `${p.day}/${p.month}/${p.year} ${p.hour}:${p.minute}:${p.second}`;
45
59
  }
46
60
 
47
- // Search (views/mixins/log-viewer.pug): case-insensitive, literal text.
48
- // Matches are <mark>ed; `current` is the one the arrows step to.
61
+ const number = (n) => n.toLocaleString('en-GB');
62
+
63
+ function updateInfo(){
64
+ const logLines = lines.filter((l) => l.seq !== undefined).length;
65
+ if (loading){
66
+ info.textContent = 'Loading earlier lines…';
67
+ }
68
+ else if (!total){
69
+ info.textContent = jobActive ? 'No output yet.' : 'This job produced no output.';
70
+ }
71
+ else if (hasMore){
72
+ info.textContent = `Showing the last ${number(logLines)} of ${number(total)} lines — scroll up for earlier ones.`;
73
+ }
74
+ else {
75
+ info.textContent = `All ${number(total)} lines.`;
76
+ }
77
+ loadAllButton.hidden = !hasMore || loading;
78
+ }
79
+
80
+ // Search (views/mixins/log-viewer.pug): case-insensitive, literal text,
81
+ // over the loaded lines. Matches are <mark>ed; `current` is the one the
82
+ // arrows step to.
49
83
  const search = document.getElementById('log-search'),
50
84
  onlyMatching = document.getElementById('log-search-only'),
51
85
  count = document.getElementById('log-search-count'),
@@ -62,7 +96,7 @@
62
96
  function showCurrent(scroll){
63
97
  hits.forEach((el, i) => el.classList.toggle('current', i === current));
64
98
  if (current >= 0){
65
- count.textContent = `${current + 1} / ${hits.length}`;
99
+ count.textContent = `${current + 1} / ${hits.length}${hasMore ? ' loaded' : ''}`;
66
100
  if (scroll){
67
101
  const el = hits[current];
68
102
  pre.scrollTop = el.offsetTop - pre.clientHeight / 2;
@@ -78,22 +112,42 @@
78
112
  showCurrent(true);
79
113
  }
80
114
 
81
- // `live`: re-rendered for a newly streamed line — keep the reader's place
82
- // while searching; without a search, follow the end of the log as before.
83
- function render({ live = false } = {}){
115
+ // `mode` says what changed, and so where the view should stay:
116
+ // reset — first load, a new search or filter: the end (or first match)
117
+ // live — a streamed line: follow the end only if the reader is there
118
+ // prepend — earlier lines loaded above: keep the same lines in view
119
+ function render(mode = 'reset'){
84
120
  const wanted = filter.value,
85
121
  query = search.value.trim(),
122
+ atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 30,
123
+ fromBottom = pre.scrollHeight - pre.scrollTop,
124
+ scrollTop = pre.scrollTop,
125
+ hitsBefore = hits.length,
86
126
  shown = lines
87
127
  .filter((l) => !wanted || l.stream === wanted)
88
128
  .map((l) => `[${fmtTime(l.ts)}] [${l.stream}] ${l.line}`);
89
129
 
130
+ function place(){
131
+ if (mode === 'prepend'){
132
+ pre.scrollTop = pre.scrollHeight - fromBottom;
133
+ }
134
+ else if (mode === 'reset' || atBottom){
135
+ pre.scrollTop = pre.scrollHeight;
136
+ }
137
+ else {
138
+ pre.scrollTop = scrollTop;
139
+ }
140
+ }
141
+
142
+ updateInfo();
90
143
  if (!query){
91
144
  pre.textContent = shown.join('\n');
92
145
  hits = [];
93
146
  current = -1;
94
147
  count.hidden = true;
95
148
  prev.disabled = next.disabled = true;
96
- pre.scrollTop = pre.scrollHeight;
149
+ place();
150
+ fillIfShort();
97
151
  return;
98
152
  }
99
153
 
@@ -112,7 +166,6 @@
112
166
  out.push(html + escapeHtml(text.slice(last)));
113
167
  }
114
168
  }
115
- const scrollTop = pre.scrollTop;
116
169
  pre.innerHTML = out.join('\n');
117
170
  hits = [...pre.querySelectorAll('mark.thub-log-hit')];
118
171
  count.hidden = false;
@@ -120,18 +173,23 @@
120
173
  prev.disabled = next.disabled = hits.length < 2;
121
174
  if (!hits.length){
122
175
  current = -1;
123
- count.textContent = 'No matches';
176
+ count.textContent = hasMore ? 'No matches in loaded lines' : 'No matches';
177
+ place();
178
+ fillIfShort();
124
179
  return;
125
180
  }
126
- if (live){
127
- current = Math.min(Math.max(current, 0), hits.length - 1);
128
- showCurrent(false);
129
- pre.scrollTop = scrollTop;
130
- }
131
- else {
181
+ if (mode === 'reset'){
132
182
  current = 0;
133
183
  showCurrent(true);
134
184
  }
185
+ else {
186
+ // Earlier lines add their matches in front: the same match moves down.
187
+ current = mode === 'prepend' ? current + (hits.length - hitsBefore) : current;
188
+ current = Math.min(Math.max(current, 0), hits.length - 1);
189
+ showCurrent(false);
190
+ place();
191
+ }
192
+ fillIfShort();
135
193
  }
136
194
 
137
195
  // Streamed lines can arrive in bursts: render at most once per frame.
@@ -142,10 +200,75 @@
142
200
  queued = true;
143
201
  requestAnimationFrame(() => {
144
202
  queued = false;
145
- render({ live: true });
203
+ render('live');
146
204
  });
147
205
  }
148
206
 
207
+ // --- loading pages ------------------------------------------------------
208
+ async function fetchPage(before, limit){
209
+ const qs = new URLSearchParams({ limit: String(limit) });
210
+ if (before){
211
+ qs.set('before', String(before));
212
+ }
213
+ const res = await fetch(`/jobs/${encodeURIComponent(jobId)}/logs?${qs}`, { cache: 'no-store' });
214
+ if (!res.ok){
215
+ throw new Error(`HTTP ${res.status}`);
216
+ }
217
+ return res.json();
218
+ }
219
+
220
+ function absorb(page, where){
221
+ const fetched = page.lines;
222
+ if (fetched.length){
223
+ firstSeq = fetched[0].seq;
224
+ }
225
+ lines = where === 'prepend' ? [...fetched, ...lines] : fetched;
226
+ if (where !== 'prepend' && fetched.length){
227
+ lastSeq = fetched.at(-1).seq;
228
+ }
229
+ hasMore = page.hasMore;
230
+ total = Math.max(total, page.total);
231
+ }
232
+
233
+ async function loadEarlier(limit = PAGE){
234
+ if (loading || !hasMore){
235
+ return;
236
+ }
237
+ loading = true;
238
+ updateInfo();
239
+ try {
240
+ absorb(await fetchPage(firstSeq, limit), 'prepend');
241
+ }
242
+ catch {
243
+ info.textContent = 'Couldn’t load earlier lines — scroll up to try again.';
244
+ loading = false;
245
+ return;
246
+ }
247
+ loading = false;
248
+ render('prepend');
249
+ }
250
+
251
+ async function loadAll(){
252
+ while (hasMore && !loading){
253
+ await loadEarlier(PAGE_ALL);
254
+ }
255
+ }
256
+
257
+ // A filter or "only matching lines" can leave too little to scroll: keep
258
+ // loading earlier pages until there's something to scroll, or nothing left.
259
+ function fillIfShort(){
260
+ if (hasMore && !loading && pre.scrollHeight <= pre.clientHeight + NEAR_TOP_PX){
261
+ loadEarlier();
262
+ }
263
+ }
264
+
265
+ pre.addEventListener('scroll', () => {
266
+ if (pre.scrollTop < NEAR_TOP_PX){
267
+ loadEarlier();
268
+ }
269
+ }, { passive: true });
270
+ loadAllButton.addEventListener('click', loadAll);
271
+
149
272
  let typing;
150
273
  search.addEventListener('input', () => {
151
274
  clearTimeout(typing);
@@ -167,43 +290,52 @@
167
290
  onlyMatching.addEventListener('change', () => render());
168
291
  filter.addEventListener('change', () => render());
169
292
 
170
- // Already finished when the page loaded: the output will never change
171
- // again, so just fetch it once — no EventSource, no live connection, no
172
- // reload. (Opening a stream here used to trigger an auto-reload once it
173
- // immediately got the job's "end" event, which reloaded the page, which
174
- // re-opened the stream, which reloaded again — an infinite loop for
175
- // anyone viewing a finished job.)
176
- if (!jobActive){
177
- fetch(`/jobs/${jobId}/logs`)
178
- .then((r) => r.json())
179
- .then(({ lines: fetched }) => {
180
- lines.push(...fetched);
181
- render();
182
- })
183
- .catch(() => {});
184
- return;
185
- }
293
+ // --- start: the end of the log, then (active job) live lines after it ----
294
+ (async () => {
295
+ try {
296
+ absorb(await fetchPage(null, PAGE), 'replace');
297
+ }
298
+ catch {
299
+ info.textContent = 'Couldn’t load the log — reload the page to try again.';
300
+ return;
301
+ }
302
+ render();
186
303
 
187
- const source = new EventSource(`/jobs/${jobId}/stream`);
188
- source.addEventListener('log', (e) => {
189
- lines.push(JSON.parse(e.data));
190
- renderLive();
191
- });
192
- source.addEventListener('state', (e) => {
193
- const { state, resource } = JSON.parse(e.data);
194
- lines.push({ ts: new Date().toISOString(), stream: 'state', line: `-> ${state}${resource ? ' on ' + resource : ''}` });
195
- renderLive();
196
- });
197
- source.addEventListener('end', (e) => {
198
- const { state } = JSON.parse(e.data);
199
- lines.push({ ts: new Date().toISOString(), stream: 'state', line: `job finished: ${state}` });
200
- renderLive();
201
- source.close();
202
- // The job was active when we opened this page and just finished —
203
- // reload once to pick up the now-final badge/artifacts list.
204
- setTimeout(() => window.location.reload(), 1500);
205
- });
206
- source.onerror = () => {
207
- // EventSource auto-reconnects; nothing to do here.
208
- };
304
+ // Already finished when the page loaded: the output will never change
305
+ // again — no EventSource, no live connection. (Opening a stream here used
306
+ // to reload the page on its immediate "end" event, forever.)
307
+ if (!jobActive){
308
+ return;
309
+ }
310
+ // Live lines start right after what's loaded; on a reconnect the
311
+ // browser resumes from the last one it got (Last-Event-ID).
312
+ const source = new EventSource(`/jobs/${encodeURIComponent(jobId)}/stream?after=${lastSeq}`);
313
+ source.addEventListener('log', (e) => {
314
+ const seq = Number(e.lastEventId) || undefined;
315
+ if (seq && seq <= lastSeq){
316
+ return; // already have it
317
+ }
318
+ lastSeq = seq ?? lastSeq;
319
+ lines.push({ ...JSON.parse(e.data), seq });
320
+ total += 1;
321
+ renderLive();
322
+ });
323
+ source.addEventListener('state', (e) => {
324
+ const { state, resource } = JSON.parse(e.data);
325
+ lines.push({ ts: new Date().toISOString(), stream: 'state', line: `-> ${state}${resource ? ' on ' + resource : ''}` });
326
+ renderLive();
327
+ });
328
+ source.addEventListener('end', (e) => {
329
+ const { state } = JSON.parse(e.data);
330
+ lines.push({ ts: new Date().toISOString(), stream: 'state', line: `job finished: ${state}` });
331
+ renderLive();
332
+ source.close();
333
+ // The job was active when we opened this page and just finished —
334
+ // reload once to pick up the now-final badge and details.
335
+ setTimeout(() => window.location.reload(), 1500);
336
+ });
337
+ source.onerror = () => {
338
+ // EventSource auto-reconnects; nothing to do here.
339
+ };
340
+ })();
209
341
  })();
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @file packages/coordinator/public/js/settings.js
3
+ * @description Dashboard Settings page: generate/show the join key, and wait out a restart
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
+ (function (){
17
+ // Generate: a random key, shown so it can be copied into the Clients'
18
+ // configs (joinKey) before saving.
19
+ document.querySelectorAll('[data-secret-generate]').forEach((btn) => {
20
+ btn.addEventListener('click', () => {
21
+ const input = document.querySelector(btn.dataset.secretGenerate),
22
+ bytes = crypto.getRandomValues(new Uint8Array(32)),
23
+ b64 = btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
24
+ input.value = `jk_${b64}`;
25
+ input.type = 'text';
26
+ input.select();
27
+ });
28
+ });
29
+ document.querySelectorAll('[data-secret-show]').forEach((btn) => {
30
+ btn.addEventListener('click', () => {
31
+ const input = document.querySelector(btn.dataset.secretShow);
32
+ input.type = input.type === 'password' ? 'text' : 'password';
33
+ });
34
+ });
35
+
36
+ // Restarting: poll until a process with a different start time answers,
37
+ // then reload (riding out the seconds it's down).
38
+ const banner = document.querySelector('[data-restarting]');
39
+ if (banner){
40
+ const before = banner.dataset.restarting,
41
+ started = Date.now(),
42
+ poll = async () => {
43
+ try {
44
+ const res = await fetch('/admin/settings/status', { cache: 'no-store', redirect: 'manual' });
45
+ if (res.type === 'opaqueredirect'){
46
+ window.location.href = '/login'; // the session didn't survive? sign in again
47
+ return;
48
+ }
49
+ if (res.ok && (await res.json()).startedAt !== before){
50
+ window.location.href = '/admin/settings';
51
+ return;
52
+ }
53
+ }
54
+ catch {
55
+ // down for now
56
+ }
57
+ if (Date.now() - started > 120_000){
58
+ banner.lastElementChild.textContent = 'The Coordinator hasn\'t come back after 2 minutes — check journalctl -u thub-coordinator on its host.';
59
+ banner.querySelector('.spinner-border')?.remove();
60
+ return;
61
+ }
62
+ setTimeout(poll, 1500);
63
+ };
64
+ setTimeout(poll, 1500);
65
+ }
66
+ })();
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @file scripts/install-default-config.js
3
3
  * @description npm postinstall: on a real global install, creates the Coordinator's directory layout
4
- * (~/.config/thub, ~/.thub and its artifacts/avatars/work subdirs) and, if it
4
+ * (~/.config/thub, ~/.thub and its avatars/work subdirs) and, if it
5
5
  * doesn't already exist, ~/.config/thub/coordinator.json with every default option and
6
6
  * a freshly generated sessionSecret (README §12, §13)
7
7
  *
@@ -52,7 +52,7 @@ function resolveTargetUser(){
52
52
  }
53
53
 
54
54
  // Default layout (README §13): config under ~/.config/thub, runtime data
55
- // (SQLite DB, uploaded artifacts, avatars, work files) under ~/.thub.
55
+ // (SQLite DB, avatars, work files) under ~/.thub.
56
56
  // An existing coordinator.json's own dataDir wins over the default, so a
57
57
  // re-install prepares (and the systemd unit allows writes to) the
58
58
  // directory the Coordinator will really use.
@@ -69,7 +69,7 @@ function targetPaths(user){
69
69
  // Written by the dashboard's "Update app" button, watched by
70
70
  // thub-coordinator-update.path (README §10.2).
71
71
  updateRequestFile: path.join(dataDir, 'update-request.json'),
72
- dataSubdirs: ['artifacts', 'avatars', 'work'].map((d) => path.join(dataDir, d))
72
+ dataSubdirs: ['avatars', 'work'].map((d) => path.join(dataDir, d))
73
73
  };
74
74
  }
75
75
 
package/src/api/admin.js CHANGED
@@ -134,7 +134,7 @@ function createAdminRouter({ services }){
134
134
  res.json({ canceled });
135
135
  });
136
136
 
137
- // Permanently deletes finished jobs and their logs/artifacts (§13.1).
137
+ // Permanently deletes finished jobs and their logs (§13.1).
138
138
  // Database cleanup (§9): optional `before` (ISO 8601; default now) —
139
139
  // finished jobs that ended before it, older history, then VACUUM.
140
140
  router.post('/jobs/clean-history', (req, res) => {