@andrian.yablonskyy/thub-coordinator 1.0.26 → 1.0.28

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.28",
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
+ }
@@ -47,7 +47,16 @@
47
47
  field('ok').className = `btn btn-${tone}`;
48
48
  field('ok').textContent = form.dataset.confirmOk || 'Confirm';
49
49
  field('dismiss').textContent = form.dataset.confirmDismiss || 'Cancel';
50
- modal.show();
50
+ // Asked from inside another modal (e.g. the resource card): close that
51
+ // one first — Bootstrap doesn't stack modals.
52
+ const host = form.closest('.modal.show');
53
+ if (host && host !== modalEl){
54
+ host.addEventListener('hidden.bs.modal', () => modal.show(), { once: true });
55
+ bootstrap.Modal.getInstance(host)?.hide();
56
+ }
57
+ else {
58
+ modal.show();
59
+ }
51
60
  });
52
61
 
53
62
  field('ok').addEventListener('click', () => {
package/src/api/admin.js CHANGED
@@ -51,6 +51,28 @@ function createAdminRouter({ services }){
51
51
  res.status(204).end();
52
52
  });
53
53
 
54
+ // Reboot a Client's host now (its running job is canceled first).
55
+ router.post('/resources/:id/reboot', (req, res, next) => {
56
+ try {
57
+ const { resource, canceledJob } = services.jobs.requestReboot(req.params.id);
58
+ res.json({ id: resource.id, rebooting: true, canceledJob });
59
+ }
60
+ catch (err){
61
+ next(err);
62
+ }
63
+ });
64
+
65
+ // Scheduled host reboot: { cron } (5-field, host local time; "" clears).
66
+ router.post('/resources/:id/reboot-schedule', (req, res, next) => {
67
+ try {
68
+ const r = services.registry.setRebootSchedule(req.params.id, req.body?.cron ?? '');
69
+ res.json({ id: r.id, rebootSchedule: r.reboot_schedule, applied: r.reboot_schedule_applied });
70
+ }
71
+ catch (err){
72
+ next(err);
73
+ }
74
+ });
75
+
54
76
  router.post('/resources/:id/maintenance', (req, res, next) => {
55
77
  try {
56
78
  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
+ },
35
+ address: '10.0.0.41'
36
+ },
37
+ {
38
+ clientId: 'virtual-client-hw-01',
39
+ name: 'virtual-hw-01',
40
+ type: 'hw',
41
+ labels: ['virtual', 'board:nucleo-f401re', 'uart', 'stlink'],
42
+ capabilities: {
43
+ hw: {
44
+ stlinks: [{ path: '/dev/thub/dut1-stlink', index: 1, serial: '066DFF485457725187092834', present: true }],
45
+ uarts: [{ path: '/dev/thub/dut1-uart', index: 1, baudRate: 115200, present: true }],
46
+ usbs: [{ path: '/dev/thub/dut1-usb', index: 1, present: false }],
47
+ relays: [{ channel: 0, baseUrl: 'http://localhost:3000' }],
48
+ power: { method: 'relay' }
49
+ }
50
+ },
51
+ address: '10.0.0.42'
52
+ }
53
+ ],
54
+ // Seeded history: [daysAgo, source, target type, final state, duration s, user].
55
+ HISTORY = [
56
+ [6.8, 'ci', 'hw', 'PASSED', 412, null],
57
+ [6.1, 'cli', 'sw', 'FAILED', 95, 'alice'],
58
+ [5.4, 'ci', 'sw', 'PASSED', 188, null],
59
+ [4.9, 'ci', 'hw', 'ERROR', 31, null],
60
+ [4.2, 'cli', 'hw', 'PASSED', 640, 'bob'],
61
+ [3.6, 'ci', 'sw', 'TIMEOUT', 1800, null],
62
+ [2.7, 'cli', 'sw', 'CANCELED', 44, 'alice'],
63
+ [2.1, 'ci', 'hw', 'PASSED', 377, null],
64
+ [1.3, 'ci', 'sw', 'FAILED', 152, null],
65
+ [0.8, 'cli', 'hw', 'LOST', 260, 'bob'],
66
+ [0.4, 'ci', 'sw', 'PASSED', 201, null],
67
+ [0.1, 'cli', 'sw', 'PASSED', 73, 'alice']
68
+ ],
69
+ LOG_SCRIPT = [
70
+ ['runner', 'downloading app.bin (virtual)'],
71
+ ['runner', 'cloning yourorg/firmware-tests at main (virtual)'],
72
+ ['flash', 'st-flash write app.bin 0x08000000 ... done (virtual)'],
73
+ ['uart', '[BOOT] app v1.4.0-dev'],
74
+ ['uart', '[INIT] peripherals ok'],
75
+ ['runner', 'running: sh -c "./ci/run.sh"'],
76
+ ['runner', 'test_boot ............ ok'],
77
+ ['runner', 'test_uart_echo ....... ok'],
78
+ ['runner', 'test_flash_crc ....... ok'],
79
+ ['runner', 'test_power_cycle ..... ok']
80
+ ],
81
+
82
+ spec = (type, user, extra = {}) => ({
83
+ target: { type, labels: ['virtual'] },
84
+ command: './ci/run.sh',
85
+ downloads: [{ url: 'https://artifactory.example.com/fw-local/app/1.4.0-dev/app.bin' }],
86
+ ...(type === 'sw' ? { image: 'alpine:3.20' } : {}),
87
+ git: { url: 'https://github.com/yourorg/firmware-tests.git', ref: 'main', depth: 1 },
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 --docker-image alpine --command 'uname -a'`
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}`);
@@ -13,7 +13,7 @@
13
13
 
14
14
  'use strict';
15
15
 
16
- const { validateJobSpec, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES } = require('@andrian.yablonskyy/thub-common'),
16
+ const { validateJobSpec, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
17
17
  { paginate } = require('./list-prefs');
18
18
 
19
19
  function rowToJob(row){
@@ -145,22 +145,15 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
145
145
  spec.target.client = resource.id;
146
146
  }
147
147
 
148
- // A job-supplied Docker image or command needs a Client that opted in —
149
- // say which, rather than the generic "no resource can satisfy".
150
- const needs = { jobImage: Boolean(spec.firmware.image), jobCommand: Boolean(spec.tests.command) },
148
+ // A job-supplied Docker image needs an SW Client that opted in — say so,
149
+ // rather than the generic "no resource can satisfy".
150
+ const needs = { jobImage: Boolean(spec.image) },
151
151
  { type, labels, group, client } = spec.target;
152
- if ((needs.jobImage || needs.jobCommand) && registry.everSatisfiable(type, labels, group, client) &&
153
- !registry.everSatisfiable(type, labels, group, client, needs)){
154
- const missing = [
155
- needs.jobImage && !registry.everSatisfiable(type, labels, group, client, { jobImage: true }) &&
156
- `job-supplied Docker images (${spec.firmware.image}) — "sw": { "allowJobImages": true }`,
157
- needs.jobCommand && !registry.everSatisfiable(type, labels, group, client, { jobCommand: true }) &&
158
- 'job-supplied commands (--run) — "allowJobCommands": true'
159
- ].filter(Boolean);
152
+ if (needs.jobImage && registry.everSatisfiable(type, labels, group, client) && !registry.everSatisfiable(type, labels, group, client, needs)){
160
153
  throw Object.assign(
161
154
  new Error(
162
- `No matching Client runs ${missing.length ? missing.join(', nor ') : 'both a job-supplied Docker image and command together'}` +
163
- ' — enable it in a Client\'s config'
155
+ `No matching SW Client runs job-supplied Docker images (${spec.image}) — enable it on a Client ` +
156
+ 'with "sw": { "allowJobImages": true } in its config'
164
157
  ),
165
158
  { status: 422 }
166
159
  );
@@ -263,7 +256,8 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
263
256
  }
264
257
 
265
258
  // Agent: POST /jobs/:id/cancel
266
- function cancel(id, { agentId, isAdmin }){
259
+ // `reason`: shown as the job's message (e.g. "Canceled: user reboot request").
260
+ function cancel(id, { agentId, isAdmin, reason }){
267
261
  const job = get(id);
268
262
  if (!job){
269
263
  throw Object.assign(new Error('Unknown job'), { status: 404 });
@@ -278,7 +272,35 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
278
272
  if (job.resource_id){
279
273
  bus.emit('command', { resourceId: job.resource_id, command: 'cancel-job', jobId: id });
280
274
  }
281
- return setState(id, JOB_STATES.CANCELED);
275
+ return setState(id, JOB_STATES.CANCELED, reason ? { message: reason } : {});
276
+ }
277
+
278
+ // Admin "Reboot" on a Client (resource card): its running job is canceled
279
+ // first (the host is going down anyway), then a `reboot` command goes out
280
+ // with its next heartbeat — the Client reboots the host through its root
281
+ // reboot helper once nothing on it is busy. Refused for a Client that's
282
+ // offline (it would reboot whenever it next reconnects) or can't reboot.
283
+ function requestReboot(resourceId, { by } = {}){
284
+ const r = registry.get(resourceId);
285
+ if (!r){
286
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
287
+ }
288
+ if (r.status === RESOURCE_STATES.OUT_OF_SERVICE || !r.last_heartbeat_at){
289
+ throw Object.assign(new Error(`${r.name} is offline — it can only be rebooted while it's connected`), { status: 409 });
290
+ }
291
+ if (r.capabilities?.rebootSupported === false){
292
+ throw Object.assign(
293
+ new Error(`${r.name}'s host has no reboot helper — reinstall the Client as root (sudo npm i -g @andrian.yablonskyy/thub-client)`),
294
+ { status: 409 }
295
+ );
296
+ }
297
+ const job = activeForResource(resourceId);
298
+ if (job){
299
+ cancel(job.id, { isAdmin: true, reason: 'Canceled: user reboot request' });
300
+ }
301
+ bus.emit('command', { resourceId, command: 'reboot', reason: `user reboot request${by ? ` by ${by}` : ''}` });
302
+ events.record('resource', resourceId, 'resource.reboot_requested', { by, canceledJob: job?.id || null });
303
+ return { resource: r, canceledJob: job?.id || null };
282
304
  }
283
305
 
284
306
  // Queued jobs pinned to a resource (`target.client`) can never run once it's
@@ -493,6 +515,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
493
515
  setState,
494
516
  cancel,
495
517
  cancelPinnedTo,
518
+ requestReboot,
496
519
  removeResource,
497
520
  completeRemoval,
498
521
  activeForResource,
@@ -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){
@@ -67,13 +67,19 @@ const MAX_DEVICES = 8,
67
67
  present: typeof d?.present === 'boolean' ? d.present : null
68
68
  });
69
69
 
70
- // Plus `allowJobCommands` (either type): runs a job's own `tests.command`.
71
70
  function sanitizeCapabilities(caps){
72
71
  if (!caps || typeof caps !== 'object'){
73
72
  return null;
74
73
  }
75
74
  const typed = sanitizeTypedCapabilities(caps);
76
- return typed ? { ...typed, allowJobCommands: caps.allowJobCommands === true } : null;
75
+ return typed
76
+ ? {
77
+ ...typed,
78
+ // Whether the host has the root reboot helper (thub-client-reboot.path)
79
+ // a scheduled reboot needs; unknown (null) for older Clients.
80
+ rebootSupported: typeof caps.rebootSupported === 'boolean' ? caps.rebootSupported : null
81
+ }
82
+ : null;
77
83
  }
78
84
 
79
85
  function sanitizeTypedCapabilities(caps){
@@ -109,7 +115,7 @@ function sanitizeTypedCapabilities(caps){
109
115
  // Heartbeat durations (README §10) arrive as seconds relative to "now" on
110
116
  // the Client and are anchored to the Coordinator's clock here. Anything
111
117
  // negative, non-numeric or over ~10 years is ignored.
112
- const ACTIVITY_STATES = new Set(['idle', 'job', 'locked', 'update-hold']),
118
+ const ACTIVITY_STATES = new Set(['idle', 'job', 'locked', 'update-hold', 'reboot-hold']),
113
119
  MAX_DURATION_SEC = 10 * 365 * 86400;
114
120
 
115
121
  function secondsAgo(now, sec){
@@ -184,7 +190,12 @@ function createRegistryService(db, { bus, events }){
184
190
  function registerAuto({ clientId, name, type, labels = [], groups = [], hostInfo, capabilities, remoteAddr, clientVersion = null }){
185
191
  const resourceToken = generateToken('res'),
186
192
  remote = normalizeRemoteAddr(remoteAddr);
187
- hostInfo = { ...(hostInfo || {}), addresses: sanitizeAddresses(hostInfo?.addresses) || [] };
193
+ hostInfo = {
194
+ ...(hostInfo || {}),
195
+ addresses: sanitizeAddresses(hostInfo?.addresses) || [],
196
+ // The host's own IANA zone — a scheduled reboot's cron runs in it.
197
+ timeZone: typeof hostInfo?.timeZone === 'string' && /^[A-Za-z0-9_+/-]{1,64}$/.test(hostInfo.timeZone) ? hostInfo.timeZone : null
198
+ };
188
199
  const caps = sanitizeCapabilities(capabilities),
189
200
  capsJson = caps ? JSON.stringify(caps) : null;
190
201
  let existing = getByClientId(clientId);
@@ -296,7 +307,7 @@ function createRegistryService(db, { bus, events }){
296
307
  // Reconcile status from a heartbeat's self-reported state (§5.1).
297
308
  function heartbeat(
298
309
  resourceId,
299
- { state, activeJobId, localLock, metrics, addresses, remoteAddr, clientVersion = null, hostUptimeSec, activity } = {}
310
+ { state, activeJobId, localLock, metrics, addresses, remoteAddr, clientVersion = null, hostUptimeSec, activity, rebootSchedule } = {}
300
311
  ){
301
312
  const resource = get(resourceId);
302
313
  if (!resource){
@@ -329,6 +340,15 @@ function createRegistryService(db, { bus, events }){
329
340
  busyReason = null;
330
341
  }
331
342
 
343
+ // The reboot schedule the Client applies (null: none); older Clients
344
+ // don't report one at all — then leave what's stored.
345
+ if (rebootSchedule !== undefined){
346
+ db.prepare('UPDATE resources SET reboot_schedule_applied = ? WHERE id = ?').run(
347
+ typeof rebootSchedule === 'string' && rebootSchedule ? rebootSchedule.slice(0, 200) : null,
348
+ resourceId
349
+ );
350
+ }
351
+
332
352
  const now = Date.now(),
333
353
  cleanActivity = sanitizeActivity(now, activity),
334
354
 
@@ -476,14 +496,12 @@ function createRegistryService(db, { bus, events }){
476
496
  }
477
497
 
478
498
  // `needs`: what a job brings that a Client must have opted in to run —
479
- // jobImage (firmware.image; sw.allowJobImages) and jobCommand
480
- // (tests.command; allowJobCommands).
499
+ // jobImage (the job's own Docker image; sw.allowJobImages).
481
500
  function matchesTarget(r, labels, groupId, resourceId, needs = {}){
482
501
  return labels.every((l) => r.labels.includes(l)) &&
483
502
  (!groupId || r.group_ids.includes(groupId)) &&
484
503
  (!resourceId || r.id === resourceId) &&
485
- (!needs.jobImage || r.capabilities?.sw?.allowJobImages === true) &&
486
- (!needs.jobCommand || r.capabilities?.allowJobCommands === true);
504
+ (!needs.jobImage || r.capabilities?.sw?.allowJobImages === true);
487
505
  }
488
506
 
489
507
  // Admin: remove a resource from the registry (dashboard "Remove"). Refused
@@ -521,6 +539,37 @@ function createRegistryService(db, { bus, events }){
521
539
  return db.prepare('SELECT * FROM resources WHERE remove_requested_at IS NOT NULL').all().map(rowToResource);
522
540
  }
523
541
 
542
+ // Scheduled host reboot (resource card): a cron expression, validated
543
+ // here; '' or null clears it. Delivered on the Client's next heartbeat.
544
+ function setRebootSchedule(resourceId, cron, { by } = {}){
545
+ const r = get(resourceId);
546
+ if (!r){
547
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
548
+ }
549
+ const text = typeof cron === 'string' ? cron.trim().replace(/\s+/g, ' ') : '';
550
+ if (text){
551
+ try {
552
+ parseCron(text);
553
+ }
554
+ catch (err){
555
+ throw Object.assign(new Error(`Invalid reboot schedule "${text}": ${err.message}`), { status: 400 });
556
+ }
557
+ }
558
+ db.prepare('UPDATE resources SET reboot_schedule = ? WHERE id = ?').run(text || null, resourceId);
559
+ events.record('resource', resourceId, 'resource.reboot_schedule', { cron: text || null, by });
560
+ return get(resourceId);
561
+ }
562
+
563
+ // What a heartbeat reply must tell the Client, if its applied schedule
564
+ // isn't the saved one: { cron } (null = clear). Repeated until it reports it.
565
+ function pendingRebootSchedule(resourceId){
566
+ const r = get(resourceId);
567
+ if (!r || (r.reboot_schedule || null) === (r.reboot_schedule_applied || null)){
568
+ return null;
569
+ }
570
+ return { cron: r.reboot_schedule || null };
571
+ }
572
+
524
573
  function findIdleCandidates(type, labels, groupId, resourceId, needs){
525
574
  const rows = db
526
575
  .prepare('SELECT * FROM resources WHERE status = ? AND type = ? AND remove_requested_at IS NULL')
@@ -552,6 +601,8 @@ function createRegistryService(db, { bus, events }){
552
601
  pendingUpdate,
553
602
  assignToJob,
554
603
  remove,
604
+ setRebootSchedule,
605
+ pendingRebootSchedule,
555
606
  requestRemoval,
556
607
  pendingRemovals,
557
608
  findIdleCandidates,
@@ -17,7 +17,7 @@ const { JOB_STATES, BUSY_SOURCES } = require('@andrian.yablonskyy/thub-common');
17
17
 
18
18
  // What a job needs a Client to have opted in to (registry matchesTarget).
19
19
  function jobNeeds(spec){
20
- return { jobImage: Boolean(spec.firmware?.image), jobCommand: Boolean(spec.tests?.command) };
20
+ return { jobImage: Boolean(spec.image) };
21
21
  }
22
22
 
23
23
  function rowToJob(row){
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,35 @@ function createWebRouter({ services, config }){
405
412
  res.redirect(returnTo(req, '/resources'));
406
413
  });
407
414
 
415
+ // Reboot now (resource card footer): cancels a running job, then the
416
+ // Client reboots its host on its next heartbeat (jobs.requestReboot).
417
+ router.post('/resources/:id/reboot', requireAdminRole, (req, res) => {
418
+ try {
419
+ const { resource: r, canceledJob } = services.jobs.requestReboot(req.params.id, { by: req.session.user.username });
420
+ flash(req, 'warning', `Rebooting ${r.name}'s host on its next heartbeat.` + (canceledJob ? `\nCanceled its running job ${canceledJob}.` : ''));
421
+ }
422
+ catch (err){
423
+ flash(req, 'danger', err.message);
424
+ }
425
+ res.redirect(returnTo(req, '/resources'));
426
+ });
427
+
428
+ // Scheduled host reboot (resource card): save a cron expression, or clear
429
+ // it; the Client applies it on its next heartbeat.
430
+ router.post('/resources/:id/reboot-schedule', requireAdminRole, (req, res) => {
431
+ try {
432
+ const cron = req.body.clear === '1' ? '' : req.body.cron,
433
+ r = services.registry.setRebootSchedule(req.params.id, cron, { by: req.session.user.username });
434
+ flash(req, 'success', r.reboot_schedule
435
+ ? `Reboot schedule for ${r.name} saved: ${r.reboot_schedule}.\nThe Client applies it on its next heartbeat.`
436
+ : `Scheduled reboot for ${r.name} cleared.\nThe Client applies it on its next heartbeat.`);
437
+ }
438
+ catch (err){
439
+ flash(req, 'danger', err.message);
440
+ }
441
+ res.redirect(returnTo(req, '/resources'));
442
+ });
443
+
408
444
  router.post('/resources/:id/rotate-token', requireAdminRole, (req, res) => {
409
445
  const { generateToken, hashToken } = require('../services/tokens');
410
446
  const token = generateToken('res');
@@ -55,7 +55,7 @@ function setup(jobRetention = 'forever'){
55
55
 
56
56
  // A job that finished at `finishedAt` (or is still queued, if null), with a log line.
57
57
  function job(finishedAt){
58
- const spec = { target: { type: 'sw' }, firmware: { url: 'https://x/a' }, tests: { url: 'https://x/t' } },
58
+ const spec = { target: { type: 'sw' }, command: './run.sh' },
59
59
  j = jobs.create({ agentId: agent.id, source: 'cli', spec });
60
60
  db.prepare('INSERT INTO job_logs (job_id, seq, ts, stream, line) VALUES (?, 1, ?, \'runner\', ?)').run(j.id, NOW.toISOString(), 'x'.repeat(2000));
61
61
  if (finishedAt){
@@ -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
+ });
@@ -74,7 +74,7 @@ test('jobs.page sorts and pages in SQL, with empty values last', () => {
74
74
  jobs.create({
75
75
  agentId: agent.id,
76
76
  source: 'cli',
77
- spec: { target: { type: 'sw' }, firmware: { url: 'https://x/a' }, tests: { url: 'https://x/t' }, ...(user ? { user } : {}) }
77
+ spec: { target: { type: 'sw' }, command: './run.sh', ...(user ? { user } : {}) }
78
78
  }).id),
79
79
 
80
80
  byUser = (dir) => jobs.page({ sort: 'user', dir, size: 'all' }).rows.map((j) => j.spec.user ?? null);
@@ -57,7 +57,7 @@ function buildTestServices(overrides = {}){
57
57
  jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
58
58
  scheduler = createScheduler(db, { bus, events, registry, config }),
59
59
  heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config });
60
- return { db, registry, agents, groups, artifacts, jobs, scheduler, heartbeatMonitor, artifactsDir };
60
+ return { db, bus, registry, agents, groups, artifacts, jobs, scheduler, heartbeatMonitor, artifactsDir };
61
61
  }
62
62
 
63
63
  function registerResource(registry, { name, type, labels = [], groups = [], clientId } = {}){
@@ -68,8 +68,7 @@ function registerResource(registry, { name, type, labels = [], groups = [], clie
68
68
  function makeSpec(overrides = {}){
69
69
  return {
70
70
  target: { type: 'sw', labels: [] },
71
- firmware: { url: 'https://x/app.bin' },
72
- tests: { url: 'https://x/tests.tar.gz' },
71
+ command: './run.sh',
73
72
  ...overrides
74
73
  };
75
74
  }
@@ -528,7 +527,7 @@ test('a removed Client re-registers as a fresh resource under the same name', ()
528
527
  test('a job-supplied Docker image only goes to SW Clients that allow it; otherwise 422', () => {
529
528
  const { registry, agents, jobs, scheduler } = buildTestServices(),
530
529
  { agent } = agents.create({ name: 'dev', kind: 'cli' }),
531
- imageJob = { target: { type: 'sw', labels: [] }, firmware: { image: 'alpine' }, tests: { url: 'https://x/t.tar.gz' } },
530
+ imageJob = { target: { type: 'sw', labels: [] }, image: 'alpine', command: './run.sh' },
532
531
  strict = registerResource(registry, { name: 'sw-strict', type: 'sw' });
533
532
  registry.heartbeat(strict.id, { state: 'idle' });
534
533
 
@@ -544,25 +543,79 @@ test('a job-supplied Docker image only goes to SW Clients that allow it; otherwi
544
543
 
545
544
  const fileJob = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
546
545
  scheduler.runPass();
547
- assert.equal(jobs.get(fileJob.id).resource_id, strict.id); // firmware URLs still go anywhere
546
+ assert.equal(jobs.get(fileJob.id).resource_id, strict.id); // jobs without an image still go anywhere
548
547
  });
549
548
 
550
- test('a job-supplied command only goes to Clients with allowJobCommands; otherwise 422 naming it', () => {
549
+ test('every job carries its command and runs on any matching Client; an old-Agent spec is refused', () => {
551
550
  const { registry, agents, jobs, scheduler } = buildTestServices(),
552
551
  { agent } = agents.create({ name: 'dev', kind: 'cli' }),
553
- commandJob = makeSpec({ tests: { url: 'https://x/t.tar.gz', command: 'make test' } }),
554
- plain = registerResource(registry, { name: 'sw-plain', type: 'sw' });
555
- registry.heartbeat(plain.id, { state: 'idle' });
552
+ r = registerResource(registry, { name: 'sw-any', type: 'sw' });
553
+ registry.heartbeat(r.id, { state: 'idle' });
556
554
 
557
- assert.throws(() => jobs.create({ agentId: agent.id, source: 'cli', spec: commandJob }), /allowJobCommands/);
555
+ const job = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ command: 'make test', args: ['-j4'] }) });
556
+ scheduler.runPass();
557
+ assert.equal(jobs.get(job.id).resource_id, r.id);
558
+ assert.equal(jobs.get(job.id).spec.command, 'make test');
558
559
 
559
- const { resourceId } = registry.registerAuto({
560
- clientId: 'c-cmd', name: 'sw-cmd', type: 'sw', labels: [], capabilities: { sw: { image: 'emu' }, allowJobCommands: true }
561
- });
562
- registry.heartbeat(resourceId, { state: 'idle' });
563
- const job = jobs.create({ agentId: agent.id, source: 'cli', spec: commandJob });
560
+ assert.throws(
561
+ () => jobs.create({ agentId: agent.id, source: 'cli', spec: { target: { type: 'sw' }, firmware: { url: 'https://x/a' }, tests: { url: 'https://x/t' } } }),
562
+ /older Agent.*--command/
563
+ );
564
+ assert.throws(() => jobs.create({ agentId: agent.id, source: 'cli', spec: { target: { type: 'sw' } } }), /command/);
565
+ });
566
+
567
+ test('reboot schedule: validated, then resent on heartbeats until the Client reports applying it', () => {
568
+ const { registry } = buildTestServices(),
569
+ r = registerResource(registry, { name: 'lab-reboot', type: 'sw' });
570
+
571
+ assert.throws(() => registry.setRebootSchedule(r.id, '61 3 * * *'), /Invalid reboot schedule.*minute: 61/);
572
+ assert.equal(registry.pendingRebootSchedule(r.id), null); // nothing set, nothing applied
573
+
574
+ registry.setRebootSchedule(r.id, ' 30 3 * * sun ');
575
+ assert.deepEqual(registry.pendingRebootSchedule(r.id), { cron: '30 3 * * sun' });
576
+ registry.heartbeat(r.id, { state: 'idle' }); // an older Client: reports nothing
577
+ assert.deepEqual(registry.pendingRebootSchedule(r.id), { cron: '30 3 * * sun' });
578
+ registry.heartbeat(r.id, { state: 'idle', rebootSchedule: '30 3 * * sun' });
579
+ assert.equal(registry.pendingRebootSchedule(r.id), null);
580
+
581
+ registry.setRebootSchedule(r.id, '');
582
+ assert.deepEqual(registry.pendingRebootSchedule(r.id), { cron: null }); // tell it to clear
583
+ registry.heartbeat(r.id, { state: 'idle', rebootSchedule: null });
584
+ assert.equal(registry.pendingRebootSchedule(r.id), null);
585
+ });
586
+
587
+ test('reboot now: refused offline or without a helper; cancels a running job first, then sends reboot', () => {
588
+ const { bus, registry, agents, jobs, scheduler } = buildTestServices(),
589
+ { agent } = agents.create({ name: 'dev', kind: 'cli' }),
590
+ reg = (name, rebootSupported) => registry.get(registry.registerAuto({
591
+ clientId: `c-${name}`, name, type: 'sw', labels: [], capabilities: { sw: { image: 'emu' }, rebootSupported }
592
+ }).resourceId),
593
+ offline = reg('rb-offline', true),
594
+ noHelper = reg('rb-nohelper', false),
595
+ idle = reg('rb-idle', true),
596
+ busy = reg('rb-busy', true),
597
+ commands = [];
598
+ for (const r of [noHelper, idle, busy]){
599
+ registry.heartbeat(r.id, { state: 'idle' });
600
+ }
601
+ bus.on('command', (c) => commands.push(c));
602
+
603
+ assert.throws(() => jobs.requestReboot(offline.id), /offline/);
604
+ assert.throws(() => jobs.requestReboot(noHelper.id), /no reboot helper/);
605
+
606
+ assert.equal(jobs.requestReboot(idle.id).canceledJob, null);
607
+ assert.deepEqual(commands.map((c) => [c.resourceId, c.command]), [[idle.id, 'reboot']]);
608
+ commands.length = 0;
609
+
610
+ const job = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', client: busy.id } }) });
564
611
  scheduler.runPass();
565
- assert.equal(jobs.get(job.id).resource_id, resourceId);
612
+ jobs.setState(job.id, JOB_STATES.RUNNING);
613
+ assert.equal(jobs.requestReboot(busy.id, { by: 'admin' }).canceledJob, job.id);
614
+ assert.deepEqual(commands.map((c) => c.command), ['cancel-job', 'reboot']); // same heartbeat reply, in order
615
+ assert.equal(commands[1].reason, 'user reboot request by admin');
616
+ const after = jobs.get(job.id);
617
+ assert.equal(after.state, JOB_STATES.CANCELED);
618
+ assert.equal(after.message, 'Canceled: user reboot request');
566
619
  });
567
620
 
568
621
  test('a resource can belong to several groups at once', () => {
@@ -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,123 @@ 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
+ if user.role === 'admin'
341
+ .modal-footer
342
+ +rebootButton(r, returnTo)
343
+
344
+ //- Reboot the Client's host now (card footer): confirmed first — naming the
345
+ //- job that gets canceled, if one is running — then sent with the Client's
346
+ //- next heartbeat. Disabled while it's offline or has no reboot helper.
347
+ mixin rebootButton(r, returnTo)
348
+ - const offline = r.status === 'OUT_OF_SERVICE' || !r.last_heartbeat_at
349
+ - const unsupported = r.capabilities && r.capabilities.rebootSupported === false
350
+ - const why = offline ? 'Offline — a Client can only be rebooted while connected' : unsupported ? "This host has no reboot helper — reinstall the Client as root" : null
351
+ if why
352
+ span.d-inline-block(tabindex="0" data-bs-toggle="tooltip" data-bs-title=why)
353
+ button.btn.btn-outline-danger(type="button" disabled style="pointer-events: none;")
354
+ i.bi.bi-bootstrap-reboot.me-1
355
+ | Reboot
356
+ else
357
+ form(
358
+ method="post"
359
+ action=`/resources/${r.id}/reboot`
360
+ data-confirm=r.activeJob
361
+ ? `${r.name} is running job ${r.activeJob.id}. Rebooting cancels that job (reason: user reboot request), then reboots the host on the Client's next heartbeat. Every Client instance on that host goes down with it.`
362
+ : `Reboot the host of ${r.name}? It reboots on the Client's next heartbeat, once nothing on the host is busy. Every Client instance on that host goes down with it.`
363
+ data-confirm-title=r.activeJob ? `Cancel job ${r.activeJob.id} and reboot?` : `Reboot ${r.name}?`
364
+ data-confirm-ok=r.activeJob ? 'Cancel job and reboot' : 'Reboot'
365
+ data-confirm-dismiss="Don't reboot"
366
+ )
367
+ input(type="hidden" name="returnTo" value=returnTo)
368
+ button.btn.btn-outline-danger(type="submit")
369
+ i.bi.bi-bootstrap-reboot.me-1
370
+ | Reboot
371
+
372
+ //- The resource card's Details tab.
373
+ mixin resourceDetails(r, groupsById, returnTo, local, external)
374
+ dl.row.mb-0
375
+ dt.col-sm-4 Name
376
+ dd.col-sm-8= r.name
377
+ dt.col-sm-4 Type
378
+ dd.col-sm-8.text-uppercase= r.type
379
+ dt.col-sm-4 Status
380
+ dd.col-sm-8: +resourceBadge(r.status, r.remove_requested_at)
381
+ +uptimeRows(r, returnTo)
382
+ dt.col-sm-4 Client version
383
+ 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')
384
+ dt.col-sm-4 IP addresses
385
+ dd.col-sm-8
386
+ if !local.length && !external
387
+ span.text-body-secondary —
388
+ ul.list-unstyled.mb-0
389
+ each a in local
390
+ li
391
+ code= a.address
392
+ span.text-body-secondary.small.ms-2= a.iface ? `${a.iface} · ${a.family}` : a.family
393
+ if external
394
+ li
395
+ code= external
396
+ span.text-body-secondary.small.ms-2(
397
+ data-bs-toggle="tooltip"
398
+ data-bs-title="The address the Coordinator last saw this Client connect from"
399
+ ) external
400
+ dt.col-sm-4 Capabilities
401
+ dd.col-sm-8.resource-capabilities
402
+ +capabilitiesList(r.capabilities)
403
+ dt.col-sm-4 Labels
404
+ dd.col-sm-8
405
+ if r.labels.length
406
+ each l in r.labels
407
+ span.badge.text-bg-light.border.me-1= l
408
+ else
409
+ span.text-body-secondary —
410
+ dt.col-sm-4 Groups
411
+ dd.col-sm-8
412
+ if r.group_ids.length
413
+ each gid in r.group_ids
414
+ span.badge.text-bg-info.me-1(data-bs-toggle="tooltip" data-bs-title=gid)= groupsById[gid] ? groupsById[gid].name : `(unknown: ${gid})`
415
+ else
416
+ span.text-body-secondary —
417
+ dt.col-sm-4 Busy source / reason
418
+ dd.col-sm-8= r.busy_source ? `${r.busy_source}${r.busy_reason ? ' — ' + r.busy_reason : ''}` : '—'
419
+ dt.col-sm-4 Last heartbeat
420
+ dd.col-sm-8= fmtDate(r.last_heartbeat_at, 'never')
421
+ if user.role === 'admin'
422
+ dt.col-sm-4 Actions
423
+ dd.col-sm-8.mb-0
424
+ +resourceActions(r, returnTo)