@andrian.yablonskyy/thub-coordinator 1.0.13 → 1.0.14
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/README.md +2 -2
- package/package.json +1 -1
- package/src/api/resource.js +4 -2
- package/src/db/migrations/012_add_uptime_activity.sql +7 -0
- package/src/services/jobs.js +11 -0
- package/src/services/registry.js +36 -6
- package/src/web/routes.js +54 -2
- package/views/mixins/resource.pug +52 -0
package/README.md
CHANGED
|
@@ -132,7 +132,7 @@ All endpoints are under `/api/v1`, JSON, and require `Authorization: Bearer <tok
|
|
|
132
132
|
| Method | Path | Description |
|
|
133
133
|
|---|---|---|
|
|
134
134
|
| `POST` | `/resources/register` | Self-register (every start/restart) using the shared `clientJoinKey`; upserts by `clientId`. `hostInfo.addresses` (`[{iface, address, family}]`) lists the host's non-loopback interface addresses; the request's source address is stored as the external one. |
|
|
135
|
-
| `POST` | `/resources/:id/heartbeat` | Heartbeat and self-reported state, plus the current `addresses
|
|
135
|
+
| `POST` | `/resources/:id/heartbeat` | Heartbeat and self-reported state, plus the current `addresses`, `hostUptimeSec` and `activity` (`{state, jobId, durationSec}`); refreshes the external address; response may carry `commands[]` (`cancel-job`, `self-update`, `unlock`, `cancel-update`). |
|
|
136
136
|
| `POST` | `/resources/:id/status` | Explicit status change, e.g. a local lock. |
|
|
137
137
|
| `GET` | `/resources/:id/jobs/next?wait=30` | Long-poll for an assigned job; `204` when nothing arrived within `wait` seconds. |
|
|
138
138
|
| `POST` | `/jobs/:id/accept` | Acknowledge assignment. |
|
|
@@ -157,7 +157,7 @@ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla J
|
|
|
157
157
|
| `/admin/agents` | Register/revoke CI and developer agents; token shown once. Shows each agent's last reported version. |
|
|
158
158
|
| `/profile` | Every logged-in user's own account settings: display name, avatar, timezone (renders every dashboard timestamp), theme, idle session timeout, password change. |
|
|
159
159
|
|
|
160
|
-
**Resource card.** Clicking a resource on `/` or `/resources` opens a card with its name, type, status, Client version, IP addresses, capabilities (reported at registration: HW udev devices with a **missing** flag for any whose device node doesn't exist, relay channels and power control; SW image, source and limits), labels, groups, busy source/reason, last heartbeat and (admins only) the maintenance/rotate-token actions. IP addresses are the Client host's interface addresses except loopback (reported at registration and on every heartbeat) plus the external address — the one the Coordinator last saw the Client connect from, taken from `X-Forwarded-For` when the request came through a proxy `trustProxy` trusts.
|
|
160
|
+
**Resource card.** Clicking a resource on `/` or `/resources` opens a card with its name, type, status, host uptime and current task/status with its duration (both from the Client's heartbeats, anchored to the Coordinator's clock; as of the last heartbeat for an OUT_OF_SERVICE resource), Client version, IP addresses, capabilities (reported at registration: HW udev devices with a **missing** flag for any whose device node doesn't exist, relay channels and power control; SW image, source and limits), labels, groups, busy source/reason, last heartbeat and (admins only) the maintenance/rotate-token actions. IP addresses are the Client host's interface addresses except loopback (reported at registration and on every heartbeat) plus the external address — the one the Coordinator last saw the Client connect from, taken from `X-Forwarded-For` when the request came through a proxy `trustProxy` trusts. Admins get a **Cancel** button on it, enabled only while the Client is doing something: it cancels its current job, releases a manual local lock, or aborts a self-update hold on its host (the last two delivered as `unlock` / `cancel-update` heartbeat commands).
|
|
161
161
|
|
|
162
162
|
The Coordinator's own version (`ver. X.Y.Z`) is shown under the TestHub logo, top left of every page.
|
|
163
163
|
|
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.14",
|
|
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/src/api/resource.js
CHANGED
|
@@ -84,7 +84,7 @@ function createResourceRouter({ services, config }){
|
|
|
84
84
|
|
|
85
85
|
router.post('/resources/:id/heartbeat', auth, requireOwnResource, (req, res, next) => {
|
|
86
86
|
try {
|
|
87
|
-
const { state, activeJobId, localLock, metrics, addresses } = req.body;
|
|
87
|
+
const { state, activeJobId, localLock, metrics, addresses, hostUptimeSec, activity } = req.body;
|
|
88
88
|
services.registry.heartbeat(req.params.id, {
|
|
89
89
|
state,
|
|
90
90
|
activeJobId,
|
|
@@ -92,7 +92,9 @@ function createResourceRouter({ services, config }){
|
|
|
92
92
|
metrics,
|
|
93
93
|
addresses,
|
|
94
94
|
remoteAddr: req.ip,
|
|
95
|
-
clientVersion: appVersion(req, 'client')
|
|
95
|
+
clientVersion: appVersion(req, 'client'),
|
|
96
|
+
hostUptimeSec,
|
|
97
|
+
activity
|
|
96
98
|
});
|
|
97
99
|
const commands = services.commands.drain(req.params.id),
|
|
98
100
|
// Repeated on every heartbeat until the Client reports the new
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- Reported on every heartbeat (README §10), converted to the
|
|
2
|
+
-- Coordinator's clock: when the Client's host booted (host uptime), and
|
|
3
|
+
-- what the Client is doing since when (JSON {state, jobId, since}; state
|
|
4
|
+
-- idle | job | locked | update-hold) for the "Task / status" duration.
|
|
5
|
+
-- NULL for Clients too old to report them.
|
|
6
|
+
ALTER TABLE resources ADD COLUMN host_booted_at TEXT;
|
|
7
|
+
ALTER TABLE resources ADD COLUMN activity TEXT;
|
package/src/services/jobs.js
CHANGED
|
@@ -202,6 +202,16 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
202
202
|
return setState(id, JOB_STATES.CANCELED);
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// The job a resource is working on (ASSIGNED through RUNNING), if any —
|
|
206
|
+
// for the resource card's Cancel button.
|
|
207
|
+
function activeForResource(resourceId){
|
|
208
|
+
const placeholders = [...ACTIVE_JOB_STATES].map(() => '?').join(','),
|
|
209
|
+
row = db.prepare(
|
|
210
|
+
`SELECT id FROM jobs WHERE resource_id = ? AND state IN (${placeholders}) ORDER BY created_at DESC LIMIT 1`
|
|
211
|
+
).get(resourceId, ...ACTIVE_JOB_STATES);
|
|
212
|
+
return row ? get(row.id) : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
205
215
|
// Resource: POST /jobs/:id/result — final verdict from the test runner.
|
|
206
216
|
function applyResult(id, resourceId, { state, exitCode, summary }){
|
|
207
217
|
const job = get(id);
|
|
@@ -341,6 +351,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
341
351
|
create,
|
|
342
352
|
setState,
|
|
343
353
|
cancel,
|
|
354
|
+
activeForResource,
|
|
344
355
|
applyResult,
|
|
345
356
|
requeueUnacked,
|
|
346
357
|
markLost,
|
package/src/services/registry.js
CHANGED
|
@@ -26,7 +26,8 @@ function rowToResource(row){
|
|
|
26
26
|
labels: JSON.parse(row.labels || '[]'),
|
|
27
27
|
group_ids: JSON.parse(row.group_ids || '[]'),
|
|
28
28
|
host_info: row.host_info ? JSON.parse(row.host_info) : null,
|
|
29
|
-
capabilities: row.capabilities ? JSON.parse(row.capabilities) : null
|
|
29
|
+
capabilities: row.capabilities ? JSON.parse(row.capabilities) : null,
|
|
30
|
+
activity: row.activity ? JSON.parse(row.activity) : null
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -98,6 +99,26 @@ function sanitizeCapabilities(caps){
|
|
|
98
99
|
return null;
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
// Heartbeat durations (README §10) arrive as seconds relative to "now" on
|
|
103
|
+
// the Client and are anchored to the Coordinator's clock here. Anything
|
|
104
|
+
// negative, non-numeric or over ~10 years is ignored.
|
|
105
|
+
const ACTIVITY_STATES = new Set(['idle', 'job', 'locked', 'update-hold']),
|
|
106
|
+
MAX_DURATION_SEC = 10 * 365 * 86400;
|
|
107
|
+
|
|
108
|
+
function secondsAgo(now, sec){
|
|
109
|
+
return Number.isFinite(sec) && sec >= 0 && sec < MAX_DURATION_SEC ? new Date(now - sec * 1000).toISOString() : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sanitizeActivity(now, activity){
|
|
113
|
+
if (!activity || !ACTIVITY_STATES.has(activity.state)){
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const since = secondsAgo(now, activity.durationSec);
|
|
117
|
+
return since
|
|
118
|
+
? { state: activity.state, jobId: activity.state === 'job' ? str(activity.jobId, 64) : null, since }
|
|
119
|
+
: null;
|
|
120
|
+
}
|
|
121
|
+
|
|
101
122
|
function createRegistryService(db, { bus, events }){
|
|
102
123
|
function get(id){
|
|
103
124
|
return rowToResource(db.prepare('SELECT * FROM resources WHERE id = ?').get(id));
|
|
@@ -266,7 +287,10 @@ function createRegistryService(db, { bus, events }){
|
|
|
266
287
|
}
|
|
267
288
|
|
|
268
289
|
// Reconcile status from a heartbeat's self-reported state (§5.1).
|
|
269
|
-
function heartbeat(
|
|
290
|
+
function heartbeat(
|
|
291
|
+
resourceId,
|
|
292
|
+
{ state, activeJobId, localLock, metrics, addresses, remoteAddr, clientVersion = null, hostUptimeSec, activity } = {}
|
|
293
|
+
){
|
|
270
294
|
const resource = get(resourceId);
|
|
271
295
|
if (!resource){
|
|
272
296
|
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
@@ -298,23 +322,29 @@ function createRegistryService(db, { bus, events }){
|
|
|
298
322
|
busyReason = null;
|
|
299
323
|
}
|
|
300
324
|
|
|
301
|
-
|
|
302
|
-
|
|
325
|
+
const now = Date.now(),
|
|
326
|
+
cleanActivity = sanitizeActivity(now, activity),
|
|
327
|
+
|
|
328
|
+
// Older Clients don't send addresses — keep whatever registration stored.
|
|
329
|
+
cleanAddresses = sanitizeAddresses(addresses),
|
|
303
330
|
hostInfo = cleanAddresses ? { ...(resource.host_info || {}), addresses: cleanAddresses } : resource.host_info;
|
|
304
331
|
|
|
305
332
|
db.prepare(
|
|
306
333
|
`UPDATE resources
|
|
307
334
|
SET last_heartbeat_at = ?, status = ?, busy_source = ?, busy_reason = ?, host_info = ?,
|
|
308
|
-
remote_addr = COALESCE(?, remote_addr), client_version = COALESCE(?, client_version)
|
|
335
|
+
remote_addr = COALESCE(?, remote_addr), client_version = COALESCE(?, client_version),
|
|
336
|
+
host_booted_at = COALESCE(?, host_booted_at), activity = COALESCE(?, activity)
|
|
309
337
|
WHERE id = ?`
|
|
310
338
|
).run(
|
|
311
|
-
new Date().toISOString(),
|
|
339
|
+
new Date(now).toISOString(),
|
|
312
340
|
nextStatus,
|
|
313
341
|
busySource,
|
|
314
342
|
busyReason,
|
|
315
343
|
hostInfo ? JSON.stringify(hostInfo) : null,
|
|
316
344
|
normalizeRemoteAddr(remoteAddr),
|
|
317
345
|
clientVersion,
|
|
346
|
+
secondsAgo(now, hostUptimeSec),
|
|
347
|
+
cleanActivity ? JSON.stringify(cleanActivity) : null,
|
|
318
348
|
resourceId
|
|
319
349
|
);
|
|
320
350
|
completeUpdateIfDone(resourceId);
|
package/src/web/routes.js
CHANGED
|
@@ -53,6 +53,11 @@ function createWebRouter({ services, config }){
|
|
|
53
53
|
return typeof target === 'string' && /^\/(?![\/\\])/.test(target) ? target : fallback;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// The job each resource is on (if any), for the resource card's Cancel.
|
|
57
|
+
function withActiveJobs(resources){
|
|
58
|
+
return resources.map((r) => ({ ...r, activeJob: services.jobs.activeForResource(r.id) }));
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
router.use((req, res, next) => {
|
|
57
62
|
const user = req.session?.user || null;
|
|
58
63
|
res.locals.user = user;
|
|
@@ -69,6 +74,14 @@ function createWebRouter({ services, config }){
|
|
|
69
74
|
// without this every user would see the *server's* local time — not
|
|
70
75
|
// their own (§10.1). Falls back to UTC for the (rare) pre-login page.
|
|
71
76
|
const tz = user?.timezone || 'UTC';
|
|
77
|
+
// "3d 4h", "5h 12m", "12m 5s", "8s" — resource card uptime/durations.
|
|
78
|
+
res.locals.fmtDuration = (ms) => {
|
|
79
|
+
const s = Math.max(0, Math.floor(ms / 1000)),
|
|
80
|
+
d = Math.floor(s / 86400),
|
|
81
|
+
h = Math.floor((s % 86400) / 3600),
|
|
82
|
+
m = Math.floor((s % 3600) / 60);
|
|
83
|
+
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m ${s % 60}s` : `${s}s`;
|
|
84
|
+
};
|
|
72
85
|
res.locals.fmtDate = (iso, fallback = '—') =>
|
|
73
86
|
iso ? new Date(iso).toLocaleString('en-US', { timeZone: tz }) : fallback;
|
|
74
87
|
|
|
@@ -204,7 +217,7 @@ function createWebRouter({ services, config }){
|
|
|
204
217
|
});
|
|
205
218
|
|
|
206
219
|
router.get('/', (req, res) => {
|
|
207
|
-
const resources = services.registry.list(),
|
|
220
|
+
const resources = withActiveJobs(services.registry.list()),
|
|
208
221
|
queueLength = services.jobs.list({ state: JOB_STATES.QUEUED, limit: 1000 }).length,
|
|
209
222
|
dayAgo = new Date(Date.now() - 86400 * 1000).toISOString(),
|
|
210
223
|
jobsLast24h = services.jobs
|
|
@@ -220,7 +233,7 @@ function createWebRouter({ services, config }){
|
|
|
220
233
|
res.render('resources/list', {
|
|
221
234
|
title: 'Resources',
|
|
222
235
|
active: 'resources',
|
|
223
|
-
resources: services.registry.list(),
|
|
236
|
+
resources: withActiveJobs(services.registry.list()),
|
|
224
237
|
groupsById
|
|
225
238
|
});
|
|
226
239
|
});
|
|
@@ -230,6 +243,45 @@ function createWebRouter({ services, config }){
|
|
|
230
243
|
res.redirect(returnTo(req, '/resources'));
|
|
231
244
|
});
|
|
232
245
|
|
|
246
|
+
// Cancel whatever the Client is doing (resource card): its job, a manual
|
|
247
|
+
// local lock, or a self-update hold (README §10). The Client applies the
|
|
248
|
+
// lock/update ones via heartbeat commands; a job is canceled right here.
|
|
249
|
+
router.post('/resources/:id/cancel-activity', requireAdminRole, (req, res) => {
|
|
250
|
+
const r = services.registry.get(req.params.id),
|
|
251
|
+
job = r && services.jobs.activeForResource(r.id),
|
|
252
|
+
activity = r?.activity?.state;
|
|
253
|
+
try {
|
|
254
|
+
if (!r){
|
|
255
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
256
|
+
}
|
|
257
|
+
if (job){
|
|
258
|
+
services.jobs.cancel(job.id, { isAdmin: true });
|
|
259
|
+
flash(req, 'warning', `Canceled job ${job.id} on ${r.name}.`);
|
|
260
|
+
}
|
|
261
|
+
else if (activity === 'locked'){
|
|
262
|
+
services.commands.push(r.id, { command: 'unlock' });
|
|
263
|
+
flash(req, 'warning', `Releasing the local lock on ${r.name} (on its next heartbeat).`);
|
|
264
|
+
}
|
|
265
|
+
else if (activity === 'update-hold'){
|
|
266
|
+
services.commands.push(r.id, { command: 'cancel-update' });
|
|
267
|
+
if (r.update_to){
|
|
268
|
+
services.registry.setUpdateTo(r.id, null);
|
|
269
|
+
}
|
|
270
|
+
flash(req, 'warning', `Canceling the self-update on ${r.name}'s host (on its next heartbeat).`);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
flash(req, 'info', `${r.name} isn't doing anything to cancel.`);
|
|
274
|
+
}
|
|
275
|
+
if (!job && ['locked', 'update-hold'].includes(activity)){
|
|
276
|
+
services.events.record('resource', r.id, 'resource.activity_canceled', { activity, by: req.session.user.username });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (err){
|
|
280
|
+
flash(req, 'danger', err.message);
|
|
281
|
+
}
|
|
282
|
+
res.redirect(returnTo(req, '/resources'));
|
|
283
|
+
});
|
|
284
|
+
|
|
233
285
|
router.post('/resources/:id/rotate-token', requireAdminRole, (req, res) => {
|
|
234
286
|
const { generateToken, hashToken } = require('../services/tokens');
|
|
235
287
|
const token = generateToken('res');
|
|
@@ -89,6 +89,56 @@ mixin capabilitiesList(caps)
|
|
|
89
89
|
if caps.hw.power.method === 'uhubctl' && caps.hw.power.hub
|
|
90
90
|
span.text-body-secondary.small.ms-2= `hub ${caps.hw.power.hub} port ${caps.hw.power.port}`
|
|
91
91
|
|
|
92
|
+
//- Host uptime and the Client's current task/status with its duration,
|
|
93
|
+
//- both from the last heartbeat. Measured up to now — or, for a Client
|
|
94
|
+
//- that stopped heartbeating, up to its last heartbeat.
|
|
95
|
+
mixin uptimeRows(r)
|
|
96
|
+
- const stale = r.status === 'OUT_OF_SERVICE' && r.last_heartbeat_at
|
|
97
|
+
- const ref = stale ? new Date(r.last_heartbeat_at).getTime() : Date.now()
|
|
98
|
+
- const activityLabel = { idle: 'Idle', job: 'Running job', locked: 'Locked locally', 'update-hold': 'Holding for self-update' }
|
|
99
|
+
dt.col-sm-4 Host uptime
|
|
100
|
+
dd.col-sm-8
|
|
101
|
+
if r.host_booted_at
|
|
102
|
+
= fmtDuration(ref - new Date(r.host_booted_at).getTime())
|
|
103
|
+
span.text-body-secondary.small.ms-2= `booted ${fmtDate(r.host_booted_at)}`
|
|
104
|
+
if stale
|
|
105
|
+
span.text-body-secondary.small.ms-1 (at last heartbeat)
|
|
106
|
+
else
|
|
107
|
+
span.text-body-secondary —
|
|
108
|
+
dt.col-sm-4 Task / status
|
|
109
|
+
dd.col-sm-8
|
|
110
|
+
if r.activity
|
|
111
|
+
= activityLabel[r.activity.state] || r.activity.state
|
|
112
|
+
if r.activity.jobId
|
|
113
|
+
a.ms-1(href=`/jobs/${r.activity.jobId}`)= r.activity.jobId
|
|
114
|
+
= ` for ${fmtDuration(ref - new Date(r.activity.since).getTime())}`
|
|
115
|
+
span.text-body-secondary.small.ms-2= `since ${fmtDate(r.activity.since)}`
|
|
116
|
+
if stale
|
|
117
|
+
span.text-body-secondary.small.ms-1 (at last heartbeat)
|
|
118
|
+
else
|
|
119
|
+
span.text-body-secondary —
|
|
120
|
+
|
|
121
|
+
//- Cancel the Client's current activity (its job, a local lock or a
|
|
122
|
+
//- self-update hold); disabled while it's idle.
|
|
123
|
+
mixin cancelActivityButton(r, returnTo)
|
|
124
|
+
- const state = r.activity && r.activity.state
|
|
125
|
+
- const what = r.activeJob ? `job ${r.activeJob.id}` : state === 'locked' ? 'the local lock' : state === 'update-hold' ? 'the self-update' : null
|
|
126
|
+
if what
|
|
127
|
+
form.d-inline.ms-1(
|
|
128
|
+
method="post"
|
|
129
|
+
action=`/resources/${r.id}/cancel-activity`
|
|
130
|
+
onsubmit=`return confirm('Cancel ${what} on ${r.name}?')`
|
|
131
|
+
)
|
|
132
|
+
input(type="hidden" name="returnTo" value=returnTo)
|
|
133
|
+
button.btn.btn-sm.btn-outline-danger(type="submit" data-bs-toggle="tooltip" data-bs-title=`Cancel ${what}`)
|
|
134
|
+
i.bi.bi-x-octagon.me-1
|
|
135
|
+
| Cancel
|
|
136
|
+
else
|
|
137
|
+
span.d-inline-block.ms-1(tabindex="0" data-bs-toggle="tooltip" data-bs-title="Nothing to cancel — the Client is idle")
|
|
138
|
+
button.btn.btn-sm.btn-outline-danger(type="button" disabled style="pointer-events: none;")
|
|
139
|
+
i.bi.bi-x-octagon.me-1
|
|
140
|
+
| Cancel
|
|
141
|
+
|
|
92
142
|
mixin resourceCard(r, groupsById, returnTo)
|
|
93
143
|
- const isLoopback = (a) => /^127\./.test(a) || a === '::1' || a === 'localhost'
|
|
94
144
|
- const local = ((r.host_info && r.host_info.addresses) || []).filter((a) => !isLoopback(a.address))
|
|
@@ -107,6 +157,7 @@ mixin resourceCard(r, groupsById, returnTo)
|
|
|
107
157
|
dd.col-sm-8.text-uppercase= r.type
|
|
108
158
|
dt.col-sm-4 Status
|
|
109
159
|
dd.col-sm-8: +resourceBadge(r.status)
|
|
160
|
+
+uptimeRows(r)
|
|
110
161
|
dt.col-sm-4 Client version
|
|
111
162
|
dd.col-sm-8: +versionCell(r.client_version, updates.latest.client, r.update_to)
|
|
112
163
|
dt.col-sm-4 IP addresses
|
|
@@ -150,3 +201,4 @@ mixin resourceCard(r, groupsById, returnTo)
|
|
|
150
201
|
dt.col-sm-4 Actions
|
|
151
202
|
dd.col-sm-8.mb-0
|
|
152
203
|
+resourceActions(r, returnTo)
|
|
204
|
+
+cancelActivityButton(r, returnTo)
|