@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 +1 -1
- package/public/css/thub.css +25 -0
- package/public/js/log-viewer.js +23 -2
- package/src/web/routes.js +7 -5
- package/views/jobs/list.pug +2 -2
- package/views/jobs/show.pug +2 -2
- package/views/mixins/log-viewer.pug +1 -0
- package/views/mixins/resource.pug +32 -20
- package/views/mixins/status-badge.pug +1 -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.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": {
|
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
|
+
}
|
package/public/js/log-viewer.js
CHANGED
|
@@ -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
|
-
|
|
137
|
-
|
|
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
|
|
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) => {
|
package/views/jobs/list.pug
CHANGED
|
@@ -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
|
package/views/jobs/show.pug
CHANGED
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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-
|
|
210
|
+
form.d-inline.ms-2(
|
|
204
211
|
method="post"
|
|
205
212
|
action=`/resources/${r.id}/cancel-activity`
|
|
206
|
-
|
|
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(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
i.bi.bi-x-octagon
|
|
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
|
|
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
|
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 : ''}` : '—'
|