@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.
- package/bin/thub-admin.js +3 -5
- package/config.json +1 -6
- package/package.json +1 -1
- package/public/js/log-viewer.js +188 -56
- package/scripts/install-default-config.js +1 -1
- package/scripts/install-target.js +2 -2
- package/src/api/admin.js +1 -1
- package/src/api/agent.js +4 -10
- package/src/api/resource.js +6 -11
- package/src/api/sse.js +13 -10
- package/src/config.js +16 -9
- package/src/db/migrations/022_add_sessions.sql +10 -0
- package/src/db/migrations/023_drop_artifacts.sql +4 -0
- package/src/dev/virtual.js +1 -1
- package/src/server.js +37 -21
- package/src/services/cleanup.js +2 -2
- package/src/services/jobs.js +4 -7
- package/src/services/logs.js +12 -14
- package/src/services/retention.js +5 -14
- package/src/services/session-store.js +127 -0
- package/src/web/routes.js +75 -13
- package/test/cleanup.test.js +1 -5
- package/test/logs.test.js +150 -0
- package/test/scheduler.test.js +10 -12
- package/test/session.test.js +132 -0
- package/views/help/_agent-cli.pug +1 -1
- package/views/help/_coordinator.pug +9 -6
- package/views/help/_docker.pug +1 -1
- package/views/help/_env.pug +1 -1
- package/views/help/_git.pug +3 -2
- package/views/help/_overview.pug +2 -2
- package/views/help/_troubleshooting.pug +2 -0
- package/views/jobs/list.pug +2 -2
- package/views/jobs/show.pug +0 -10
- package/views/mixins/log-viewer.pug +14 -0
- package/src/services/artifacts.js +0 -149
package/bin/thub-admin.js
CHANGED
|
@@ -30,7 +30,6 @@ 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'),
|
|
@@ -46,7 +45,7 @@ function usage(){
|
|
|
46
45
|
thub-admin resource maintenance <resourceId> --on|--off
|
|
47
46
|
thub-admin jobs reset --yes Cancel every queued/assigned/preparing/running job
|
|
48
47
|
thub-admin jobs clean --yes [--before "dd/mm/yyyy HH:MM:SS"]
|
|
49
|
-
Permanently delete finished jobs (with logs
|
|
48
|
+
Permanently delete finished jobs (with their logs) and
|
|
50
49
|
history from before then (local time; default: now), then
|
|
51
50
|
compact the database file
|
|
52
51
|
thub-admin group add <name> [--comment <text>]
|
|
@@ -134,8 +133,7 @@ function main(){
|
|
|
134
133
|
registry = createRegistryService(db, { bus, events }),
|
|
135
134
|
agents = createAgentsService(db, { events }),
|
|
136
135
|
adminUsers = createAdminUsersService(db),
|
|
137
|
-
|
|
138
|
-
jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
|
|
136
|
+
jobs = createJobsService(db, { bus, events, registry, config }),
|
|
139
137
|
groups = createGroupsService(db, { events, registry });
|
|
140
138
|
|
|
141
139
|
if (cmd === 'create-admin'){
|
|
@@ -193,7 +191,7 @@ function main(){
|
|
|
193
191
|
if (cmd === 'jobs' && sub === 'clean'){
|
|
194
192
|
const { flags } = parseFlags(rest);
|
|
195
193
|
if (!flags.yes){
|
|
196
|
-
console.error('This permanently deletes finished jobs and their logs
|
|
194
|
+
console.error('This permanently deletes finished jobs and their logs. Re-run with --yes to confirm.');
|
|
197
195
|
process.exit(4);
|
|
198
196
|
}
|
|
199
197
|
const before = flags.before ? parseDateTime(flags.before) : new Date();
|
package/config.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.39",
|
|
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": {
|
package/public/js/log-viewer.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file packages/coordinator/public/js/log-viewer.js
|
|
3
|
-
* @description Dashboard:
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
// `
|
|
82
|
-
//
|
|
83
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
})();
|
|
@@ -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
|
|
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,
|
|
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: ['
|
|
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
|
|
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) => {
|
package/src/api/agent.js
CHANGED
|
@@ -80,19 +80,13 @@ function createAgentRouter({ services, config }){
|
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
router.get('/jobs/:id/logs/stream', auth, (req, res) => {
|
|
83
|
-
attachJobStream(req, res, { jobId: req.params.id, services
|
|
83
|
+
attachJobStream(req, res, { jobId: req.params.id, services });
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
// No artifacts are stored any more (README §9); an Agent older than that
|
|
87
|
+
// still asks after a finished job's `thub status`, so answer "none".
|
|
86
88
|
router.get('/jobs/:id/artifacts', auth, (req, res) => {
|
|
87
|
-
|
|
88
|
-
id: a.id,
|
|
89
|
-
name: a.name,
|
|
90
|
-
size: a.size,
|
|
91
|
-
sha256: a.sha256,
|
|
92
|
-
contentType: a.content_type,
|
|
93
|
-
url: services.artifacts.signedUrl(a)
|
|
94
|
-
}));
|
|
95
|
-
res.json({ artifacts });
|
|
89
|
+
res.json({ artifacts: [] });
|
|
96
90
|
});
|
|
97
91
|
|
|
98
92
|
router.get('/resources', auth, (req, res) => {
|
package/src/api/resource.js
CHANGED
|
@@ -14,12 +14,9 @@
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
16
|
const express = require('express'),
|
|
17
|
-
multer = require('multer'),
|
|
18
17
|
{ requireRole, requireJoinKey, appVersion } = require('../auth'),
|
|
19
18
|
{ JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
20
19
|
|
|
21
|
-
const upload = multer({ dest: require('node:os').tmpdir() });
|
|
22
|
-
|
|
23
20
|
function requireOwnResource(req, res, next){
|
|
24
21
|
if (req.resource.id !== req.params.id){
|
|
25
22
|
return res.status(403).json({ error: 'Token does not match resource' });
|
|
@@ -211,14 +208,12 @@ function createResourceRouter({ services, config }){
|
|
|
211
208
|
}
|
|
212
209
|
});
|
|
213
210
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
next(err);
|
|
221
|
-
}
|
|
211
|
+
// Artifacts are no longer stored on the Coordinator (README §9). A Client
|
|
212
|
+
// older than that still uploads its results after each job and would end
|
|
213
|
+
// the job as ERROR on a 404, so accept the upload and discard it unread.
|
|
214
|
+
router.post('/jobs/:id/artifacts', auth, requireOwnJob(services), (req, res) => {
|
|
215
|
+
req.resume();
|
|
216
|
+
req.on('end', () => res.status(201).json({ artifacts: [] }));
|
|
222
217
|
});
|
|
223
218
|
|
|
224
219
|
router.post('/jobs/:id/result', auth, requireOwnJob(services), (req, res, next) => {
|
package/src/api/sse.js
CHANGED
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
|
|
16
16
|
const { TERMINAL_JOB_STATES, JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
17
17
|
|
|
18
|
-
const WAITING_CHECK_MS = 15_000
|
|
18
|
+
const WAITING_CHECK_MS = 15_000,
|
|
19
|
+
REPLAY_PAGE = 2000;
|
|
19
20
|
|
|
20
21
|
// §6.4: log / state / end events, resumable via Last-Event-ID (§7: "the
|
|
21
22
|
// Agent reconnects with exponential backoff and resumes from the last seq").
|
|
22
|
-
function attachJobStream(req, res, { jobId, services
|
|
23
|
+
function attachJobStream(req, res, { jobId, services }){
|
|
23
24
|
const { jobs, logs, registry, bus } = services,
|
|
24
25
|
|
|
25
26
|
job = jobs.get(jobId);
|
|
@@ -47,17 +48,19 @@ function attachJobStream(req, res, { jobId, services, config }){
|
|
|
47
48
|
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
// Replay everything after `afterSeq`, in pages (listSince stops at 500 a
|
|
52
|
+
// call — replaying one page used to skip lines 501.. of a longer log). All
|
|
53
|
+
// synchronous, so no live line can slip in between replay and subscribing.
|
|
54
|
+
for (let seq = afterSeq, page; (page = logs.listSince(jobId, seq, REPLAY_PAGE)).length;){
|
|
55
|
+
for (const line of page){
|
|
56
|
+
writeEvent('log', { ts: line.ts, stream: line.stream, line: line.line }, line.seq);
|
|
57
|
+
}
|
|
58
|
+
seq = page.at(-1).seq;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
const current = jobs.get(jobId);
|
|
59
62
|
if (TERMINAL_JOB_STATES.has(current.state)){
|
|
60
|
-
writeEvent('end', { state: current.state
|
|
63
|
+
writeEvent('end', { state: current.state });
|
|
61
64
|
return res.end();
|
|
62
65
|
}
|
|
63
66
|
|
|
@@ -97,7 +100,7 @@ function attachJobStream(req, res, { jobId, services, config }){
|
|
|
97
100
|
if (evt.jobId !== jobId){
|
|
98
101
|
return;
|
|
99
102
|
}
|
|
100
|
-
writeEvent('end', { state: evt.state
|
|
103
|
+
writeEvent('end', { state: evt.state });
|
|
101
104
|
cleanup();
|
|
102
105
|
res.end();
|
|
103
106
|
};
|
package/src/config.js
CHANGED
|
@@ -28,6 +28,12 @@ const DEFAULTS = {
|
|
|
28
28
|
trustProxy: 'loopback',
|
|
29
29
|
dataDir: path.join(process.cwd(), '.data'),
|
|
30
30
|
sessionSecret: 'dev-only-change-me',
|
|
31
|
+
session: {
|
|
32
|
+
// The dashboard session cookie's Secure flag (server.js cookieSecure):
|
|
33
|
+
// "auto" = Secure whenever publicUrl is https:// (else per request:
|
|
34
|
+
// Secure only on a connection that is itself HTTPS); true / false force it.
|
|
35
|
+
secureCookie: 'auto'
|
|
36
|
+
},
|
|
31
37
|
// Shared secret Clients present to self-register (see api/resource.js).
|
|
32
38
|
// null disables auto-registration entirely — set it explicitly to turn it on.
|
|
33
39
|
clientJoinKey: null,
|
|
@@ -46,19 +52,16 @@ const DEFAULTS = {
|
|
|
46
52
|
defaultTimeoutSec: 1800,
|
|
47
53
|
maxTimeoutSec: 14400
|
|
48
54
|
},
|
|
55
|
+
// logRetentionDays, artifactRetentionDays and the `artifacts` section of
|
|
56
|
+
// older configs are no longer read: a job's log lines live as long as the
|
|
57
|
+
// job, and the Coordinator stores no artifacts (README §9).
|
|
49
58
|
retention: {
|
|
50
|
-
|
|
51
|
-
artifactRetentionDays: 30,
|
|
52
|
-
// How long a finished job (with its logs, artifacts and events) is kept
|
|
59
|
+
// How long a finished job (with its logs and events) is kept
|
|
53
60
|
// before the hourly retention task wipes it (services/cleanup.js):
|
|
54
61
|
// 1w | 2w | 1m | 3m | 6m | forever. `forever` keeps everything until a
|
|
55
62
|
// manual cleanup (dashboard Clean up database / thub-admin jobs clean).
|
|
56
63
|
jobRetention: 'forever'
|
|
57
64
|
},
|
|
58
|
-
artifacts: {
|
|
59
|
-
maxUploadMb: 512,
|
|
60
|
-
linkTtlHours: 168
|
|
61
|
-
},
|
|
62
65
|
// New-version check of the Coordinator/Agent/Client packages (README
|
|
63
66
|
// §10.2), every 15 minutes so a new Coordinator's "Update app" button
|
|
64
67
|
// shows up promptly. 0 disables the periodic check; registry null = the
|
|
@@ -122,6 +125,12 @@ function loadConfig(configPath = process.env.THUB_COORDINATOR_CONFIG){
|
|
|
122
125
|
config.clientJoinKey = process.env.THUB_CLIENT_JOIN_KEY;
|
|
123
126
|
}
|
|
124
127
|
|
|
128
|
+
if (![true, false, 'auto'].includes(config.session.secureCookie)){
|
|
129
|
+
throw new Error(
|
|
130
|
+
`session.secureCookie must be "auto", true or false (got ${JSON.stringify(config.session.secureCookie)}) in ${candidate || 'the config'}`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
125
134
|
if (!Object.hasOwn(JOB_RETENTION, config.retention.jobRetention)){
|
|
126
135
|
throw new Error(
|
|
127
136
|
`retention.jobRetention must be one of: ${Object.keys(JOB_RETENTION).join(', ')} ` +
|
|
@@ -136,12 +145,10 @@ function loadConfig(configPath = process.env.THUB_COORDINATOR_CONFIG){
|
|
|
136
145
|
config.host = host;
|
|
137
146
|
config.port = Number(port);
|
|
138
147
|
config.dbPath = path.join(config.dataDir, 'thub.db');
|
|
139
|
-
config.artifactsDir = path.join(config.dataDir, 'artifacts');
|
|
140
148
|
config.workDir = path.join(config.dataDir, 'work');
|
|
141
149
|
config.avatarsDir = path.join(config.dataDir, 'avatars');
|
|
142
150
|
|
|
143
151
|
ensureWritableDir(config.dataDir, config.configPath);
|
|
144
|
-
ensureWritableDir(config.artifactsDir, config.configPath);
|
|
145
152
|
ensureWritableDir(config.avatarsDir, config.configPath);
|
|
146
153
|
|
|
147
154
|
return config;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Dashboard sessions (services/session-store.js), instead of express-session's
|
|
2
|
+
-- in-memory store: they survive a restart, and expired ones are pruned.
|
|
3
|
+
-- `expires` is epoch milliseconds (the session cookie's own expiry).
|
|
4
|
+
CREATE TABLE sessions (
|
|
5
|
+
sid TEXT PRIMARY KEY,
|
|
6
|
+
sess TEXT NOT NULL,
|
|
7
|
+
expires INTEGER NOT NULL
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
CREATE INDEX idx_sessions_expires ON sessions(expires);
|
package/src/dev/virtual.js
CHANGED
|
@@ -170,7 +170,7 @@ function seedHistory(services, agentId, resourceIds){
|
|
|
170
170
|
// stay IDLE instead of going OUT_OF_SERVICE) and play out any job the
|
|
171
171
|
// scheduler assigns them — accept, PREPARING, RUNNING with streamed log
|
|
172
172
|
// lines, then a PASSED (mostly) or FAILED result — through the same
|
|
173
|
-
// services the Client API uses, so SSE
|
|
173
|
+
// services the Client API uses, so SSE and durations all work.
|
|
174
174
|
function startVirtualClients(services, config){
|
|
175
175
|
const { registry, jobs, logs, bus } = services,
|
|
176
176
|
intervalMs = config.heartbeat.intervalSec * 1000,
|