@andrian.yablonskyy/thub-coordinator 1.0.21 → 1.0.23
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 +25 -0
- package/public/js/confirm.js +69 -0
- package/src/web/routes.js +2 -2
- package/views/admin/agents.pug +7 -1
- package/views/groups/list.pug +7 -1
- package/views/jobs/list.pug +6 -4
- package/views/jobs/show.pug +10 -3
- package/views/layout.pug +19 -1
- package/views/mixins/resource.pug +15 -5
- package/views/mixins/status-badge.pug +1 -1
- package/views/mixins/updates.pug +4 -1
- package/views/resources/list.pug +1 -1
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.23",
|
|
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
|
@@ -28,3 +28,28 @@
|
|
|
28
28
|
from { transform: scaleX(1); }
|
|
29
29
|
to { transform: scaleX(0); }
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/* Hints (Bootstrap tooltips, public/js/tooltips.js): left-aligned — many
|
|
33
|
+
are multi-line sentences — and 0.9 of Bootstrap's .875rem font size. */
|
|
34
|
+
.tooltip {
|
|
35
|
+
--bs-tooltip-font-size: calc(0.875rem * 0.9);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.tooltip-inner {
|
|
39
|
+
text-align: left;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Resource card (views/mixins/resource.pug): 30% wider than Bootstrap's
|
|
43
|
+
default 500px modal. Only applies from 576px up, like Bootstrap's own
|
|
44
|
+
width — below that the modal fills the screen as before. */
|
|
45
|
+
.modal.thub-resource-card {
|
|
46
|
+
--bs-modal-width: calc(500px * 1.3);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* A status badge inside a page heading (e.g. the job page's state): badges
|
|
50
|
+
are 0.75em of their parent, which in an h4 makes them oversized — keep
|
|
51
|
+
it close to the badges in tables, centered on the heading text. */
|
|
52
|
+
.thub-heading-badge {
|
|
53
|
+
font-size: 0.55em;
|
|
54
|
+
vertical-align: middle;
|
|
55
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/public/js/confirm.js
|
|
3
|
+
* @description Dashboard: confirmation modal for forms marked data-confirm (replaces the browser's confirm())
|
|
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
|
+
// A form with data-confirm="<message>" asks first, in the shared
|
|
17
|
+
// #thub-confirm-modal (layout.pug), and is only submitted on confirm.
|
|
18
|
+
// Optional: data-confirm-title, data-confirm-ok (confirm button label),
|
|
19
|
+
// data-confirm-tone (Bootstrap color: danger — the default —, warning,
|
|
20
|
+
// primary) and data-confirm-dismiss (the other button, default "Cancel").
|
|
21
|
+
// Texts are set as plain text, never HTML — they carry resource/group names.
|
|
22
|
+
(function (){
|
|
23
|
+
const modalEl = document.getElementById('thub-confirm-modal');
|
|
24
|
+
if (!modalEl){
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const modal = bootstrap.Modal.getOrCreateInstance(modalEl),
|
|
28
|
+
field = (name) => modalEl.querySelector(`[data-field="${name}"]`),
|
|
29
|
+
TONES = ['danger', 'warning', 'primary', 'secondary', 'success', 'info'];
|
|
30
|
+
let pending = null; // { form, submitter } awaiting an answer
|
|
31
|
+
|
|
32
|
+
document.addEventListener('submit', (e) => {
|
|
33
|
+
const form = e.target;
|
|
34
|
+
if (!(form instanceof HTMLFormElement) || !form.dataset.confirm){
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (form.dataset.confirmed === '1'){
|
|
38
|
+
delete form.dataset.confirmed; // one confirmation per submit
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
const tone = TONES.includes(form.dataset.confirmTone) ? form.dataset.confirmTone : 'danger';
|
|
43
|
+
pending = { form, submitter: e.submitter };
|
|
44
|
+
field('title').textContent = form.dataset.confirmTitle || 'Are you sure?';
|
|
45
|
+
field('message').textContent = form.dataset.confirm;
|
|
46
|
+
field('header').className = `modal-header text-bg-${tone}`;
|
|
47
|
+
field('ok').className = `btn btn-${tone}`;
|
|
48
|
+
field('ok').textContent = form.dataset.confirmOk || 'Confirm';
|
|
49
|
+
field('dismiss').textContent = form.dataset.confirmDismiss || 'Cancel';
|
|
50
|
+
modal.show();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
field('ok').addEventListener('click', () => {
|
|
54
|
+
if (!pending){
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const { form, submitter } = pending;
|
|
58
|
+
pending = null;
|
|
59
|
+
form.dataset.confirmed = '1';
|
|
60
|
+
modal.hide();
|
|
61
|
+
// requestSubmit (not submit) so the form's own submit handlers still run.
|
|
62
|
+
form.requestSubmit(submitter && form.contains(submitter) ? submitter : undefined);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
modalEl.addEventListener('shown.bs.modal', () => field('ok').focus());
|
|
66
|
+
modalEl.addEventListener('hidden.bs.modal', () => {
|
|
67
|
+
pending = null;
|
|
68
|
+
});
|
|
69
|
+
})();
|
package/src/web/routes.js
CHANGED
|
@@ -408,7 +408,7 @@ function createWebRouter({ services, config }){
|
|
|
408
408
|
}
|
|
409
409
|
|
|
410
410
|
parts.push(s.coordinatorUpdate
|
|
411
|
-
? `Coordinator v${s.coordinatorVersion} → v${s.coordinatorUpdate} available
|
|
411
|
+
? `Coordinator v${s.coordinatorVersion} → v${s.coordinatorUpdate} available.`
|
|
412
412
|
: `Coordinator is up to date (v${s.coordinatorVersion}).`);
|
|
413
413
|
|
|
414
414
|
const connected = services.registry.list().filter((r) => r.status !== RESOURCE_STATES.OUT_OF_SERVICE && r.last_heartbeat_at),
|
|
@@ -435,7 +435,7 @@ function createWebRouter({ services, config }){
|
|
|
435
435
|
parts.push(`Some checks failed: ${s.error}`);
|
|
436
436
|
}
|
|
437
437
|
const behind = s.coordinatorUpdate || outdated.length || unknown.length;
|
|
438
|
-
return { type: behind || s.error ? 'warning' : 'success', text: parts.join('
|
|
438
|
+
return { type: behind || s.error ? 'warning' : 'success', text: parts.join('\n') };
|
|
439
439
|
}));
|
|
440
440
|
|
|
441
441
|
router.post('/updates/coordinator', requireAdminRole, updateAction(async (req) => {
|
package/views/admin/agents.pug
CHANGED
|
@@ -37,7 +37,13 @@ block content
|
|
|
37
37
|
if !a.revoked_at
|
|
38
38
|
.d-flex.justify-content-end
|
|
39
39
|
+updateButton(`/admin/agents/${a.id}/update`, a.update_to, '/admin/agents')
|
|
40
|
-
form.ms-1(
|
|
40
|
+
form.ms-1(
|
|
41
|
+
method="post"
|
|
42
|
+
action=`/admin/agents/${a.id}/revoke`
|
|
43
|
+
data-confirm=`Revoke the token of agent "${a.name}"? Everything using it — a CI pipeline, a developer's thub — gets 401 from then on. This cannot be undone.`
|
|
44
|
+
data-confirm-title="Revoke agent token?"
|
|
45
|
+
data-confirm-ok="Revoke"
|
|
46
|
+
)
|
|
41
47
|
button.btn.btn-sm.btn-outline-danger(type="submit") Revoke
|
|
42
48
|
|
|
43
49
|
.modal(tabindex="-1" id="addAgentModal")
|
package/views/groups/list.pug
CHANGED
|
@@ -41,7 +41,13 @@ block content
|
|
|
41
41
|
if canManage
|
|
42
42
|
td
|
|
43
43
|
button.btn.btn-sm.btn-outline-secondary(type="button" data-bs-toggle="modal" data-bs-target=`#editGroupModal-${g.id}`) Edit
|
|
44
|
-
form.d-inline.ms-1(
|
|
44
|
+
form.d-inline.ms-1(
|
|
45
|
+
method="post"
|
|
46
|
+
action=`/groups/${g.id}/delete`
|
|
47
|
+
data-confirm=`Delete group "${g.name}"? Resources listing it just stop matching on it, and jobs targeting it (--group) can no longer be scheduled.`
|
|
48
|
+
data-confirm-title="Delete group?"
|
|
49
|
+
data-confirm-ok="Delete group"
|
|
50
|
+
)
|
|
45
51
|
button.btn.btn-sm.btn-outline-danger(type="submit") Delete
|
|
46
52
|
|
|
47
53
|
.modal(tabindex="-1" id=`editGroupModal-${g.id}`)
|
package/views/jobs/list.pug
CHANGED
|
@@ -48,18 +48,20 @@ block content
|
|
|
48
48
|
td: +jobBadge(j.state)
|
|
49
49
|
td= j.resource ? j.resource.name : j.resource_name ? `${j.resource_name} (removed)` : '—'
|
|
50
50
|
td= fmtDate(j.created_at)
|
|
51
|
-
td(title=j.finished_at ? `Finished ${fmtDate(j.finished_at)}` : null)= j.duration_sec != null ? fmtDuration(j.duration_sec * 1000) : '—'
|
|
51
|
+
td(data-bs-toggle=j.finished_at ? 'tooltip' : null data-bs-title=j.finished_at ? `Finished ${fmtDate(j.finished_at)}` : null)= j.duration_sec != null ? fmtDuration(j.duration_sec * 1000) : '—'
|
|
52
52
|
if canManage
|
|
53
53
|
td.text-end
|
|
54
54
|
if j.active
|
|
55
55
|
form.d-inline(
|
|
56
56
|
method="post"
|
|
57
57
|
action=`/jobs/${j.id}/cancel`
|
|
58
|
-
data-confirm=`Cancel job ${j.id}
|
|
59
|
-
|
|
58
|
+
data-confirm=`Cancel job ${j.id}? It ends as CANCELED; if it's running, its Client stops it.`
|
|
59
|
+
data-confirm-title="Cancel job?"
|
|
60
|
+
data-confirm-ok="Cancel job"
|
|
61
|
+
data-confirm-dismiss="Keep it"
|
|
60
62
|
)
|
|
61
63
|
input(type="hidden" name="returnTo" value=returnTo)
|
|
62
|
-
button.btn.btn-sm.btn-outline-danger(type="submit" aria-label="Cancel" title="Cancel this job")
|
|
64
|
+
button.btn.btn-sm.btn-outline-danger(type="submit" aria-label="Cancel" data-bs-toggle="tooltip" data-bs-title="Cancel this job")
|
|
63
65
|
i.bi.bi-x-circle
|
|
64
66
|
|
|
65
67
|
if canManage
|
package/views/jobs/show.pug
CHANGED
|
@@ -7,9 +7,16 @@ block content
|
|
|
7
7
|
h1.h4.mb-0
|
|
8
8
|
= job.id
|
|
9
9
|
|
|
|
10
|
-
+jobBadge(job.state)
|
|
10
|
+
+jobBadge(job.state)(class="thub-heading-badge ms-1")
|
|
11
11
|
if canCancel
|
|
12
|
-
form(
|
|
12
|
+
form(
|
|
13
|
+
method="post"
|
|
14
|
+
action=`/jobs/${job.id}/cancel`
|
|
15
|
+
data-confirm=`Cancel job ${job.id}? It ends as CANCELED; if it's running, its Client stops it.`
|
|
16
|
+
data-confirm-title="Cancel job?"
|
|
17
|
+
data-confirm-ok="Cancel job"
|
|
18
|
+
data-confirm-dismiss="Keep it"
|
|
19
|
+
)
|
|
13
20
|
button.btn.btn-outline-danger.btn-sm(type="submit") Cancel
|
|
14
21
|
|
|
15
22
|
dl.row
|
|
@@ -29,7 +36,7 @@ block content
|
|
|
29
36
|
dd.col-sm-10= fmtDate(job.created_at)
|
|
30
37
|
if job.duration_sec != null
|
|
31
38
|
dt.col-sm-2 Duration
|
|
32
|
-
dd.col-sm-10(title=`Finished ${fmtDate(job.finished_at)}`)= fmtDuration(job.duration_sec * 1000)
|
|
39
|
+
dd.col-sm-10(data-bs-toggle="tooltip" data-bs-title=`Finished ${fmtDate(job.finished_at)}`)= fmtDuration(job.duration_sec * 1000)
|
|
33
40
|
if job.message
|
|
34
41
|
dt.col-sm-2 Message
|
|
35
42
|
dd.col-sm-10= job.message
|
package/views/layout.pug
CHANGED
|
@@ -66,7 +66,10 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
66
66
|
form(
|
|
67
67
|
method="post"
|
|
68
68
|
action="/updates/coordinator"
|
|
69
|
-
|
|
69
|
+
data-confirm=`Update the Coordinator to v${updates.coordinatorUpdate}? It restarts, so the dashboard and API are briefly unavailable — this page reloads by itself once it's back.`
|
|
70
|
+
data-confirm-title="Update the Coordinator?"
|
|
71
|
+
data-confirm-ok="Update and restart"
|
|
72
|
+
data-confirm-tone="warning"
|
|
70
73
|
)
|
|
71
74
|
input(type="hidden" name="returnTo" value=currentPath)
|
|
72
75
|
button.btn.btn-sm.btn-warning(
|
|
@@ -142,11 +145,26 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
142
145
|
)
|
|
143
146
|
main.container.py-4
|
|
144
147
|
block content
|
|
148
|
+
//- Shared confirmation for forms with data-confirm (public/js/confirm.js)
|
|
149
|
+
//- — used instead of the browser's confirm() everywhere.
|
|
150
|
+
if user
|
|
151
|
+
.modal.fade#thub-confirm-modal(tabindex="-1" aria-labelledby="thub-confirm-title" aria-hidden="true")
|
|
152
|
+
.modal-dialog.modal-dialog-centered
|
|
153
|
+
.modal-content
|
|
154
|
+
.modal-header.text-bg-danger(data-field="header")
|
|
155
|
+
h5.modal-title#thub-confirm-title(data-field="title") Are you sure?
|
|
156
|
+
button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
|
|
157
|
+
.modal-body
|
|
158
|
+
p.mb-0(data-field="message" style="white-space: pre-line;")
|
|
159
|
+
.modal-footer
|
|
160
|
+
button.btn.btn-secondary(type="button" data-bs-dismiss="modal" data-field="dismiss") Cancel
|
|
161
|
+
button.btn.btn-danger(type="button" data-field="ok") Confirm
|
|
145
162
|
script(src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js")
|
|
146
163
|
script(src="/js/theme.js")
|
|
147
164
|
script(src="/js/tooltips.js")
|
|
148
165
|
script(src="/js/toasts.js")
|
|
149
166
|
script(src="/js/copy-to-clipboard.js")
|
|
167
|
+
script(src="/js/confirm.js")
|
|
150
168
|
if user && user.role === 'admin' && updates.coordinatorPending
|
|
151
169
|
script(src="/js/update-watch.js")
|
|
152
170
|
block scripts
|
|
@@ -24,7 +24,14 @@ mixin resourceActions(r, returnTo, opts = {})
|
|
|
24
24
|
data-bs-title="Take this resource out of rotation for maintenance — the scheduler will skip it"
|
|
25
25
|
)
|
|
26
26
|
i.bi.bi-tools
|
|
27
|
-
form.d-inline.ms-1(
|
|
27
|
+
form.d-inline.ms-1(
|
|
28
|
+
method="post"
|
|
29
|
+
action=`/resources/${r.id}/rotate-token`
|
|
30
|
+
data-confirm=`Issue a new token for ${r.name}? Its current token stops working at once, and the Client won't reconnect until it has the new one.`
|
|
31
|
+
data-confirm-title="Rotate resource token?"
|
|
32
|
+
data-confirm-ok="Rotate token"
|
|
33
|
+
data-confirm-tone="warning"
|
|
34
|
+
)
|
|
28
35
|
input(type="hidden" name="returnTo" value=returnTo)
|
|
29
36
|
button.btn.btn-sm.btn-outline-warning(
|
|
30
37
|
type="submit"
|
|
@@ -62,7 +69,8 @@ mixin removeResourceButton(r, returnTo)
|
|
|
62
69
|
method="post"
|
|
63
70
|
action=`/resources/${r.id}/remove`
|
|
64
71
|
data-confirm=`Remove ${r.name} from the list? Its job history is kept. A Client that's still running shows up again when it restarts and re-registers.`
|
|
65
|
-
|
|
72
|
+
data-confirm-title=`Remove ${r.name}?`
|
|
73
|
+
data-confirm-ok="Remove"
|
|
66
74
|
)
|
|
67
75
|
input(type="hidden" name="returnTo" value=returnTo)
|
|
68
76
|
button.btn.btn-sm.btn-outline-danger(
|
|
@@ -211,7 +219,9 @@ mixin cancelActivityButton(r, returnTo)
|
|
|
211
219
|
method="post"
|
|
212
220
|
action=`/resources/${r.id}/cancel-activity`
|
|
213
221
|
data-confirm=`Cancel ${what} on ${r.name}?`
|
|
214
|
-
|
|
222
|
+
data-confirm-title=`Cancel ${what}?`
|
|
223
|
+
data-confirm-ok=`Cancel ${what}`
|
|
224
|
+
data-confirm-dismiss="Keep it"
|
|
215
225
|
)
|
|
216
226
|
input(type="hidden" name="returnTo" value=returnTo)
|
|
217
227
|
button.btn.btn-sm.btn-outline-danger(
|
|
@@ -226,7 +236,7 @@ mixin resourceCard(r, groupsById, returnTo)
|
|
|
226
236
|
- const isLoopback = (a) => /^127\./.test(a) || a === '::1' || a === 'localhost'
|
|
227
237
|
- const local = ((r.host_info && r.host_info.addresses) || []).filter((a) => !isLoopback(a.address))
|
|
228
238
|
- const external = r.remote_addr && !isLoopback(r.remote_addr) ? r.remote_addr : null
|
|
229
|
-
.modal.fade(id=`resource-card-${r.id}` tabindex="-1" aria-labelledby=`resource-card-${r.id}-title` aria-hidden="true")
|
|
239
|
+
.modal.fade.thub-resource-card(id=`resource-card-${r.id}` tabindex="-1" aria-labelledby=`resource-card-${r.id}-title` aria-hidden="true")
|
|
230
240
|
.modal-dialog.modal-dialog-centered
|
|
231
241
|
.modal-content
|
|
232
242
|
.modal-header
|
|
@@ -279,7 +289,7 @@ mixin resourceCard(r, groupsById, returnTo)
|
|
|
279
289
|
dd.col-sm-8
|
|
280
290
|
if r.group_ids.length
|
|
281
291
|
each gid in r.group_ids
|
|
282
|
-
span.badge.text-bg-info.me-1(title=gid)= groupsById[gid] ? groupsById[gid].name : `(unknown: ${gid})`
|
|
292
|
+
span.badge.text-bg-info.me-1(data-bs-toggle="tooltip" data-bs-title=gid)= groupsById[gid] ? groupsById[gid].name : `(unknown: ${gid})`
|
|
283
293
|
else
|
|
284
294
|
span.text-body-secondary —
|
|
285
295
|
dt.col-sm-4 Busy source / reason
|
|
@@ -11,4 +11,4 @@ mixin resourceBadge(status, removing)
|
|
|
11
11
|
|
|
12
12
|
mixin jobBadge(state)
|
|
13
13
|
- const map = { QUEUED: 'secondary', ASSIGNED: 'info', PREPARING: 'info', RUNNING: 'primary', PASSED: 'success', FAILED: 'danger', ERROR: 'danger', TIMEOUT: 'warning', CANCELED: 'secondary', LOST: 'danger' }
|
|
14
|
-
span.badge(class=`text-bg-${map[state] || 'secondary'}`)= state
|
|
14
|
+
span.badge(class=`text-bg-${map[state] || 'secondary'}`)&attributes(attributes)= state
|
package/views/mixins/updates.pug
CHANGED
|
@@ -37,7 +37,10 @@ mixin updatesToolbar(app, label, returnTo)
|
|
|
37
37
|
form.d-inline(
|
|
38
38
|
method="post"
|
|
39
39
|
action=app === 'agent' ? '/admin/agents/update-all' : '/resources/update-all'
|
|
40
|
-
|
|
40
|
+
data-confirm=`Request a self-update of every ${label} to the latest version${updates.latest[app] ? ` (v${updates.latest[app]})` : ''}?`
|
|
41
|
+
data-confirm-title=`Update all ${label}?`
|
|
42
|
+
data-confirm-ok="Request update"
|
|
43
|
+
data-confirm-tone="primary"
|
|
41
44
|
)
|
|
42
45
|
input(type="hidden" name="returnTo" value=returnTo)
|
|
43
46
|
button.btn.btn-sm.btn-outline-primary(type="submit" disabled=!updates.latest[app])
|
package/views/resources/list.pug
CHANGED
|
@@ -42,7 +42,7 @@ block content
|
|
|
42
42
|
td
|
|
43
43
|
if r.group_ids.length
|
|
44
44
|
each gid in r.group_ids
|
|
45
|
-
span.badge.text-bg-info.me-1(title=gid)= groupsById[gid] ? groupsById[gid].name : `(unknown: ${gid})`
|
|
45
|
+
span.badge.text-bg-info.me-1(data-bs-toggle="tooltip" data-bs-title=gid)= groupsById[gid] ? groupsById[gid].name : `(unknown: ${gid})`
|
|
46
46
|
else
|
|
47
47
|
span.text-body-secondary —
|
|
48
48
|
td= r.busy_source ? `${r.busy_source}${r.busy_reason ? ' — ' + r.busy_reason : ''}` : '—'
|