@andrian.yablonskyy/thub-coordinator 1.0.20 → 1.0.22

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.20",
3
+ "version": "1.0.22",
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
+ }
@@ -21,13 +21,34 @@
21
21
  const jobId = pre.dataset.jobId,
22
22
  jobActive = pre.dataset.jobActive === 'true',
23
23
  filter = document.getElementById('stream-filter'),
24
- lines = [];
24
+ lines = [],
25
+ // Same dd/mm/yyyy HH:MM:SS, 24-hour format and profile time zone as
26
+ // every server-rendered date (thub-common formatDateTime).
27
+ timeFormat = new Intl.DateTimeFormat('en-GB', {
28
+ timeZone: pre.dataset.timeZone || undefined,
29
+ day: '2-digit',
30
+ month: '2-digit',
31
+ year: 'numeric',
32
+ hour: '2-digit',
33
+ minute: '2-digit',
34
+ second: '2-digit',
35
+ hourCycle: 'h23'
36
+ });
37
+
38
+ function fmtTime(iso){
39
+ const d = new Date(iso);
40
+ if (Number.isNaN(d.getTime())){
41
+ return iso;
42
+ }
43
+ const p = Object.fromEntries(timeFormat.formatToParts(d).map(({ type, value }) => [type, value]));
44
+ return `${p.day}/${p.month}/${p.year} ${p.hour}:${p.minute}:${p.second}`;
45
+ }
25
46
 
26
47
  function render(){
27
48
  const wanted = filter.value;
28
49
  pre.textContent = lines
29
50
  .filter((l) => !wanted || l.stream === wanted)
30
- .map((l) => `[${l.ts}] [${l.stream}] ${l.line}`)
51
+ .map((l) => `[${fmtTime(l.ts)}] [${l.stream}] ${l.line}`)
31
52
  .join('\n');
32
53
  pre.scrollTop = pre.scrollHeight;
33
54
  }
package/src/web/routes.js CHANGED
@@ -20,7 +20,7 @@ const fs = require('node:fs'),
20
20
  multer = require('multer'),
21
21
  { requireAdminSession, requireAdminRole } = require('../auth'),
22
22
  { attachJobStream } = require('../api/sse'),
23
- { RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES, isNewer, compareVersions } = require('@andrian.yablonskyy/thub-common'),
23
+ { RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES, isNewer, compareVersions, formatDateTime } = require('@andrian.yablonskyy/thub-common'),
24
24
  listPrefs = require('../services/list-prefs');
25
25
 
26
26
  // Resources page sort keys (list-prefs.js) -> comparators. Empty values
