@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.
- package/bin/thub-admin.js +26 -5
- package/config.json +5 -6
- package/package.json +2 -2
- package/public/js/actions.js +58 -0
- package/public/js/log-viewer.js +188 -56
- package/public/js/settings.js +66 -0
- 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 +28 -22
- package/src/api/resource.js +11 -14
- package/src/api/sse.js +13 -10
- package/src/config.js +33 -10
- package/src/db/migrations/022_add_sessions.sql +10 -0
- package/src/db/migrations/023_drop_artifacts.sql +4 -0
- 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 +10 -6
- package/src/login-guard.js +111 -0
- package/src/rate-limit.js +137 -0
- package/src/security.js +104 -0
- package/src/server.js +53 -23
- package/src/services/admin-users.js +32 -19
- package/src/services/cleanup.js +2 -2
- package/src/services/heartbeat.js +23 -3
- package/src/services/job-artifacts.js +91 -0
- package/src/services/jobs.js +17 -9
- package/src/services/logs.js +12 -14
- package/src/services/registry.js +34 -0
- package/src/services/retention.js +5 -14
- package/src/services/session-store.js +127 -0
- package/src/services/settings.js +345 -0
- package/src/web/routes.js +230 -21
- package/test/admin-users.test.js +16 -16
- package/test/agent-visibility.test.js +85 -0
- package/test/cleanup.test.js +1 -5
- 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 +150 -0
- package/test/rate-limit.test.js +106 -0
- package/test/scheduler.test.js +10 -12
- package/test/security.test.js +107 -0
- package/test/session.test.js +132 -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 +2 -2
- package/views/help/_agent-setup.pug +4 -1
- package/views/help/_client-setup.pug +1 -1
- package/views/help/_coordinator.pug +18 -7
- package/views/help/_docker.pug +1 -1
- package/views/help/_env.pug +2 -1
- package/views/help/_git.pug +3 -2
- package/views/help/_overview.pug +2 -2
- package/views/help/_troubleshooting.pug +4 -0
- package/views/jobs/list.pug +5 -5
- package/views/jobs/show.pug +33 -7
- 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/log-viewer.pug +14 -0
- package/views/mixins/resource.pug +1 -8
- 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
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
22
|
+
"rateLimit": {
|
|
23
|
+
"requestsPerMinute": 500,
|
|
24
|
+
"loginPerMinute": 10
|
|
25
25
|
},
|
|
26
|
-
"
|
|
27
|
-
"
|
|
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.
|
|
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.
|
|
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
|
+
})();
|
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
|
})();
|
|
@@ -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
|
|
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) => {
|