@andrian.yablonskyy/thub-coordinator 1.0.13 → 1.0.15

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 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`; refreshes the external address; response may carry `commands[]` (`cancel-job`, `self-update`). |
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.13",
3
+ "version": "1.0.15",
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": {
@@ -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;
@@ -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,
@@ -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(resourceId, { state, activeJobId, localLock, metrics, addresses, remoteAddr, clientVersion = null } = {}){
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
- // Older Clients don't send addresses — keep whatever registration stored.
302
- const cleanAddresses = sanitizeAddresses(addresses),
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)