@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.21",
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": {
@@ -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 (Update app button).`
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) => {
@@ -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(method="post" action=`/admin/agents/${a.id}/revoke` onsubmit="return confirm('Revoke this agent token?')")
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")
@@ -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(method="post" action=`/groups/${g.id}/delete` onsubmit="return confirm('Delete this group? Any resource listing it will just stop matching on it.')")
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}`)
@@ -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
- onsubmit="return confirm(this.dataset.confirm)"
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
@@ -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(method="post" action=`/jobs/${job.id}/cancel` onsubmit=`return confirm('Cancel job ${job.id}?')`)
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
- onsubmit=`return confirm('Update the Coordinator to v${updates.coordinatorUpdate}? It restarts, and the dashboard and API are briefly unavailable.')`
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(method="post" action=`/resources/${r.id}/rotate-token` onsubmit="return confirm('Rotate token? The Client will need the new one.')")
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
- onsubmit="return confirm(this.dataset.confirm)"
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
- onsubmit="return confirm(this.dataset.confirm)"
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
@@ -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
- onsubmit=`return confirm('Request a self-update of every ${label} to the latest version?')`
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])
@@ -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 : ''}` : '—'