@@ -133,8 +133,10 @@ function createWebRouter({ services, config }){
133
133
  m = Math.floor((s % 3600) / 60);
134
134
  return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m ${s % 60}s` : `${s}s`;
135
135
  };
136
- res.locals.fmtDate = (iso, fallback = '—') =>
137
- iso ? new Date(iso).toLocaleString('en-US', { timeZone: tz }) : fallback;
136
+ // dd/mm/yyyy HH:MM:SS, 24-hour (thub-common formatDateTime) — the one
137
+ // format for every date/time label.
138
+ res.locals.fmtDate = (iso, fallback = '—') => formatDateTime(iso, { timeZone: tz, fallback });
139
+ res.locals.userTimeZone = tz;
138
140
 
139
141
  // Idle timeout (§10.1): re-applied on every request (not just login)
140
142
  // so a mid-session profile change takes effect immediately, and so
@@ -406,7 +408,7 @@ function createWebRouter({ services, config }){
406
408
  }
407
409
 
408
410
  parts.push(s.coordinatorUpdate
409
- ? `Coordinator v${s.coordinatorVersion} → v${s.coordinatorUpdate} available (Update app button).`
411
+ ? `Coordinator v${s.coordinatorVersion} → v${s.coordinatorUpdate} available.`
410
412
  : `Coordinator is up to date (v${s.coordinatorVersion}).`);
411
413
 
412
414
  const connected = services.registry.list().filter((r) => r.status !== RESOURCE_STATES.OUT_OF_SERVICE && r.last_heartbeat_at),
@@ -433,7 +435,7 @@ function createWebRouter({ services, config }){
433
435
  parts.push(`Some checks failed: ${s.error}`);
434
436
  }
435
437
  const behind = s.coordinatorUpdate || outdated.length || unknown.length;
436
- return { type: behind || s.error ? 'warning' : 'success', text: parts.join(' ') };
438
+ return { type: behind || s.error ? 'warning' : 'success', text: parts.join('\n') };
437
439
  }));
438
440
 
439
441
  router.post('/updates/coordinator', requireAdminRole, updateAction(async (req) => {
@@ -48,7 +48,7 @@ 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
@@ -59,7 +59,7 @@ block content
59
59
  onsubmit="return confirm(this.dataset.confirm)"
60
60
  )
61
61
  input(type="hidden" name="returnTo" value=returnTo)
62
- button.btn.btn-sm.btn-outline-danger(type="submit" aria-label="Cancel" title="Cancel this job")
62
+ button.btn.btn-sm.btn-outline-danger(type="submit" aria-label="Cancel" data-bs-toggle="tooltip" data-bs-title="Cancel this job")
63
63
  i.bi.bi-x-circle
64
64
 
65
65
  if canManage
@@ -7,7 +7,7 @@ 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
12
  form(method="post" action=`/jobs/${job.id}/cancel` onsubmit=`return confirm('Cancel job ${job.id}?')`)
13
13
  button.btn.btn-outline-danger.btn-sm(type="submit") Cancel
@@ -29,7 +29,7 @@ block content
29
29
  dd.col-sm-10= fmtDate(job.created_at)
30
30
  if job.duration_sec != null
31
31
  dt.col-sm-2 Duration
32
- dd.col-sm-10(title=`Finished ${fmtDate(job.finished_at)}`)= fmtDuration(job.duration_sec * 1000)
32
+ dd.col-sm-10(data-bs-toggle="tooltip" data-bs-title=`Finished ${fmtDate(job.finished_at)}`)= fmtDuration(job.duration_sec * 1000)
33
33
  if job.message
34
34
  dt.col-sm-2 Message
35
35
  dd.col-sm-10= job.message
@@ -10,5 +10,6 @@ mixin logViewer(jobId, jobActive)
10
10
  style="height: 420px; overflow-y: auto;"
11
11
  data-job-id=jobId
12
12
  data-job-active=jobActive ? 'true' : 'false'
13
+ data-time-zone=userTimeZone
13
14
  )
14
15
  script(src="/js/log-viewer.js")
@@ -2,7 +2,9 @@
2
2
  //- Overview and Resources pages. `returnTo` is the page the action forms
3
3
  //- redirect back to.
4
4
 
5
- mixin resourceActions(r, returnTo)
5
+ //- `opts.update: false` leaves out the Update button — the resource card
6
+ //- shows it on its Client version line instead.
7
+ mixin resourceActions(r, returnTo, opts = {})
6
8
  form.d-inline(method="post" action=`/resources/${r.id}/maintenance`)
7
9
  input(type="hidden" name="enabled" value=r.status === 'MAINTENANCE' ? '0' : '1')
8
10
  input(type="hidden" name="returnTo" value=returnTo)
@@ -31,7 +33,8 @@ mixin resourceActions(r, returnTo)
31
33
  data-bs-title="Invalidate the current resource token and issue a new one — the Client won't reconnect until it has it"
32
34
  )
33
35
  i.bi.bi-arrow-repeat
34
- +updateButton(`/resources/${r.id}/update`, r.update_to, returnTo)
36
+ if opts.update !== false
37
+ +updateButton(`/resources/${r.id}/update`, r.update_to, returnTo)
35
38
  +removeResourceButton(r, returnTo)
36
39
 
37
40
  //- Remove: a plain confirm when the resource is idle; with a running job,
@@ -168,7 +171,8 @@ mixin capabilitiesList(caps)
168
171
  //- Host uptime and the Client's current task/status with its duration,
169
172
  //- both from the last heartbeat. Measured up to now — or, for a Client
170
173
  //- that stopped heartbeating, up to its last heartbeat.
171
- mixin uptimeRows(r)
174
+ //- `returnTo`: where the Task / status line's Cancel button redirects.
175
+ mixin uptimeRows(r, returnTo)
172
176
  - const stale = r.status === 'OUT_OF_SERVICE' && r.last_heartbeat_at
173
177
  - const ref = stale ? new Date(r.last_heartbeat_at).getTime() : Date.now()
174
178
  - const activityLabel = { idle: 'Idle', job: 'Running job', locked: 'Locked locally', 'update-hold': 'Holding for self-update' }
@@ -193,33 +197,36 @@ mixin uptimeRows(r)
193
197
  span.text-body-secondary.small.ms-1 (at last heartbeat)
194
198
  else
195
199
  span.text-body-secondary —
200
+ if user.role === 'admin'
201
+ +cancelActivityButton(r, returnTo)
196
202
 
197
203
  //- Cancel the Client's current activity (its job, a local lock or a
198
- //- self-update hold); disabled while it's idle.
204
+ //- self-update hold) — icon only, on the card's Task / status line, and
205
+ //- only while there's something to cancel.
199
206
  mixin cancelActivityButton(r, returnTo)
200
207
  - const state = r.activity && r.activity.state
201
208
  - const what = r.activeJob ? `job ${r.activeJob.id}` : state === 'locked' ? 'the local lock' : state === 'update-hold' ? 'the self-update' : null
202
209
  if what
203
- form.d-inline.ms-1(
210
+ form.d-inline.ms-2(
204
211
  method="post"
205
212
  action=`/resources/${r.id}/cancel-activity`
206
- onsubmit=`return confirm('Cancel ${what} on ${r.name}?')`
213
+ data-confirm=`Cancel ${what} on ${r.name}?`
214
+ onsubmit="return confirm(this.dataset.confirm)"
207
215
  )
208
216
  input(type="hidden" name="returnTo" value=returnTo)
209
- button.btn.btn-sm.btn-outline-danger(type="submit" data-bs-toggle="tooltip" data-bs-title=`Cancel ${what}`)
210
- i.bi.bi-x-octagon.me-1
211
- | Cancel
212
- else
213
- span.d-inline-block.ms-1(tabindex="0" data-bs-toggle="tooltip" data-bs-title="Nothing to cancel — the Client is idle")
214
- button.btn.btn-sm.btn-outline-danger(type="button" disabled style="pointer-events: none;")
215
- i.bi.bi-x-octagon.me-1
216
- | Cancel
217
+ button.btn.btn-sm.btn-outline-danger(
218
+ type="submit"
219
+ aria-label=`Cancel ${what}`
220
+ data-bs-toggle="tooltip"
221
+ data-bs-title=`Cancel ${what}`
222
+ )
223
+ i.bi.bi-x-octagon
217
224
 
218
225
  mixin resourceCard(r, groupsById, returnTo)
219
226
  - const isLoopback = (a) => /^127\./.test(a) || a === '::1' || a === 'localhost'
220
227
  - const local = ((r.host_info && r.host_info.addresses) || []).filter((a) => !isLoopback(a.address))
221
228
  - const external = r.remote_addr && !isLoopback(r.remote_addr) ? r.remote_addr : null
222
- .modal.fade(id=`resource-card-${r.id}` tabindex="-1" aria-labelledby=`resource-card-${r.id}-title` aria-hidden="true")
229
+ .modal.fade.thub-resource-card(id=`resource-card-${r.id}` tabindex="-1" aria-labelledby=`resource-card-${r.id}-title` aria-hidden="true")
223
230
  .modal-dialog.modal-dialog-centered
224
231
  .modal-content
225
232
  .modal-header
@@ -233,9 +240,15 @@ mixin resourceCard(r, groupsById, returnTo)
233
240
  dd.col-sm-8.text-uppercase= r.type
234
241
  dt.col-sm-4 Status
235
242
  dd.col-sm-8: +resourceBadge(r.status, r.remove_requested_at)
236
- +uptimeRows(r)
243
+ +uptimeRows(r, returnTo)
237
244
  dt.col-sm-4 Client version
238
- dd.col-sm-8: +versionCell(r.client_version, updates.latest.client, r.update_to)
245
+ dd.col-sm-8
246
+ +versionCell(r.client_version, updates.latest.client, r.update_to)
247
+ //- Admins: Update only when a newer Client is published (or
248
+ //- Cancel, while one is requested). An unknown version (too
249
+ //- old to report it) can't be compared, so no button.
250
+ if user.role === 'admin' && (r.update_to || (r.client_version && isNewer(updates.latest.client, r.client_version)))
251
+ +updateButton(`/resources/${r.id}/update`, r.update_to, returnTo)
239
252
  dt.col-sm-4 IP addresses
240
253
  dd.col-sm-8
241
254
  if !local.length && !external
@@ -266,7 +279,7 @@ mixin resourceCard(r, groupsById, returnTo)
266
279
  dd.col-sm-8
267
280
  if r.group_ids.length
268
281
  each gid in r.group_ids
269
- span.badge.text-bg-info.me-1(title=gid)= groupsById[gid] ? groupsById[gid].name : `(unknown: ${gid})`
282
+ span.badge.text-bg-info.me-1(data-bs-toggle="tooltip" data-bs-title=gid)= groupsById[gid] ? groupsById[gid].name : `(unknown: ${gid})`
270
283
  else
271
284
  span.text-body-secondary —
272
285
  dt.col-sm-4 Busy source / reason
@@ -276,5 +289,4 @@ mixin resourceCard(r, groupsById, returnTo)
276
289
  if user.role === 'admin'
277
290
  dt.col-sm-4 Actions
278
291
  dd.col-sm-8.mb-0
279
- +resourceActions(r, returnTo)
280
- +cancelActivityButton(r, returnTo)
292
+ +resourceActions(r, returnTo, { update: false })
@@ -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
@@ -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 : ''}` : '—'