@andrian.yablonskyy/thub-coordinator 1.0.26 → 1.0.27

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.26",
3
+ "version": "1.0.27",
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": {
@@ -1,6 +1,6 @@
1
1
  #log-viewer {
2
2
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
3
- font-size: 0.8125rem;
3
+ font-size: 0.75rem;
4
4
  white-space: pre-wrap;
5
5
  word-break: break-all;
6
6
  }
@@ -39,19 +39,12 @@
39
39
  text-align: left;
40
40
  }
41
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
42
  /* A status badge inside a page heading (e.g. the job page's state): badges
50
43
  are 0.75em of their parent, which in an h4 makes them oversized — keep
51
44
  it close to the badges in tables, centered on the heading text. */
52
45
  .thub-heading-badge {
53
- font-size: 0.55em;
54
- vertical-align: middle;
46
+ font-size: 0.6rem;
47
+ vertical-align: super;
55
48
  }
56
49
 
57
50
  /* Toast text (layout.pug): "\n" in a flash message is a line break, and a
@@ -61,3 +54,7 @@
61
54
  overflow-wrap: anywhere;
62
55
  min-width: 0;
63
56
  }
57
+
58
+ .resource-capabilities {
59
+ font-size: 0.75rem;
60
+ }
package/src/api/admin.js CHANGED
@@ -51,6 +51,17 @@ function createAdminRouter({ services }){
51
51
  res.status(204).end();
52
52
  });
53
53
 
54
+ // Scheduled host reboot: { cron } (5-field, host local time; "" clears).
55
+ router.post('/resources/:id/reboot-schedule', (req, res, next) => {
56
+ try {
57
+ const r = services.registry.setRebootSchedule(req.params.id, req.body?.cron ?? '');
58
+ res.json({ id: r.id, rebootSchedule: r.reboot_schedule, applied: r.reboot_schedule_applied });
59
+ }
60
+ catch (err){
61
+ next(err);
62
+ }
63
+ });
64
+
54
65
  router.post('/resources/:id/maintenance', (req, res, next) => {
55
66
  try {
56
67
  const resource = services.registry.setMaintenance(req.params.id, !!req.body.enabled);
@@ -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, hostUptimeSec, activity } = req.body;
87
+ const { state, activeJobId, localLock, metrics, addresses, hostUptimeSec, activity, rebootSchedule } = req.body;
88
88
  services.registry.heartbeat(req.params.id, {
89
89
  state,
90
90
  activeJobId,
@@ -94,7 +94,8 @@ function createResourceRouter({ services, config }){
94
94
  remoteAddr: req.ip,
95
95
  clientVersion: appVersion(req, 'client'),
96
96
  hostUptimeSec,
97
- activity
97
+ activity,
98
+ rebootSchedule
98
99
  });
99
100
  const commands = services.commands.drain(req.params.id),
100
101
  // Repeated on every heartbeat until the Client reports the new
@@ -103,6 +104,11 @@ function createResourceRouter({ services, config }){
103
104
  if (updateTo){
104
105
  commands.push({ command: 'self-update', version: updateTo });
105
106
  }
107
+ // Also repeated until the Client reports applying it (README §10).
108
+ const reboot = services.registry.pendingRebootSchedule(req.params.id);
109
+ if (reboot){
110
+ commands.push({ command: 'set-reboot-schedule', cron: reboot.cron });
111
+ }
106
112
  // Removal pending (jobs.removeResource): once the Client reports its
107
113
  // job stopped, finish it. This response still goes out; the next
108
114
  // heartbeat gets 401, the token being gone with the resource.
@@ -0,0 +1,7 @@
1
+ -- Scheduled host reboot per Client (README §10): a 5-field cron expression
2
+ -- in the Client host's local time, set on the resource card. `reboot_schedule`
3
+ -- is what an admin saved; `reboot_schedule_applied` what the Client last
4
+ -- reported applying — while they differ, every heartbeat reply carries a
5
+ -- set-reboot-schedule command (api/resource.js). NULL = no scheduled reboot.
6
+ ALTER TABLE resources ADD COLUMN reboot_schedule TEXT;
7
+ ALTER TABLE resources ADD COLUMN reboot_schedule_applied TEXT;
@@ -0,0 +1,286 @@
1
+ /**
2
+ * @file packages/coordinator/src/dev/virtual.js
3
+ * @description DEV mode only (DEV_MODE=1 with THUB_BOOTSTRAP_ADMIN_PASSWORD): a virtual agent, virtual Clients that
4
+ * heartbeat and run assigned jobs in-process, and a seeded job history — for working on the dashboard
5
+ * without real hardware (README §13.1)
6
+ *
7
+ * @author Andrian Yablonskyy
8
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
9
+ *
10
+ * This file is part of TestHub and is proprietary and confidential.
11
+ * Unauthorized copying, modification, distribution, or use of this file,
12
+ * via any medium, is strictly prohibited without prior written permission
13
+ * from AdSystem.PRO.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const { JOB_STATES, ACTIVE_JOB_STATES } = require('@andrian.yablonskyy/thub-common'),
19
+ { hashToken } = require('../services/tokens'),
20
+ { version } = require('../../package.json');
21
+
22
+ // Fixed, so `thub --token <it>` works against a dev Coordinator without
23
+ // copying a fresh token each time. Only ever created in DEV mode.
24
+ const DEV_AGENT_NAME = 'virtual-agent',
25
+ DEV_AGENT_TOKEN = 'agt_dev-virtual-agent-token',
26
+ VIRTUAL_CLIENTS = [
27
+ {
28
+ clientId: 'virtual-client-sw-01',
29
+ name: 'virtual-sw-01',
30
+ type: 'sw',
31
+ labels: ['virtual', 'emulator'],
32
+ capabilities: {
33
+ sw: { image: 'dut-emulator:dev', registry: null, allowDockerHub: true, allowJobImages: true, cpus: 2, memory: '2g' },
34
+ allowJobCommands: true
35
+ },
36
+ address: '10.0.0.41'
37
+ },
38
+ {
39
+ clientId: 'virtual-client-hw-01',
40
+ name: 'virtual-hw-01',
41
+ type: 'hw',
42
+ labels: ['virtual', 'board:nucleo-f401re', 'uart', 'stlink'],
43
+ capabilities: {
44
+ hw: {
45
+ stlinks: [{ path: '/dev/thub/dut1-stlink', index: 1, serial: '066DFF485457725187092834', present: true }],
46
+ uarts: [{ path: '/dev/thub/dut1-uart', index: 1, baudRate: 115200, present: true }],
47
+ usbs: [{ path: '/dev/thub/dut1-usb', index: 1, present: false }],
48
+ relays: [{ channel: 0, baseUrl: 'http://localhost:3000' }],
49
+ power: { method: 'relay' }
50
+ },
51
+ allowJobCommands: false
52
+ },
53
+ address: '10.0.0.42'
54
+ }
55
+ ],
56
+ // Seeded history: [daysAgo, source, target type, final state, duration s, user].
57
+ HISTORY = [
58
+ [6.8, 'ci', 'hw', 'PASSED', 412, null],
59
+ [6.1, 'cli', 'sw', 'FAILED', 95, 'alice'],
60
+ [5.4, 'ci', 'sw', 'PASSED', 188, null],
61
+ [4.9, 'ci', 'hw', 'ERROR', 31, null],
62
+ [4.2, 'cli', 'hw', 'PASSED', 640, 'bob'],
63
+ [3.6, 'ci', 'sw', 'TIMEOUT', 1800, null],
64
+ [2.7, 'cli', 'sw', 'CANCELED', 44, 'alice'],
65
+ [2.1, 'ci', 'hw', 'PASSED', 377, null],
66
+ [1.3, 'ci', 'sw', 'FAILED', 152, null],
67
+ [0.8, 'cli', 'hw', 'LOST', 260, 'bob'],
68
+ [0.4, 'ci', 'sw', 'PASSED', 201, null],
69
+ [0.1, 'cli', 'sw', 'PASSED', 73, 'alice']
70
+ ],
71
+ LOG_SCRIPT = [
72
+ ['runner', 'downloading firmware (virtual)'],
73
+ ['runner', 'downloading tests (virtual)'],
74
+ ['flash', 'st-flash write app.bin 0x08000000 ... done (virtual)'],
75
+ ['uart', '[BOOT] app v1.4.0-dev'],
76
+ ['uart', '[INIT] peripherals ok'],
77
+ ['runner', 'running: run-tests.sh --suite default'],
78
+ ['runner', 'test_boot ............ ok'],
79
+ ['runner', 'test_uart_echo ....... ok'],
80
+ ['runner', 'test_flash_crc ....... ok'],
81
+ ['runner', 'test_power_cycle ..... ok']
82
+ ],
83
+
84
+ spec = (type, user, extra = {}) => ({
85
+ target: { type, labels: ['virtual'] },
86
+ firmware: type === 'sw' ? { image: 'alpine:3.20' } : { url: 'https://artifactory.example.com/fw-local/app/1.4.0-dev/app.bin' },
87
+ tests: { url: 'https://artifactory.example.com/fw-local/app/1.4.0-dev/tests.tar.gz', suite: 'default' },
88
+ ...(user ? { user } : {}),
89
+ meta: { repo: 'yourorg/firmware', branch: 'main', virtual: true },
90
+ ...extra
91
+ });
92
+
93
+ function isDevMode(env = process.env){
94
+ return env.DEV_MODE === '1' && Boolean(env.THUB_BOOTSTRAP_ADMIN_PASSWORD);
95
+ }
96
+
97
+ // The virtual agent, with its fixed token (re-applied if revoked or changed).
98
+ function ensureAgent(services){
99
+ const { db } = services,
100
+ existing = db.prepare('SELECT * FROM agents WHERE name = ?').get(DEV_AGENT_NAME);
101
+ if (existing){
102
+ db.prepare('UPDATE agents SET token_hash = ?, revoked_at = NULL WHERE id = ?').run(hashToken(DEV_AGENT_TOKEN), existing.id);
103
+ return existing.id;
104
+ }
105
+ const id = 'agt_dev-virtual-agent';
106
+ db.prepare('INSERT INTO agents (id, name, kind, token_hash, created_at) VALUES (?, ?, ?, ?, ?)').run(
107
+ id, DEV_AGENT_NAME, 'cli', hashToken(DEV_AGENT_TOKEN), new Date().toISOString()
108
+ );
109
+ services.agents.touchLastUsed(id, version);
110
+ return id;
111
+ }
112
+
113
+ // A week of finished jobs in every final state, on the virtual Clients —
114
+ // only into an empty jobs table, so restarts don't pile them up.
115
+ function seedHistory(services, agentId, resourceIds){
116
+ const { db, jobs, logs } = services;
117
+ if (db.prepare('SELECT COUNT(*) AS n FROM jobs').get().n > 0){
118
+ return 0;
119
+ }
120
+ for (const [daysAgo, source, type, state, durationSec, user]of HISTORY){
121
+ const job = jobs.create({ agentId, source, spec: spec(type, user) }),
122
+ created = Date.now() - daysAgo * 86400000,
123
+ started = created + 20000,
124
+ finished = started + durationSec * 1000,
125
+ ran = state !== 'CANCELED' || durationSec > 0,
126
+ failed = state === 'FAILED' ? 2 : 0;
127
+ db.prepare(
128
+ `UPDATE jobs SET state = ?, resource_id = ?, created_at = ?, assigned_at = ?, started_at = ?, finished_at = ?,
129
+ duration_sec = ?, exit_code = ?, summary = ?, message = ? WHERE id = ?`
130
+ ).run(
131
+ state,
132
+ resourceIds[type],
133
+ new Date(created).toISOString(),
134
+ new Date(created + 5000).toISOString(),
135
+ ran ? new Date(started).toISOString() : null,
136
+ new Date(finished).toISOString(),
137
+ ran ? durationSec : null,
138
+ { PASSED: 0, FAILED: 1 }[state] ?? null,
139
+ JSON.stringify(['PASSED', 'FAILED'].includes(state)
140
+ ? { total: 12, passed: 12 - failed, failed, skipped: 0 }
141
+ : { error: `virtual ${state.toLowerCase()}` }),
142
+ { TIMEOUT: 'Job exceeded its timeout', LOST: 'Resource stopped heartbeating', ERROR: 'Flashing failed (virtual)' }[state] || null,
143
+ job.id
144
+ );
145
+ logs.appendBatch(job.id, LOG_SCRIPT.slice(0, state === 'PASSED' ? LOG_SCRIPT.length : 6).map(([stream, line], i) => ({
146
+ ts: new Date(started + i * 1000).toISOString(), stream, line
147
+ })));
148
+ }
149
+ return HISTORY.length;
150
+ }
151
+
152
+ // Virtual Clients, run in-process: they heartbeat like real ones (so they
153
+ // stay IDLE instead of going OUT_OF_SERVICE) and play out any job the
154
+ // scheduler assigns them — accept, PREPARING, RUNNING with streamed log
155
+ // lines, then a PASSED (mostly) or FAILED result — through the same
156
+ // services the Client API uses, so SSE, durations and artifacts all work.
157
+ function startVirtualClients(services, config){
158
+ const { registry, jobs, logs, bus } = services,
159
+ intervalMs = config.heartbeat.intervalSec * 1000,
160
+ clients = new Map(); // resourceId -> { def, activity, running }
161
+
162
+ for (const def of VIRTUAL_CLIENTS){
163
+ const { resourceId } = registry.registerAuto({
164
+ clientId: def.clientId,
165
+ name: def.name,
166
+ type: def.type,
167
+ labels: def.labels,
168
+ hostInfo: { hostname: `${def.name}.virtual`, platform: 'linux', addresses: [{ iface: 'eth0', address: def.address, family: 'IPv4' }], timeZone: 'UTC' },
169
+ capabilities: { ...def.capabilities, rebootSupported: false },
170
+ remoteAddr: '127.0.0.1',
171
+ clientVersion: version
172
+ });
173
+ clients.set(resourceId, { def, activity: { state: 'idle', jobId: null, since: Date.now() }, running: null });
174
+ }
175
+
176
+ const bootedAt = Date.now() - 3 * 86400000;
177
+ function heartbeat(resourceId){
178
+ const c = clients.get(resourceId),
179
+ busy = Boolean(c.running);
180
+ registry.heartbeat(resourceId, {
181
+ state: busy ? 'busy' : 'idle',
182
+ activeJobId: c.running,
183
+ localLock: false,
184
+ addresses: [{ iface: 'eth0', address: c.def.address, family: 'IPv4' }],
185
+ remoteAddr: '127.0.0.1',
186
+ clientVersion: version,
187
+ hostUptimeSec: Math.round((Date.now() - bootedAt) / 1000),
188
+ activity: { state: c.activity.state, jobId: c.activity.jobId, durationSec: Math.round((Date.now() - c.activity.since) / 1000) },
189
+ rebootSchedule: registry.get(resourceId)?.reboot_schedule || null // "applies" whatever is saved
190
+ });
191
+ services.commands.drain(resourceId); // nothing real to act on
192
+ }
193
+
194
+ const beat = () => {
195
+ for (const resourceId of clients.keys()){
196
+ try {
197
+ heartbeat(resourceId);
198
+ }
199
+ catch (err){
200
+ console.error(`DEV virtual client heartbeat: ${err.message}`);
201
+ }
202
+ }
203
+ },
204
+ timer = setInterval(beat, intervalMs);
205
+ timer.unref?.();
206
+ beat();
207
+
208
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
209
+ stillActive = (jobId) => ACTIVE_JOB_STATES.has(jobs.get(jobId)?.state);
210
+
211
+ async function run(resourceId, jobId){
212
+ const c = clients.get(resourceId);
213
+ c.running = jobId;
214
+ c.activity = { state: 'job', jobId, since: Date.now() };
215
+ try {
216
+ await sleep(800);
217
+ if (!stillActive(jobId)){
218
+ return;
219
+ }
220
+ jobs.setState(jobId, JOB_STATES.PREPARING);
221
+ logs.appendBatch(jobId, [{ stream: 'runner', line: `(virtual client ${c.def.name}) preparing` }]);
222
+ await sleep(1500);
223
+ if (!stillActive(jobId)){
224
+ return;
225
+ }
226
+ jobs.setState(jobId, JOB_STATES.RUNNING);
227
+ for (const [stream, line]of LOG_SCRIPT){
228
+ await sleep(600 + Math.random() * 900);
229
+ if (!stillActive(jobId)){
230
+ return; // canceled meanwhile
231
+ }
232
+ logs.appendBatch(jobId, [{ stream, line }]);
233
+ }
234
+ const failed = Math.random() < 0.2 ? 1 : 0;
235
+ jobs.applyResult(jobId, resourceId, {
236
+ state: failed ? JOB_STATES.FAILED : JOB_STATES.PASSED,
237
+ exitCode: failed,
238
+ summary: { total: 4, passed: 4 - failed, failed, skipped: 0 }
239
+ });
240
+ }
241
+ catch (err){
242
+ console.error(`DEV virtual client ${c.def.name}: ${err.message}`);
243
+ }
244
+ finally {
245
+ c.running = null;
246
+ c.activity = { state: 'idle', jobId: null, since: Date.now() };
247
+ heartbeat(resourceId);
248
+ }
249
+ }
250
+
251
+ bus.on('job.assigned', ({ jobId, resourceId }) => {
252
+ if (clients.has(resourceId)){
253
+ run(resourceId, jobId);
254
+ }
255
+ });
256
+
257
+ return {
258
+ resourceIds: Object.fromEntries([...clients].map(([id, c]) => [c.def.type, id])),
259
+ stop: () => clearInterval(timer)
260
+ };
261
+ }
262
+
263
+ // Entry point from server.js. Returns null outside DEV mode.
264
+ function startDevMode(services, config, env = process.env){
265
+ if (!isDevMode(env)){
266
+ return null;
267
+ }
268
+ const agentId = ensureAgent(services),
269
+ virtual = startVirtualClients(services, config),
270
+ seeded = seedHistory(services, agentId, virtual.resourceIds);
271
+ // A few live ones for the virtual Clients to pick up right away.
272
+ if (seeded){
273
+ for (const [type, user]of [['sw', 'alice'], ['hw', null], ['sw', 'bob']]){
274
+ services.jobs.create({ agentId, source: 'cli', spec: spec(type, user) });
275
+ }
276
+ }
277
+ console.warn(
278
+ '*** DEV MODE (DEV_MODE=1): virtual Clients virtual-sw-01 / virtual-hw-01 are running in-process' +
279
+ `${seeded ? `, and ${seeded} past jobs + 3 live ones were seeded` : ''}.\n` +
280
+ `*** Virtual agent token: ${DEV_AGENT_TOKEN}\n` +
281
+ `*** e.g. thub --url ${config.publicUrl} --token ${DEV_AGENT_TOKEN} run --type sw --image alpine --tests https://x/t.tgz`
282
+ );
283
+ return virtual;
284
+ }
285
+
286
+ module.exports = { startDevMode, isDevMode, DEV_AGENT_TOKEN };
package/src/server.js CHANGED
@@ -35,6 +35,7 @@ const path = require('node:path'),
35
35
  { createCommandsService } = require('./services/commands'),
36
36
  { createRetentionService } = require('./services/retention'),
37
37
  { createCleanupService } = require('./services/cleanup'),
38
+ { startDevMode } = require('./dev/virtual'),
38
39
  { createUpdatesService } = require('./services/updates'),
39
40
 
40
41
  { createAgentRouter } = require('./api/agent'),
@@ -154,6 +155,9 @@ function start(configPath){
154
155
  app = createApp(config, services);
155
156
 
156
157
  ensureBootstrapAdmin(services);
158
+ // DEV_MODE=1 plus THUB_BOOTSTRAP_ADMIN_PASSWORD only: virtual agent,
159
+ // Clients and jobs to work on the dashboard with (src/dev/virtual.js).
160
+ services.dev = startDevMode(services, config);
157
161
 
158
162
  const server = app.listen(config.port, config.host, () => {
159
163
  console.log(`Config: ${config.configPath || '(built-in defaults)'}; data: ${config.dataDir}`);
@@ -14,7 +14,7 @@
14
14
  'use strict';
15
15
 
16
16
  const { v4: uuid } = require('uuid'),
17
- { RESOURCE_STATES, BUSY_SOURCES, ACTIVE_JOB_STATES, compareVersions } = require('@andrian.yablonskyy/thub-common'),
17
+ { RESOURCE_STATES, BUSY_SOURCES, ACTIVE_JOB_STATES, compareVersions, parseCron } = require('@andrian.yablonskyy/thub-common'),
18
18
  { generateToken, hashToken } = require('./tokens');
19
19
 
20
20
  function rowToResource(row){
@@ -73,7 +73,15 @@ function sanitizeCapabilities(caps){
73
73
  return null;
74
74
  }
75
75
  const typed = sanitizeTypedCapabilities(caps);
76
- return typed ? { ...typed, allowJobCommands: caps.allowJobCommands === true } : null;
76
+ return typed
77
+ ? {
78
+ ...typed,
79
+ allowJobCommands: caps.allowJobCommands === true,
80
+ // Whether the host has the root reboot helper (thub-client-reboot.path)
81
+ // a scheduled reboot needs; unknown (null) for older Clients.
82
+ rebootSupported: typeof caps.rebootSupported === 'boolean' ? caps.rebootSupported : null
83
+ }
84
+ : null;
77
85
  }
78
86
 
79
87
  function sanitizeTypedCapabilities(caps){
@@ -109,7 +117,7 @@ function sanitizeTypedCapabilities(caps){
109
117
  // Heartbeat durations (README §10) arrive as seconds relative to "now" on
110
118
  // the Client and are anchored to the Coordinator's clock here. Anything
111
119
  // negative, non-numeric or over ~10 years is ignored.
112
- const ACTIVITY_STATES = new Set(['idle', 'job', 'locked', 'update-hold']),
120
+ const ACTIVITY_STATES = new Set(['idle', 'job', 'locked', 'update-hold', 'reboot-hold']),
113
121
  MAX_DURATION_SEC = 10 * 365 * 86400;
114
122
 
115
123
  function secondsAgo(now, sec){
@@ -184,7 +192,12 @@ function createRegistryService(db, { bus, events }){
184
192
  function registerAuto({ clientId, name, type, labels = [], groups = [], hostInfo, capabilities, remoteAddr, clientVersion = null }){
185
193
  const resourceToken = generateToken('res'),
186
194
  remote = normalizeRemoteAddr(remoteAddr);
187
- hostInfo = { ...(hostInfo || {}), addresses: sanitizeAddresses(hostInfo?.addresses) || [] };
195
+ hostInfo = {
196
+ ...(hostInfo || {}),
197
+ addresses: sanitizeAddresses(hostInfo?.addresses) || [],
198
+ // The host's own IANA zone — a scheduled reboot's cron runs in it.
199
+ timeZone: typeof hostInfo?.timeZone === 'string' && /^[A-Za-z0-9_+/-]{1,64}$/.test(hostInfo.timeZone) ? hostInfo.timeZone : null
200
+ };
188
201
  const caps = sanitizeCapabilities(capabilities),
189
202
  capsJson = caps ? JSON.stringify(caps) : null;
190
203
  let existing = getByClientId(clientId);
@@ -296,7 +309,7 @@ function createRegistryService(db, { bus, events }){
296
309
  // Reconcile status from a heartbeat's self-reported state (§5.1).
297
310
  function heartbeat(
298
311
  resourceId,
299
- { state, activeJobId, localLock, metrics, addresses, remoteAddr, clientVersion = null, hostUptimeSec, activity } = {}
312
+ { state, activeJobId, localLock, metrics, addresses, remoteAddr, clientVersion = null, hostUptimeSec, activity, rebootSchedule } = {}
300
313
  ){
301
314
  const resource = get(resourceId);
302
315
  if (!resource){
@@ -329,6 +342,15 @@ function createRegistryService(db, { bus, events }){
329
342
  busyReason = null;
330
343
  }
331
344
 
345
+ // The reboot schedule the Client applies (null: none); older Clients
346
+ // don't report one at all — then leave what's stored.
347
+ if (rebootSchedule !== undefined){
348
+ db.prepare('UPDATE resources SET reboot_schedule_applied = ? WHERE id = ?').run(
349
+ typeof rebootSchedule === 'string' && rebootSchedule ? rebootSchedule.slice(0, 200) : null,
350
+ resourceId
351
+ );
352
+ }
353
+
332
354
  const now = Date.now(),
333
355
  cleanActivity = sanitizeActivity(now, activity),
334
356
 
@@ -521,6 +543,37 @@ function createRegistryService(db, { bus, events }){
521
543
  return db.prepare('SELECT * FROM resources WHERE remove_requested_at IS NOT NULL').all().map(rowToResource);
522
544
  }
523
545
 
546
+ // Scheduled host reboot (resource card): a cron expression, validated
547
+ // here; '' or null clears it. Delivered on the Client's next heartbeat.
548
+ function setRebootSchedule(resourceId, cron, { by } = {}){
549
+ const r = get(resourceId);
550
+ if (!r){
551
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
552
+ }
553
+ const text = typeof cron === 'string' ? cron.trim().replace(/\s+/g, ' ') : '';
554
+ if (text){
555
+ try {
556
+ parseCron(text);
557
+ }
558
+ catch (err){
559
+ throw Object.assign(new Error(`Invalid reboot schedule "${text}": ${err.message}`), { status: 400 });
560
+ }
561
+ }
562
+ db.prepare('UPDATE resources SET reboot_schedule = ? WHERE id = ?').run(text || null, resourceId);
563
+ events.record('resource', resourceId, 'resource.reboot_schedule', { cron: text || null, by });
564
+ return get(resourceId);
565
+ }
566
+
567
+ // What a heartbeat reply must tell the Client, if its applied schedule
568
+ // isn't the saved one: { cron } (null = clear). Repeated until it reports it.
569
+ function pendingRebootSchedule(resourceId){
570
+ const r = get(resourceId);
571
+ if (!r || (r.reboot_schedule || null) === (r.reboot_schedule_applied || null)){
572
+ return null;
573
+ }
574
+ return { cron: r.reboot_schedule || null };
575
+ }
576
+
524
577
  function findIdleCandidates(type, labels, groupId, resourceId, needs){
525
578
  const rows = db
526
579
  .prepare('SELECT * FROM resources WHERE status = ? AND type = ? AND remove_requested_at IS NULL')
@@ -552,6 +605,8 @@ function createRegistryService(db, { bus, events }){
552
605
  pendingUpdate,
553
606
  assignToJob,
554
607
  remove,
608
+ setRebootSchedule,
609
+ pendingRebootSchedule,
555
610
  requestRemoval,
556
611
  pendingRemovals,
557
612
  findIdleCandidates,
package/src/web/routes.js CHANGED
@@ -21,7 +21,8 @@ const fs = require('node:fs'),
21
21
  { requireAdminSession, requireAdminRole } = require('../auth'),
22
22
  { attachJobStream } = require('../api/sse'),
23
23
  {
24
- RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, isNewer, compareVersions, formatDateTime, parseDateTime
24
+ RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, isNewer, compareVersions, formatDateTime, parseDateTime,
25
+ nextCronRun
25
26
  } = require('@andrian.yablonskyy/thub-common'),
26
27
  { retentionCutoff, JOB_RETENTION } = require('../services/cleanup'),
27
28
  listPrefs = require('../services/list-prefs');
@@ -152,6 +153,8 @@ function createWebRouter({ services, config }){
152
153
  ];
153
154
  }
154
155
  res.locals.isNewer = isNewer;
156
+ res.locals.nextCronRun = nextCronRun;
157
+ res.locals.formatDateTime = formatDateTime;
155
158
  res.locals.currentPath = req.originalUrl;
156
159
  if (req.session){
157
160
  req.session.flash = [];
@@ -365,6 +368,10 @@ function createWebRouter({ services, config }){
365
368
  services.commands.push(r.id, { command: 'unlock' });
366
369
  flash(req, 'warning', `Releasing the local lock on ${r.name} (on its next heartbeat).`);
367
370
  }
371
+ else if (activity === 'reboot-hold'){
372
+ services.commands.push(r.id, { command: 'cancel-reboot' });
373
+ flash(req, 'warning', `Canceling the scheduled reboot of ${r.name}'s host (on its next heartbeat).`);
374
+ }
368
375
  else if (activity === 'update-hold'){
369
376
  services.commands.push(r.id, { command: 'cancel-update' });
370
377
  if (r.update_to){
@@ -375,7 +382,7 @@ function createWebRouter({ services, config }){
375
382
  else {
376
383
  flash(req, 'info', `${r.name} isn't doing anything to cancel.`);
377
384
  }
378
- if (!job && ['locked', 'update-hold'].includes(activity)){
385
+ if (!job && ['locked', 'update-hold', 'reboot-hold'].includes(activity)){
379
386
  services.events.record('resource', r.id, 'resource.activity_canceled', { activity, by: req.session.user.username });
380
387
  }
381
388
  }
@@ -405,6 +412,22 @@ function createWebRouter({ services, config }){
405
412
  res.redirect(returnTo(req, '/resources'));
406
413
  });
407
414
 
415
+ // Scheduled host reboot (resource card): save a cron expression, or clear
416
+ // it; the Client applies it on its next heartbeat.
417
+ router.post('/resources/:id/reboot-schedule', requireAdminRole, (req, res) => {
418
+ try {
419
+ const cron = req.body.clear === '1' ? '' : req.body.cron,
420
+ r = services.registry.setRebootSchedule(req.params.id, cron, { by: req.session.user.username });
421
+ flash(req, 'success', r.reboot_schedule
422
+ ? `Reboot schedule for ${r.name} saved: ${r.reboot_schedule}.\nThe Client applies it on its next heartbeat.`
423
+ : `Scheduled reboot for ${r.name} cleared.\nThe Client applies it on its next heartbeat.`);
424
+ }
425
+ catch (err){
426
+ flash(req, 'danger', err.message);
427
+ }
428
+ res.redirect(returnTo(req, '/resources'));
429
+ });
430
+
408
431
  router.post('/resources/:id/rotate-token', requireAdminRole, (req, res) => {
409
432
  const { generateToken, hashToken } = require('../services/tokens');
410
433
  const token = generateToken('res');
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @file packages/coordinator/test/dev-mode.test.js
3
+ * @description Tests: DEV mode (virtual agent, Clients and jobs) — only with DEV_MODE=1 and a bootstrap password
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
+ const test = require('node:test'),
17
+ assert = require('node:assert/strict'),
18
+ fs = require('node:fs'),
19
+ os = require('node:os'),
20
+ path = require('node:path'),
21
+ { loadConfig } = require('../src/config'),
22
+ { buildServices } = require('../src/server'),
23
+ { startDevMode, isDevMode, DEV_AGENT_TOKEN } = require('../src/dev/virtual'),
24
+ { hashToken } = require('../src/services/tokens');
25
+
26
+ test('DEV mode needs both DEV_MODE=1 and THUB_BOOTSTRAP_ADMIN_PASSWORD', () => {
27
+ assert.equal(isDevMode({ DEV_MODE: '1', THUB_BOOTSTRAP_ADMIN_PASSWORD: 'pw' }), true);
28
+ assert.equal(isDevMode({ DEV_MODE: '1' }), false);
29
+ assert.equal(isDevMode({ THUB_BOOTSTRAP_ADMIN_PASSWORD: 'pw' }), false);
30
+ assert.equal(isDevMode({ DEV_MODE: 'true', THUB_BOOTSTRAP_ADMIN_PASSWORD: 'pw' }), false);
31
+ });
32
+
33
+ test('seeds a virtual agent, virtual Clients and jobs once; nothing outside DEV mode', (t) => {
34
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-dev-')),
35
+ file = path.join(dir, 'coordinator.json');
36
+ fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 } }));
37
+ const config = loadConfig(file),
38
+ services = buildServices(config),
39
+ count = (table) => services.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n,
40
+ warn = t.mock.method(console, 'warn', () => {});
41
+
42
+ assert.equal(startDevMode(services, config, {}), null);
43
+ assert.equal(count('agents') + count('resources') + count('jobs'), 0);
44
+
45
+ const env = { DEV_MODE: '1', THUB_BOOTSTRAP_ADMIN_PASSWORD: 'pw' },
46
+ first = startDevMode(services, config, env);
47
+ t.after(() => first.stop());
48
+ assert.equal(services.agents.getByTokenHash(hashToken(DEV_AGENT_TOKEN)).name, 'virtual-agent');
49
+ assert.deepEqual(services.registry.list().map((r) => r.name).sort(), ['virtual-hw-01', 'virtual-sw-01']);
50
+ const jobs = count('jobs'),
51
+ states = new Set(services.db.prepare('SELECT state FROM jobs').all().map((r) => r.state));
52
+ assert.equal(jobs, 15);
53
+ for (const s of ['PASSED', 'FAILED', 'ERROR', 'TIMEOUT', 'CANCELED', 'LOST', 'QUEUED']){
54
+ assert.ok(states.has(s), s);
55
+ }
56
+
57
+ const second = startDevMode(services, config, env); // a restart
58
+ t.after(() => second.stop());
59
+ assert.equal(count('jobs'), jobs);
60
+ assert.equal(count('agents'), 1);
61
+ assert.equal(count('resources'), 2);
62
+ assert.equal(warn.mock.callCount(), 2);
63
+ });
@@ -565,6 +565,26 @@ test('a job-supplied command only goes to Clients with allowJobCommands; otherwi
565
565
  assert.equal(jobs.get(job.id).resource_id, resourceId);
566
566
  });
567
567
 
568
+ test('reboot schedule: validated, then resent on heartbeats until the Client reports applying it', () => {
569
+ const { registry } = buildTestServices(),
570
+ r = registerResource(registry, { name: 'lab-reboot', type: 'sw' });
571
+
572
+ assert.throws(() => registry.setRebootSchedule(r.id, '61 3 * * *'), /Invalid reboot schedule.*minute: 61/);
573
+ assert.equal(registry.pendingRebootSchedule(r.id), null); // nothing set, nothing applied
574
+
575
+ registry.setRebootSchedule(r.id, ' 30 3 * * sun ');
576
+ assert.deepEqual(registry.pendingRebootSchedule(r.id), { cron: '30 3 * * sun' });
577
+ registry.heartbeat(r.id, { state: 'idle' }); // an older Client: reports nothing
578
+ assert.deepEqual(registry.pendingRebootSchedule(r.id), { cron: '30 3 * * sun' });
579
+ registry.heartbeat(r.id, { state: 'idle', rebootSchedule: '30 3 * * sun' });
580
+ assert.equal(registry.pendingRebootSchedule(r.id), null);
581
+
582
+ registry.setRebootSchedule(r.id, '');
583
+ assert.deepEqual(registry.pendingRebootSchedule(r.id), { cron: null }); // tell it to clear
584
+ registry.heartbeat(r.id, { state: 'idle', rebootSchedule: null });
585
+ assert.equal(registry.pendingRebootSchedule(r.id), null);
586
+ });
587
+
568
588
  test('a resource can belong to several groups at once', () => {
569
589
  const { registry, groups } = buildTestServices(),
570
590
  g1 = groups.create({ name: 'g1' }),
@@ -41,9 +41,6 @@ block content
41
41
  dt.col-sm-2 Message
42
42
  dd.col-sm-10= job.message
43
43
 
44
- h2.h6.mt-4 Log
45
- +logViewer(job.id, jobActive)
46
-
47
44
  h2.h6.mt-4#artifacts Artifacts
48
45
  if artifacts.length
49
46
  ul.list-group
@@ -53,3 +50,6 @@ block content
53
50
  a(href=a.url) download
54
51
  else
55
52
  p.text-body-secondary No artifacts yet.
53
+
54
+ h2.h6.mt-4 Log
55
+ +logViewer(job.id, jobActive)
@@ -180,7 +180,7 @@ mixin capabilitiesList(caps)
180
180
  mixin uptimeRows(r, returnTo)
181
181
  - const stale = r.status === 'OUT_OF_SERVICE' && r.last_heartbeat_at
182
182
  - const ref = stale ? new Date(r.last_heartbeat_at).getTime() : Date.now()
183
- - const activityLabel = { idle: 'Idle', job: 'Running job', locked: 'Locked locally', 'update-hold': 'Holding for self-update' }
183
+ - const activityLabel = { idle: 'Idle', job: 'Running job', locked: 'Locked locally', 'update-hold': 'Holding for self-update', 'reboot-hold': 'Holding for scheduled reboot' }
184
184
  dt.col-sm-4 Host uptime
185
185
  dd.col-sm-8
186
186
  if r.host_booted_at
@@ -205,12 +205,80 @@ mixin uptimeRows(r, returnTo)
205
205
  if user.role === 'admin'
206
206
  +cancelActivityButton(r, returnTo)
207
207
 
208
+ //- Scheduled host reboot (cron, in the Client host's local time): what's
209
+ //- saved, when it next fires, whether the Client has applied it, and — for
210
+ //- admins — an editor. Saved here, applied on the Client's next heartbeat.
211
+ mixin rebootScheduleRow(r, returnTo)
212
+ - const tz = r.host_info && r.host_info.timeZone
213
+ - const caps = r.capabilities || {}
214
+ - const pending = (r.reboot_schedule || null) !== (r.reboot_schedule_applied || null)
215
+ - let next = null
216
+ - try { next = r.reboot_schedule && tz ? nextCronRun(r.reboot_schedule, new Date(), { timeZone: tz }) : null } catch (e) { next = null }
217
+ dt.col-sm-4 Scheduled reboot
218
+ dd.col-sm-8
219
+ if r.reboot_schedule
220
+ code= r.reboot_schedule
221
+ if next
222
+ span.text-body-secondary.small.ms-2(
223
+ data-bs-toggle="tooltip"
224
+ data-bs-title=`${formatDateTime(next, { timeZone: tz })} host time (${tz}); shown here in your time zone`
225
+ )= `next ${fmtDate(next)}`
226
+ else if !tz
227
+ span.text-body-secondary.small.ms-2(
228
+ data-bs-toggle="tooltip"
229
+ data-bs-title="The Client runs it in its host's local time; this Client is too old to report its time zone"
230
+ ) host local time
231
+ else
232
+ span.text-body-secondary none
233
+ if pending
234
+ span.badge.text-bg-warning.ms-2(
235
+ data-bs-toggle="tooltip"
236
+ data-bs-title="Saved — the Client applies it on its next heartbeat"
237
+ ) not applied yet
238
+ if caps.rebootSupported === false
239
+ span.badge.text-bg-danger.ms-2(
240
+ data-bs-toggle="tooltip"
241
+ data-bs-title="This host has no reboot helper (thub-client-reboot.path), so the schedule can't reboot it — reinstall the Client as root: sudo npm i -g @andrian.yablonskyy/thub-client"
242
+ ) can't reboot
243
+ if user.role === 'admin'
244
+ form.d-flex.gap-1.mt-1(
245
+ method="post"
246
+ action=`/resources/${r.id}/reboot-schedule`
247
+ data-confirm=`Reboot the host of ${r.name} on this schedule? It reboots the whole host — every Client instance on it. Running jobs finish first: new ones wait, for up to 2 h, and otherwise that time is skipped.`
248
+ data-confirm-title="Schedule host reboots?"
249
+ data-confirm-ok="Save schedule"
250
+ data-confirm-tone="warning"
251
+ )
252
+ input(type="hidden" name="returnTo" value=returnTo)
253
+ input.form-control.form-control-sm.font-monospace(
254
+ type="text"
255
+ name="cron"
256
+ value=r.reboot_schedule || ''
257
+ placeholder="30 3 * * sun"
258
+ aria-label=`Reboot schedule for ${r.name} (cron)`
259
+ required
260
+ )
261
+ button.btn.btn-sm.btn-outline-primary(type="submit") Save
262
+ if r.reboot_schedule
263
+ form.d-inline(method="post" action=`/resources/${r.id}/reboot-schedule`)
264
+ input(type="hidden" name="returnTo" value=returnTo)
265
+ input(type="hidden" name="clear" value="1")
266
+ button.btn.btn-sm.btn-link.p-0.small(type="submit") Clear schedule
267
+ .form-text
268
+ | cron: minute hour day month weekday, in the host's local time#{tz ? ` (${tz})` : ''} — e.g.
269
+ |
270
+ code 30 3 * * sun
271
+ | (Sundays 03:30) or
272
+ |
273
+ code 0 4 * * *
274
+ | . Reboots the whole host.
275
+
208
276
  //- Cancel the Client's current job or local lock — icon only, on the card's
209
277
  //- Task / status line, and only while there's one. A self-update hold is
210
278
  //- canceled from the version line instead (+versionWithUpdate).
211
279
  mixin cancelActivityButton(r, returnTo)
212
280
  - const state = r.activity && r.activity.state
213
- - const what = r.activeJob ? `job ${r.activeJob.id}` : state === 'locked' ? 'the local lock' : null
281
+ - const what = r.activeJob ? `job ${r.activeJob.id}` : state === 'locked' ? 'the local lock' : state === 'reboot-hold' ? 'the scheduled reboot' : null
214
282
  if what
215
283
  form.d-inline.ms-2(
216
284
  method="post"
@@ -234,60 +302,92 @@ mixin resourceCard(r, groupsById, returnTo)
234
302
  - const local = ((r.host_info && r.host_info.addresses) || []).filter((a) => !isLoopback(a.address))
235
303
  - const external = r.remote_addr && !isLoopback(r.remote_addr) ? r.remote_addr : null
236
304
  .modal.fade.thub-resource-card(id=`resource-card-${r.id}` tabindex="-1" aria-labelledby=`resource-card-${r.id}-title` aria-hidden="true")
237
- .modal-dialog.modal-dialog-centered
305
+ .modal-dialog.modal-dialog-centered.modal-lg
238
306
  .modal-content
239
307
  .modal-header
240
308
  h5.modal-title(id=`resource-card-${r.id}-title`)= r.name
241
309
  button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
242
- .modal-body
243
- dl.row.mb-0
244
- dt.col-sm-4 Name
245
- dd.col-sm-8= r.name
246
- dt.col-sm-4 Type
247
- dd.col-sm-8.text-uppercase= r.type
248
- dt.col-sm-4 Status
249
- dd.col-sm-8: +resourceBadge(r.status, r.remove_requested_at)
250
- +uptimeRows(r, returnTo)
251
- dt.col-sm-4 Client version
252
- dd.col-sm-8: +versionWithUpdate(r.client_version, updates.latest.client, r.update_to, `/resources/${r.id}/update`, returnTo, true, r.activity && r.activity.state === 'update-hold')
253
- dt.col-sm-4 IP addresses
254
- dd.col-sm-8
255
- if !local.length && !external
256
- span.text-body-secondary —
257
- ul.list-unstyled.mb-0
258
- each a in local
259
- li
260
- code= a.address
261
- span.text-body-secondary.small.ms-2= a.iface ? `${a.iface} · ${a.family}` : a.family
262
- if external
263
- li
264
- code= external
265
- span.text-body-secondary.small.ms-2(
266
- data-bs-toggle="tooltip"
267
- data-bs-title="The address the Coordinator last saw this Client connect from"
268
- ) external
269
- dt.col-sm-4 Capabilities
270
- dd.col-sm-8
271
- +capabilitiesList(r.capabilities)
272
- dt.col-sm-4 Labels
273
- dd.col-sm-8
274
- if r.labels.length
275
- each l in r.labels
276
- span.badge.text-bg-light.border.me-1= l
277
- else
278
- span.text-body-secondary —
279
- dt.col-sm-4 Groups
280
- dd.col-sm-8
281
- if r.group_ids.length
282
- each gid in r.group_ids
283
- span.badge.text-bg-info.me-1(data-bs-toggle="tooltip" data-bs-title=gid)= groupsById[gid] ? groupsById[gid].name : `(unknown: ${gid})`
284
- else
285
- span.text-body-secondary —
286
- dt.col-sm-4 Busy source / reason
287
- dd.col-sm-8= r.busy_source ? `${r.busy_source}${r.busy_reason ? ' — ' + r.busy_reason : ''}` : '—'
288
- dt.col-sm-4 Last heartbeat
289
- dd.col-sm-8= fmtDate(r.last_heartbeat_at, 'never')
290
- if user.role === 'admin'
291
- dt.col-sm-4 Actions
292
- dd.col-sm-8.mb-0
293
- +resourceActions(r, returnTo)
310
+ .modal-body.pt-2
311
+ //- Details: what the Client is and reports. Config: settings sent
312
+ //- to it (today the scheduled reboot); read-only for viewers.
313
+ ul.nav.nav-tabs.mb-3(role="tablist")
314
+ li.nav-item(role="presentation")
315
+ button.nav-link.active(
316
+ id=`rc-${r.id}-details-tab`
317
+ type="button"
318
+ role="tab"
319
+ data-bs-toggle="tab"
320
+ data-bs-target=`#rc-${r.id}-details`
321
+ aria-controls=`rc-${r.id}-details`
322
+ aria-selected="true"
323
+ ) Details
324
+ li.nav-item(role="presentation")
325
+ button.nav-link(
326
+ id=`rc-${r.id}-config-tab`
327
+ type="button"
328
+ role="tab"
329
+ data-bs-toggle="tab"
330
+ data-bs-target=`#rc-${r.id}-config`
331
+ aria-controls=`rc-${r.id}-config`
332
+ aria-selected="false"
333
+ ) Config
334
+ .tab-content
335
+ .tab-pane.fade.show.active(id=`rc-${r.id}-details` role="tabpanel" aria-labelledby=`rc-${r.id}-details-tab` tabindex="0")
336
+ +resourceDetails(r, groupsById, returnTo, local, external)
337
+ .tab-pane.fade(id=`rc-${r.id}-config` role="tabpanel" aria-labelledby=`rc-${r.id}-config-tab` tabindex="0")
338
+ dl.row.mb-0
339
+ +rebootScheduleRow(r, returnTo)
340
+
341
+ //- The resource card's Details tab.
342
+ mixin resourceDetails(r, groupsById, returnTo, local, external)
343
+ dl.row.mb-0
344
+ dt.col-sm-4 Name
345
+ dd.col-sm-8= r.name
346
+ dt.col-sm-4 Type
347
+ dd.col-sm-8.text-uppercase= r.type
348
+ dt.col-sm-4 Status
349
+ dd.col-sm-8: +resourceBadge(r.status, r.remove_requested_at)
350
+ +uptimeRows(r, returnTo)
351
+ dt.col-sm-4 Client version
352
+ dd.col-sm-8: +versionWithUpdate(r.client_version, updates.latest.client, r.update_to, `/resources/${r.id}/update`, returnTo, true, r.activity && r.activity.state === 'update-hold')
353
+ dt.col-sm-4 IP addresses
354
+ dd.col-sm-8
355
+ if !local.length && !external
356
+ span.text-body-secondary —
357
+ ul.list-unstyled.mb-0
358
+ each a in local
359
+ li
360
+ code= a.address
361
+ span.text-body-secondary.small.ms-2= a.iface ? `${a.iface} · ${a.family}` : a.family
362
+ if external
363
+ li
364
+ code= external
365
+ span.text-body-secondary.small.ms-2(
366
+ data-bs-toggle="tooltip"
367
+ data-bs-title="The address the Coordinator last saw this Client connect from"
368
+ ) external
369
+ dt.col-sm-4 Capabilities
370
+ dd.col-sm-8.resource-capabilities
371
+ +capabilitiesList(r.capabilities)
372
+ dt.col-sm-4 Labels
373
+ dd.col-sm-8
374
+ if r.labels.length
375
+ each l in r.labels
376
+ span.badge.text-bg-light.border.me-1= l
377
+ else
378
+ span.text-body-secondary —
379
+ dt.col-sm-4 Groups
380
+ dd.col-sm-8
381
+ if r.group_ids.length
382
+ each gid in r.group_ids
383
+ span.badge.text-bg-info.me-1(data-bs-toggle="tooltip" data-bs-title=gid)= groupsById[gid] ? groupsById[gid].name : `(unknown: ${gid})`
384
+ else
385
+ span.text-body-secondary —
386
+ dt.col-sm-4 Busy source / reason
387
+ dd.col-sm-8= r.busy_source ? `${r.busy_source}${r.busy_reason ? ' — ' + r.busy_reason : ''}` : '—'
388
+ dt.col-sm-4 Last heartbeat
389
+ dd.col-sm-8= fmtDate(r.last_heartbeat_at, 'never')
390
+ if user.role === 'admin'
391
+ dt.col-sm-4 Actions
392
+ dd.col-sm-8.mb-0
393
+ +resourceActions(r, returnTo)