@andrian.yablonskyy/thub-coordinator 1.0.41 → 1.0.43
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/package.json +1 -1
- package/public/css/thub.css +108 -0
- package/public/js/actions.js +23 -0
- package/public/js/tooltips.js +5 -0
- package/src/api/sse.js +6 -0
- package/src/web/routes.js +8 -0
- package/test/stream-head.test.js +75 -0
- package/views/layout.pug +14 -17
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.43",
|
|
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/css/thub.css
CHANGED
|
@@ -227,3 +227,111 @@
|
|
|
227
227
|
animation: none;
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
|
+
|
|
231
|
+
/* Mobile / tablet menu (below lg, where the navbar collapses; layout.pug).
|
|
232
|
+
It drops down over the page instead of pushing it down, scrolls inside
|
|
233
|
+
itself on short screens, and is laid out for thumbs: pages as a two-column
|
|
234
|
+
grid, the tools in one row, the account in the next. public/js/actions.js
|
|
235
|
+
closes it on a tap outside, Esc, or choosing a page. */
|
|
236
|
+
.thub-nav-break {
|
|
237
|
+
display: none;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
@media (max-width: 991.98px) {
|
|
241
|
+
.navbar .thub-nav {
|
|
242
|
+
position: absolute;
|
|
243
|
+
top: 100%;
|
|
244
|
+
left: 0;
|
|
245
|
+
right: 0;
|
|
246
|
+
z-index: 1035;
|
|
247
|
+
max-height: calc(100dvh - 4rem);
|
|
248
|
+
overflow-y: auto;
|
|
249
|
+
padding: 0.75rem 1rem 1rem;
|
|
250
|
+
background: var(--bs-tertiary-bg);
|
|
251
|
+
border-bottom: 1px solid var(--bs-border-color);
|
|
252
|
+
box-shadow: 0 0.75rem 1.5rem rgba(0, 0, 0, 0.15);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.thub-nav-pages {
|
|
256
|
+
display: grid;
|
|
257
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
258
|
+
gap: 0.375rem;
|
|
259
|
+
padding-bottom: 0.75rem;
|
|
260
|
+
border-bottom: 1px solid var(--bs-border-color);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.thub-nav-pages .nav-link {
|
|
264
|
+
display: flex;
|
|
265
|
+
align-items: center;
|
|
266
|
+
min-height: 2.75rem;
|
|
267
|
+
padding: 0.5rem 0.75rem;
|
|
268
|
+
border-radius: var(--bs-border-radius);
|
|
269
|
+
background: var(--bs-body-bg);
|
|
270
|
+
border: 1px solid var(--bs-border-color-translucent);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.thub-nav-pages .nav-link.active {
|
|
274
|
+
background: var(--bs-primary-bg-subtle);
|
|
275
|
+
border-color: var(--bs-primary-border-subtle);
|
|
276
|
+
color: var(--bs-primary-text-emphasis);
|
|
277
|
+
font-weight: 500;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.thub-nav-tools {
|
|
281
|
+
flex-direction: row;
|
|
282
|
+
flex-wrap: wrap;
|
|
283
|
+
align-items: center;
|
|
284
|
+
gap: 0.5rem 0.75rem;
|
|
285
|
+
padding-top: 0.75rem;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.thub-nav-tools > li {
|
|
289
|
+
margin: 0 !important;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* Bigger tap targets for the icon buttons. */
|
|
293
|
+
.thub-nav-tools .btn-sm {
|
|
294
|
+
min-width: 2.5rem;
|
|
295
|
+
min-height: 2.5rem;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* The account row: the profile link takes the line, sign-out at its end. */
|
|
299
|
+
.thub-nav-break {
|
|
300
|
+
display: block;
|
|
301
|
+
flex-basis: 100%;
|
|
302
|
+
height: 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.thub-nav-account {
|
|
306
|
+
flex: 1 1 auto;
|
|
307
|
+
min-height: 2.5rem;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/* Phones: wide tables scroll sideways inside their own box instead of
|
|
312
|
+
running off the page, with one-line headers (wrapped headers of squeezed
|
|
313
|
+
columns used to make the header row very tall). The resource card's tab
|
|
314
|
+
row scrolls sideways too rather than wrapping onto several lines. */
|
|
315
|
+
@media (max-width: 767.98px) {
|
|
316
|
+
main .table:not(.table-responsive .table) {
|
|
317
|
+
display: block;
|
|
318
|
+
max-width: 100%;
|
|
319
|
+
overflow-x: auto;
|
|
320
|
+
-webkit-overflow-scrolling: touch;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
main .table th {
|
|
324
|
+
white-space: nowrap;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.thub-resource-card .nav-tabs {
|
|
328
|
+
flex-wrap: nowrap;
|
|
329
|
+
overflow-x: auto;
|
|
330
|
+
overflow-y: hidden;
|
|
331
|
+
scrollbar-width: thin;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.thub-resource-card .nav-tabs .nav-link {
|
|
335
|
+
white-space: nowrap;
|
|
336
|
+
}
|
|
337
|
+
}
|
package/public/js/actions.js
CHANGED
|
@@ -39,6 +39,29 @@
|
|
|
39
39
|
}
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
// The collapsed (mobile) menu drops down over the page (thub.css
|
|
43
|
+
// .thub-nav): close it on a tap outside, on Esc, and once a page in it is
|
|
44
|
+
// chosen — over the content, it shouldn't stay open in the way.
|
|
45
|
+
const nav = document.getElementById('nav'),
|
|
46
|
+
closeNav = () => {
|
|
47
|
+
if (nav?.classList.contains('show')){
|
|
48
|
+
window.bootstrap?.Collapse.getOrCreateInstance(nav, { toggle: false }).hide();
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
document.addEventListener('click', (e) => {
|
|
52
|
+
if (!nav?.classList.contains('show')){
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!e.target.closest('#nav, .navbar-toggler') || e.target.closest('#nav a[href]')){
|
|
56
|
+
closeNav();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
document.addEventListener('keydown', (e) => {
|
|
60
|
+
if (e.key === 'Escape'){
|
|
61
|
+
closeNav();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
42
65
|
// <form data-submit-busy>: disable its button and show a spinner while
|
|
43
66
|
// the request runs (e.g. the navbar's check for updates).
|
|
44
67
|
document.addEventListener('submit', (e) => {
|
package/public/js/tooltips.js
CHANGED
|
@@ -16,5 +16,10 @@
|
|
|
16
16
|
// Delegated (one instance per element, created on first hover/focus), so
|
|
17
17
|
// content swapped in by live updates (public/js/live.js) gets tooltips too.
|
|
18
18
|
(function (){
|
|
19
|
+
// On touch screens a tap would open a tooltip and leave it stuck there
|
|
20
|
+
// (no hover to end it) — over the mobile menu, too: no tooltips there.
|
|
21
|
+
if (!window.matchMedia('(hover: hover)').matches){
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
19
24
|
new bootstrap.Tooltip(document.body, { selector: '[data-bs-toggle="tooltip"]' });
|
|
20
25
|
})();
|
package/src/api/sse.js
CHANGED
|
@@ -28,6 +28,12 @@ function attachJobStream(req, res, { jobId, services }){
|
|
|
28
28
|
res.status(404).json({ error: 'Unknown job' });
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
|
+
// HEAD (a monitoring check, a proxy): no stream — one opened for it would
|
|
32
|
+
// never end, and block every later request on the same connection.
|
|
33
|
+
if (req.method === 'HEAD'){
|
|
34
|
+
res.status(204).end();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
31
37
|
|
|
32
38
|
res.writeHead(200, {
|
|
33
39
|
'Content-Type': 'text/event-stream',
|
package/src/web/routes.js
CHANGED
|
@@ -395,6 +395,14 @@ function createWebRouter({ services, config }){
|
|
|
395
395
|
const LIVE_PING_MS = 25_000,
|
|
396
396
|
LIVE_SESSION_CHECK_MS = 15_000;
|
|
397
397
|
router.get('/live', (req, res) => {
|
|
398
|
+
// HEAD (live.js asks it whether the session is still alive): answer and
|
|
399
|
+
// finish. Express sends HEAD to this GET handler, and a stream opened
|
|
400
|
+
// for it would never end: the browser — or a proxy — reuses the
|
|
401
|
+
// connection once the headers are in, and every later request on it
|
|
402
|
+
// would queue behind that response forever (stuck page loads).
|
|
403
|
+
if (req.method === 'HEAD'){
|
|
404
|
+
return res.status(204).end();
|
|
405
|
+
}
|
|
398
406
|
res.writeHead(200, {
|
|
399
407
|
'Content-Type': 'text/event-stream',
|
|
400
408
|
'Cache-Control': 'no-cache, no-transform',
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/stream-head.test.js
|
|
3
|
+
* @description Tests: a HEAD request to a stream endpoint (/live, job log streams) finishes, and doesn't block its keep-alive connection
|
|
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
|
+
http = require('node:http'),
|
|
20
|
+
os = require('node:os'),
|
|
21
|
+
path = require('node:path'),
|
|
22
|
+
{ loadConfig } = require('../src/config'),
|
|
23
|
+
{ buildServices, createApp } = require('../src/server');
|
|
24
|
+
|
|
25
|
+
// What a browser does: one keep-alive connection, reused as soon as a
|
|
26
|
+
// response is complete — and a HEAD response is complete with its headers.
|
|
27
|
+
function oneConnection(port){
|
|
28
|
+
const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }),
|
|
29
|
+
request = (method, url, headers = {}) => new Promise((resolve, reject) => {
|
|
30
|
+
const req = http.request({ host: '127.0.0.1', port, method, path: url, headers, agent }, (res) => {
|
|
31
|
+
res.resume();
|
|
32
|
+
res.on('end', () => resolve(res.statusCode));
|
|
33
|
+
});
|
|
34
|
+
req.setTimeout(3000, () => req.destroy(new Error(`${method} ${url}: no answer within 3 s — the connection is blocked`)));
|
|
35
|
+
req.on('error', reject);
|
|
36
|
+
req.end();
|
|
37
|
+
});
|
|
38
|
+
return { request, close: () => agent.destroy() };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('HEAD on a stream endpoint answers at once and leaves the connection usable (no stuck page loads)', async (t) => {
|
|
42
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-head-')),
|
|
43
|
+
file = path.join(dir, 'coordinator.json');
|
|
44
|
+
fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 } }));
|
|
45
|
+
const config = loadConfig(file),
|
|
46
|
+
services = buildServices(config),
|
|
47
|
+
server = createApp(config, services).listen(0, '127.0.0.1');
|
|
48
|
+
t.after(() => new Promise((r) => {
|
|
49
|
+
server.closeAllConnections?.();
|
|
50
|
+
server.close(r);
|
|
51
|
+
}));
|
|
52
|
+
await new Promise((r) => server.on('listening', r));
|
|
53
|
+
const port = server.address().port;
|
|
54
|
+
services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
|
|
55
|
+
const cookie = (await fetch(`http://127.0.0.1:${port}/login`, {
|
|
56
|
+
method: 'POST', body: new URLSearchParams({ username: 'admin', password: 'pw' }), redirect: 'manual'
|
|
57
|
+
})).headers.get('set-cookie').split(';')[0],
|
|
58
|
+
{ agent, token } = services.agents.create({ name: 'ci', kind: 'ci' }),
|
|
59
|
+
// A Client the job can wait for (a job nothing could ever run is refused).
|
|
60
|
+
registered = services.registry.registerAuto({ clientId: 'c1', name: 'lab-sw-01', type: 'sw', labels: [] }),
|
|
61
|
+
// An active job: its streams stay open for a GET.
|
|
62
|
+
job = registered && services.jobs.create({ agentId: agent.id, source: 'ci', spec: { target: { type: 'sw' }, command: './t.sh' } });
|
|
63
|
+
|
|
64
|
+
for (const [url, headers]of [
|
|
65
|
+
['/live', { cookie, 'X-Thub-Live': '1' }], // what live.js probes
|
|
66
|
+
[`/jobs/${job.id}/stream`, { cookie }],
|
|
67
|
+
[`/api/v1/jobs/${job.id}/logs/stream`, { authorization: `Bearer ${token}` }]
|
|
68
|
+
]){
|
|
69
|
+
const conn = oneConnection(port);
|
|
70
|
+
t.after(conn.close);
|
|
71
|
+
assert.equal(await conn.request('HEAD', url, headers), 204, url);
|
|
72
|
+
// The next request on the very same connection: answered, not queued forever.
|
|
73
|
+
assert.equal(await conn.request('GET', '/js/copy-to-clipboard.js'), 200, `after HEAD ${url}`);
|
|
74
|
+
}
|
|
75
|
+
});
|
package/views/layout.pug
CHANGED
|
@@ -29,23 +29,18 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
29
29
|
)= `v${updates.coordinatorUpdate} available`
|
|
30
30
|
button.navbar-toggler(type="button" data-bs-toggle="collapse" data-bs-target="#nav")
|
|
31
31
|
span.navbar-toggler-icon
|
|
32
|
-
#nav.collapse.navbar-collapse
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
#nav.collapse.navbar-collapse.thub-nav
|
|
33
|
+
//- Below lg the menu drops down over the page (thub.css .thub-nav,
|
|
34
|
+
//- closed by public/js/actions.js): pages as a two-column grid with
|
|
35
|
+
//- icons, then the tools in a row, then the account row.
|
|
36
|
+
- const pages = user ? [['/', 'overview', 'Overview', 'speedometer2'], ['/resources', 'resources', 'Resources', 'hdd-rack'], ['/groups', 'groups', 'Groups', 'collection'], ['/jobs', 'jobs', 'Jobs', 'list-task']].concat(user.role === 'admin' ? [['/admin/agents', 'agents', 'Agents', 'person-badge'], ['/admin/settings', 'settings', 'Settings', 'gear']] : []) : []
|
|
37
|
+
ul.navbar-nav.me-auto.thub-nav-pages
|
|
38
|
+
each pg in pages
|
|
39
39
|
li.nav-item
|
|
40
|
-
a.nav-link(href=
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
li.nav-item
|
|
45
|
-
a.nav-link(href="/admin/agents" class=active === 'agents' ? 'active' : '') Agents
|
|
46
|
-
li.nav-item
|
|
47
|
-
a.nav-link(href="/admin/settings" class=active === 'settings' ? 'active' : '') Settings
|
|
48
|
-
ul.navbar-nav
|
|
40
|
+
a.nav-link(href=pg[0] class=active === pg[1] ? 'active' : '' aria-current=active === pg[1] ? 'page' : false)
|
|
41
|
+
i.bi.me-2.d-lg-none(class=`bi-${pg[3]}` aria-hidden="true")
|
|
42
|
+
= pg[2]
|
|
43
|
+
ul.navbar-nav.thub-nav-tools
|
|
49
44
|
if user && liveTopics
|
|
50
45
|
//- Live-update status, driven by public/js/live.js.
|
|
51
46
|
li.nav-item.d-flex.align-items-center.me-3
|
|
@@ -117,7 +112,8 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
117
112
|
)
|
|
118
113
|
i.bi.bi-circle-half
|
|
119
114
|
if user
|
|
120
|
-
li.nav-
|
|
115
|
+
li.thub-nav-break(aria-hidden="true")
|
|
116
|
+
li.nav-item.d-flex.align-items-center.me-3.thub-nav-account
|
|
121
117
|
a.nav-link.d-flex.align-items-center.p-0(
|
|
122
118
|
href="/profile"
|
|
123
119
|
data-bs-toggle="tooltip"
|
|
@@ -130,6 +126,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
130
126
|
span.rounded-circle.bg-secondary.text-white.d-inline-flex.align-items-center.justify-content-center.me-2(style="width: 24px; height: 24px; font-size: 12px;")
|
|
131
127
|
= ((user.firstName ? user.firstName[0] : user.username[0]) + (user.lastName ? user.lastName[0] : '')).toUpperCase()
|
|
132
128
|
span= user.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : user.username
|
|
129
|
+
span.small.text-body-secondary.ms-2.d-lg-none= `${user.role} · profile`
|
|
133
130
|
li.nav-item.d-flex.align-items-center
|
|
134
131
|
form(action="/logout" method="post")
|
|
135
132
|
button.btn.btn-sm.btn-outline-danger(
|