@andrian.yablonskyy/thub-coordinator 1.0.25 → 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.25",
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,17 +39,22 @@
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;
48
+ }
49
+
50
+ /* Toast text (layout.pug): "\n" in a flash message is a line break, and a
51
+ long unbroken token/URL wraps instead of overflowing the toast. */
52
+ .thub-toast-text {
53
+ white-space: pre-line;
54
+ overflow-wrap: anywhere;
55
+ min-width: 0;
56
+ }
57
+
58
+ .resource-capabilities {
59
+ font-size: 0.75rem;
55
60
  }
package/src/api/admin.js CHANGED
@@ -37,11 +37,31 @@ function createAdminRouter({ services }){
37
37
  }
38
38
  });
39
39
 
40
+ router.post('/agents/:id', (req, res, next) => {
41
+ try {
42
+ res.json(services.agents.rename(req.params.id, req.body?.name));
43
+ }
44
+ catch (err){
45
+ next(err);
46
+ }
47
+ });
48
+
40
49
  router.delete('/agents/:id', (req, res) => {
41
50
  services.agents.revoke(req.params.id);
42
51
  res.status(204).end();
43
52
  });
44
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
+
45
65
  router.post('/resources/:id/maintenance', (req, res, next) => {
46
66
  try {
47
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}`);
@@ -42,6 +42,24 @@ function createAgentsService(db, { events }){
42
42
  return { agent: get(id), token };
43
43
  }
44
44
 
45
+ // Dashboard "Edit": the name is only a label (the token is what
46
+ // authenticates), so renaming never affects anything using the agent.
47
+ function rename(id, name){
48
+ const agent = get(id),
49
+ trimmed = typeof name === 'string' ? name.trim() : '';
50
+ if (!agent){
51
+ throw Object.assign(new Error('Unknown agent'), { status: 404 });
52
+ }
53
+ if (!trimmed || trimmed.length > 100){
54
+ throw Object.assign(new Error('Agent name must be 1-100 characters'), { status: 400 });
55
+ }
56
+ if (trimmed !== agent.name){
57
+ db.prepare('UPDATE agents SET name = ? WHERE id = ?').run(trimmed, id);
58
+ events.record('agent', id, 'agent.renamed', { from: agent.name, to: trimmed });
59
+ }
60
+ return get(id);
61
+ }
62
+
45
63
  function revoke(id){
46
64
  db.prepare('UPDATE agents SET revoked_at = ? WHERE id = ?').run(new Date().toISOString(), id);
47
65
  events.record('agent', id, 'agent.revoked', {});
@@ -79,7 +97,7 @@ function createAgentsService(db, { events }){
79
97
  return ids.length;
80
98
  }
81
99
 
82
- return { get, getByTokenHash, list, create, revoke, touchLastUsed, setUpdateTo, requestUpdateAll };
100
+ return { get, getByTokenHash, list, create, rename, revoke, touchLastUsed, setUpdateTo, requestUpdateAll };
83
101
  }
84
102
 
85
103
  module.exports = { createAgentsService };
@@ -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');
@@ -125,6 +126,14 @@ function createWebRouter({ services, config }){
125
126
  return resources.map((r) => ({ ...r, activeJob: services.jobs.activeForResource(r.id) }));
126
127
  }
127
128
 
129
+ // Express 5 leaves req.body undefined for a POST without a form body (a
130
+ // browser always sends one; curl or a script may not) — treat it as an
131
+ // empty form rather than crashing every handler that reads a field.
132
+ router.use((req, res, next) => {
133
+ req.body ??= {};
134
+ next();
135
+ });
136
+
128
137
  router.use((req, res, next) => {
129
138
  const user = req.session?.user || null;
130
139
  res.locals.user = user;
@@ -144,6 +153,8 @@ function createWebRouter({ services, config }){
144
153
  ];
145
154
  }
146
155
  res.locals.isNewer = isNewer;
156
+ res.locals.nextCronRun = nextCronRun;
157
+ res.locals.formatDateTime = formatDateTime;
147
158
  res.locals.currentPath = req.originalUrl;
148
159
  if (req.session){
149
160
  req.session.flash = [];
@@ -357,6 +368,10 @@ function createWebRouter({ services, config }){
357
368
  services.commands.push(r.id, { command: 'unlock' });
358
369
  flash(req, 'warning', `Releasing the local lock on ${r.name} (on its next heartbeat).`);
359
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
+ }
360
375
  else if (activity === 'update-hold'){
361
376
  services.commands.push(r.id, { command: 'cancel-update' });
362
377
  if (r.update_to){
@@ -367,7 +382,7 @@ function createWebRouter({ services, config }){
367
382
  else {
368
383
  flash(req, 'info', `${r.name} isn't doing anything to cancel.`);
369
384
  }
370
- if (!job && ['locked', 'update-hold'].includes(activity)){
385
+ if (!job && ['locked', 'update-hold', 'reboot-hold'].includes(activity)){
371
386
  services.events.record('resource', r.id, 'resource.activity_canceled', { activity, by: req.session.user.username });
372
387
  }
373
388
  }
@@ -388,7 +403,7 @@ function createWebRouter({ services, config }){
388
403
  'warning',
389
404
  pending
390
405
  ? `Stopped job ${stoppedJob} on ${r.name}; ${r.name} is removed as soon as its Client confirms the job ended.`
391
- : `Removed ${r.name}.` + (canceledJobs.length ? ` Canceled job(s) queued for it: ${canceledJobs.join(', ')}.` : '')
406
+ : `Removed ${r.name}.` + (canceledJobs.length ? `\nCanceled job(s) queued for it: ${canceledJobs.join(', ')}.` : '')
392
407
  );
393
408
  }
394
409
  catch (err){
@@ -397,13 +412,29 @@ function createWebRouter({ services, config }){
397
412
  res.redirect(returnTo(req, '/resources'));
398
413
  });
399
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
+
400
431
  router.post('/resources/:id/rotate-token', requireAdminRole, (req, res) => {
401
432
  const { generateToken, hashToken } = require('../services/tokens');
402
433
  const token = generateToken('res');
403
434
  services.db.prepare('UPDATE resources SET token_hash = ? WHERE id = ?').run(hashToken(token), req.params.id);
404
435
  services.events.record('resource', req.params.id, 'resource.token_rotated', {});
405
436
  // Sticky: shown only once, so it mustn't close before it's copied.
406
- flash(req, 'warning', `New resource token (copy it now): ${token}`, { sticky: true });
437
+ flash(req, 'warning', `New resource token (copy it now):\n${token}`, { sticky: true });
407
438
  res.redirect(returnTo(req, '/resources'));
408
439
  });
409
440
 
@@ -491,8 +522,20 @@ function createWebRouter({ services, config }){
491
522
  }));
492
523
 
493
524
  router.post('/resources/:id/update', requireAdminRole, updateAction(async (req) => {
525
+ // Cancel (next to the version): withdraw the request and, if the host is
526
+ // already holding for the update, abort that too (the Client's next
527
+ // heartbeat) — the one control for canceling a Client update.
494
528
  if (req.body.cancel === '1'){
495
- services.registry.setUpdateTo(req.params.id, null);
529
+ const r = services.registry.get(req.params.id);
530
+ if (!r){
531
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
532
+ }
533
+ services.registry.setUpdateTo(r.id, null);
534
+ if (r.activity?.state === 'update-hold'){
535
+ services.commands.push(r.id, { command: 'cancel-update' });
536
+ services.events.record('resource', r.id, 'resource.activity_canceled', { activity: 'update-hold', by: req.session.user.username });
537
+ return { type: 'warning', text: `Canceling the self-update on ${r.name}'s host (on its next heartbeat).` };
538
+ }
496
539
  return 'Update request canceled.';
497
540
  }
498
541
  const version = await services.updates.targetVersion('client');
@@ -568,7 +611,7 @@ function createWebRouter({ services, config }){
568
611
  req,
569
612
  'warning',
570
613
  `Deleted ${r.jobs} finished job(s) and ${r.events} history record(s) from before ` +
571
- `${formatDateTime(r.before, { timeZone: tz })}. Database: ${mb(r.bytesBefore)} → ${mb(r.bytesAfter)}.`
614
+ `${formatDateTime(r.before, { timeZone: tz })}.\nDatabase: ${mb(r.bytesBefore)} → ${mb(r.bytesAfter)}.`
572
615
  );
573
616
  }
574
617
  catch (err){
@@ -661,6 +704,20 @@ function createWebRouter({ services, config }){
661
704
  res.render('admin/agents', { title: 'Agents', active: 'agents', agents: services.agents.list(), newToken: token });
662
705
  });
663
706
 
707
+ router.post('/admin/agents/:id/rename', requireAdminRole, (req, res) => {
708
+ try {
709
+ const before = services.agents.get(req.params.id)?.name,
710
+ agent = services.agents.rename(req.params.id, req.body.name);
711
+ if (before !== agent.name){
712
+ flash(req, 'success', `Renamed agent "${before}" to "${agent.name}".`);
713
+ }
714
+ }
715
+ catch (err){
716
+ flash(req, 'danger', err.message);
717
+ }
718
+ res.redirect('/admin/agents');
719
+ });
720
+
664
721
  router.post('/admin/agents/:id/revoke', requireAdminRole, (req, res) => {
665
722
  services.agents.revoke(req.params.id);
666
723
  res.redirect('/admin/agents');
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file packages/coordinator/test/agents.test.js
3
+ * @description Tests: agent rename (dashboard Edit) — a label only; the token keeps working
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
+ { openDb } = require('../src/db'),
19
+ { createEventsService } = require('../src/services/events'),
20
+ { createAgentsService } = require('../src/services/agents'),
21
+ { hashToken } = require('../src/services/tokens');
22
+
23
+ test('renaming an agent changes only its name; its token keeps authenticating', () => {
24
+ const db = openDb(':memory:'),
25
+ agents = createAgentsService(db, { events: createEventsService(db) }),
26
+ { agent, token } = agents.create({ name: 'ci-old', kind: 'ci' });
27
+
28
+ assert.equal(agents.rename(agent.id, ' ci-firmware ').name, 'ci-firmware');
29
+ assert.equal(agents.getByTokenHash(hashToken(token)).id, agent.id);
30
+ assert.equal(db.prepare('SELECT COUNT(*) AS n FROM events WHERE type = \'agent.renamed\'').get().n, 1);
31
+
32
+ agents.rename(agent.id, 'ci-firmware'); // unchanged: no new event
33
+ assert.equal(db.prepare('SELECT COUNT(*) AS n FROM events WHERE type = \'agent.renamed\'').get().n, 1);
34
+
35
+ assert.throws(() => agents.rename(agent.id, ' '), /1-100 characters/);
36
+ assert.throws(() => agents.rename(agent.id, 'x'.repeat(101)), /1-100 characters/);
37
+ assert.throws(() => agents.rename('agt_nope', 'x'), /Unknown agent/);
38
+ });
@@ -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' }),
@@ -29,14 +29,20 @@ block content
29
29
  tr(class=a.revoked_at ? 'opacity-50' : '')
30
30
  td= a.name
31
31
  td.text-uppercase= a.kind
32
- td: +versionCell(a.version, updates.latest.agent, a.update_to)
32
+ //- A revoked token can't pick an update up: no button.
33
+ td: +versionWithUpdate(a.version, updates.latest.agent, a.update_to, `/admin/agents/${a.id}/update`, '/admin/agents', !a.revoked_at)
33
34
  td= fmtDate(a.created_at)
34
35
  td= fmtDate(a.last_used_at, 'never')
35
36
  td= a.revoked_at ? 'revoked' : 'active'
36
37
  td
37
- if !a.revoked_at
38
- .d-flex.justify-content-end
39
- +updateButton(`/admin/agents/${a.id}/update`, a.update_to, '/admin/agents')
38
+ .d-flex.justify-content-end
39
+ button.btn.btn-sm.btn-outline-secondary(
40
+ type="button"
41
+ data-bs-toggle="modal"
42
+ data-bs-target=`#editAgentModal-${a.id}`
43
+ aria-label=`Edit agent ${a.name}`
44
+ ) Edit
45
+ if !a.revoked_at
40
46
  form.ms-1(
41
47
  method="post"
42
48
  action=`/admin/agents/${a.id}/revoke`
@@ -46,6 +52,21 @@ block content
46
52
  )
47
53
  button.btn.btn-sm.btn-outline-danger(type="submit") Revoke
48
54
 
55
+ .modal(tabindex="-1" id=`editAgentModal-${a.id}` aria-labelledby=`editAgentTitle-${a.id}`)
56
+ .modal-dialog
57
+ .modal-content
58
+ form(method="post" action=`/admin/agents/${a.id}/rename`)
59
+ .modal-header
60
+ h5.modal-title(id=`editAgentTitle-${a.id}`) Edit agent
61
+ button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
62
+ .modal-body
63
+ label.form-label(for=`agentName-${a.id}`) Name
64
+ input.form-control(id=`agentName-${a.id}` type="text" name="name" value=a.name required maxlength="100")
65
+ .form-text Only a label — the agent's token, and everything using it, keep working.
66
+ .modal-footer
67
+ button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
68
+ button.btn.btn-primary(type="submit") Save
69
+
49
70
  .modal(tabindex="-1" id="addAgentModal")
50
71
  .modal-dialog
51
72
  .modal-content
@@ -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)
package/views/layout.pug CHANGED
@@ -134,9 +134,10 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
134
134
  data-sticky=m.sticky ? '1' : false
135
135
  )
136
136
  .d-flex
137
- .toast-body.d-flex.gap-2
138
- i.bi(class=`bi-${icon}`)
139
- span= m.text
137
+ //- Multi-line: "\n" in a message is a line break (thub.css).
138
+ .toast-body.d-flex.align-items-start.gap-2
139
+ i.bi.flex-shrink-0(class=`bi-${icon}`)
140
+ span.thub-toast-text= m.text
140
141
  button.btn-close.me-2.m-auto(
141
142
  type="button"
142
143
  class=type === 'danger' || type === 'success' ? 'btn-close-white' : ''
@@ -2,9 +2,8 @@
2
2
  //- Overview and Resources pages. `returnTo` is the page the action forms
3
3
  //- redirect back to.
4
4
 
5
- //- `opts.update: false` leaves out the Update button — the resource card
6
- //- shows it on its Client version line instead.
7
- mixin resourceActions(r, returnTo, opts = {})
5
+ //- Update isn't here: it sits next to the version (+versionWithUpdate).
6
+ mixin resourceActions(r, returnTo)
8
7
  form.d-inline(method="post" action=`/resources/${r.id}/maintenance`)
9
8
  input(type="hidden" name="enabled" value=r.status === 'MAINTENANCE' ? '0' : '1')
10
9
  input(type="hidden" name="returnTo" value=returnTo)
@@ -40,8 +39,6 @@ mixin resourceActions(r, returnTo, opts = {})
40
39
  data-bs-title="Invalidate the current resource token and issue a new one — the Client won't reconnect until it has it"
41
40
  )
42
41
  i.bi.bi-arrow-repeat
43
- if opts.update !== false
44
- +updateButton(`/resources/${r.id}/update`, r.update_to, returnTo)
45
42
  +removeResourceButton(r, returnTo)
46
43
 
47
44
  //- Remove: a plain confirm when the resource is idle; with a running job,
@@ -183,7 +180,7 @@ mixin capabilitiesList(caps)
183
180
  mixin uptimeRows(r, returnTo)
184
181
  - const stale = r.status === 'OUT_OF_SERVICE' && r.last_heartbeat_at
185
182
  - const ref = stale ? new Date(r.last_heartbeat_at).getTime() : Date.now()
186
- - 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' }
187
184
  dt.col-sm-4 Host uptime
188
185
  dd.col-sm-8
189
186
  if r.host_booted_at
@@ -208,12 +205,80 @@ mixin uptimeRows(r, returnTo)
208
205
  if user.role === 'admin'
209
206
  +cancelActivityButton(r, returnTo)
210
207
 
211
- //- Cancel the Client's current activity (its job, a local lock or a
212
- //- self-update hold) — icon only, on the card's Task / status line, and
213
- //- only while there's something to cancel.
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
+
276
+ //- Cancel the Client's current job or local lock — icon only, on the card's
277
+ //- Task / status line, and only while there's one. A self-update hold is
278
+ //- canceled from the version line instead (+versionWithUpdate).
214
279
  mixin cancelActivityButton(r, returnTo)
215
280
  - const state = r.activity && r.activity.state
216
- - const what = r.activeJob ? `job ${r.activeJob.id}` : state === 'locked' ? 'the local lock' : state === 'update-hold' ? 'the self-update' : null
281
+ - const what = r.activeJob ? `job ${r.activeJob.id}` : state === 'locked' ? 'the local lock' : state === 'reboot-hold' ? 'the scheduled reboot' : null
217
282
  if what
218
283
  form.d-inline.ms-2(
219
284
  method="post"
@@ -237,66 +302,92 @@ mixin resourceCard(r, groupsById, returnTo)
237
302
  - const local = ((r.host_info && r.host_info.addresses) || []).filter((a) => !isLoopback(a.address))
238
303
  - const external = r.remote_addr && !isLoopback(r.remote_addr) ? r.remote_addr : null
239
304
  .modal.fade.thub-resource-card(id=`resource-card-${r.id}` tabindex="-1" aria-labelledby=`resource-card-${r.id}-title` aria-hidden="true")
240
- .modal-dialog.modal-dialog-centered
305
+ .modal-dialog.modal-dialog-centered.modal-lg
241
306
  .modal-content
242
307
  .modal-header
243
308
  h5.modal-title(id=`resource-card-${r.id}-title`)= r.name
244
309
  button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
245
- .modal-body
246
- dl.row.mb-0
247
- dt.col-sm-4 Name
248
- dd.col-sm-8= r.name
249
- dt.col-sm-4 Type
250
- dd.col-sm-8.text-uppercase= r.type
251
- dt.col-sm-4 Status
252
- dd.col-sm-8: +resourceBadge(r.status, r.remove_requested_at)
253
- +uptimeRows(r, returnTo)
254
- dt.col-sm-4 Client version
255
- dd.col-sm-8
256
- +versionCell(r.client_version, updates.latest.client, r.update_to)
257
- //- Admins: Update only when a newer Client is published (or
258
- //- Cancel, while one is requested). An unknown version (too
259
- //- old to report it) can't be compared, so no button.
260
- if user.role === 'admin' && (r.update_to || (r.client_version && isNewer(updates.latest.client, r.client_version)))
261
- +updateButton(`/resources/${r.id}/update`, r.update_to, returnTo)
262
- dt.col-sm-4 IP addresses
263
- dd.col-sm-8
264
- if !local.length && !external
265
- span.text-body-secondary —
266
- ul.list-unstyled.mb-0
267
- each a in local
268
- li
269
- code= a.address
270
- span.text-body-secondary.small.ms-2= a.iface ? `${a.iface} · ${a.family}` : a.family
271
- if external
272
- li
273
- code= external
274
- span.text-body-secondary.small.ms-2(
275
- data-bs-toggle="tooltip"
276
- data-bs-title="The address the Coordinator last saw this Client connect from"
277
- ) external
278
- dt.col-sm-4 Capabilities
279
- dd.col-sm-8
280
- +capabilitiesList(r.capabilities)
281
- dt.col-sm-4 Labels
282
- dd.col-sm-8
283
- if r.labels.length
284
- each l in r.labels
285
- span.badge.text-bg-light.border.me-1= l
286
- else
287
- span.text-body-secondary —
288
- dt.col-sm-4 Groups
289
- dd.col-sm-8
290
- if r.group_ids.length
291
- each gid in r.group_ids
292
- span.badge.text-bg-info.me-1(data-bs-toggle="tooltip" data-bs-title=gid)= groupsById[gid] ? groupsById[gid].name : `(unknown: ${gid})`
293
- else
294
- span.text-body-secondary —
295
- dt.col-sm-4 Busy source / reason
296
- dd.col-sm-8= r.busy_source ? `${r.busy_source}${r.busy_reason ? ' — ' + r.busy_reason : ''}` : '—'
297
- dt.col-sm-4 Last heartbeat
298
- dd.col-sm-8= fmtDate(r.last_heartbeat_at, 'never')
299
- if user.role === 'admin'
300
- dt.col-sm-4 Actions
301
- dd.col-sm-8.mb-0
302
- +resourceActions(r, returnTo, { update: false })
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)
@@ -16,6 +16,18 @@ mixin versionCell(current, latest, updateTo)
16
16
  data-bs-title=`v${latest} is available`
17
17
  ) update available
18
18
 
19
+ //- A version cell with its Update button right next to it — shown only to
20
+ //- admins, and only when a newer version is published (or as Cancel while
21
+ //- an update is requested). An unknown version (too old to report it)
22
+ //- can't be compared, so no button; `canUpdate: false` (e.g. a revoked
23
+ //- agent) hides it too. `holding`: a Client's host is already holding for
24
+ //- a self-update — Cancel then also aborts that (the one cancel control for
25
+ //- an update). Agents table, Resources table, resource card.
26
+ mixin versionWithUpdate(current, latest, updateTo, action, returnTo, canUpdate = true, holding = false)
27
+ +versionCell(current, latest, updateTo)
28
+ if user.role === 'admin' && canUpdate && (updateTo || holding || (current && isNewer(latest, current)))
29
+ +updateButton(action, updateTo || (holding ? true : null), returnTo)
30
+
19
31
  //- Latest published version of `app` (agent | client), "check now" and,
20
32
  //- for admins, "update all".
21
33
  mixin updatesToolbar(app, label, returnTo)
@@ -47,7 +59,8 @@ mixin updatesToolbar(app, label, returnTo)
47
59
  i.bi.bi-cloud-download.me-1
48
60
  = `Update all ${label}`
49
61
 
50
- //- Per-item "Update" / "Cancel update" button.
62
+ //- Per-item "Update" / "Cancel update" button. `pending`: the requested
63
+ //- version, or true for an update already under way without one.
51
64
  mixin updateButton(action, pending, returnTo)
52
65
  form.d-inline.ms-1(method="post" action=action)
53
66
  input(type="hidden" name="returnTo" value=returnTo)
@@ -57,7 +70,7 @@ mixin updateButton(action, pending, returnTo)
57
70
  type="submit"
58
71
  aria-label="Cancel update"
59
72
  data-bs-toggle="tooltip"
60
- data-bs-title=`Cancel the pending self-update to v${pending}`
73
+ data-bs-title=pending === true ? 'Cancel the self-update in progress on this host' : `Cancel the pending self-update to v${pending}`
61
74
  )
62
75
  i.bi.bi-x-circle
63
76
  else
@@ -34,7 +34,7 @@ block content
34
34
  tr(role="button" tabindex="0" data-resource-card=`resource-card-${r.id}`)
35
35
  td= r.name
36
36
  td.text-uppercase= r.type
37
- td: +versionCell(r.client_version, updates.latest.client, r.update_to)
37
+ td: +versionWithUpdate(r.client_version, updates.latest.client, r.update_to, `/resources/${r.id}/update`, list.pageUrl(), true, r.activity && r.activity.state === 'update-hold')
38
38
  td: +resourceBadge(r.status, r.remove_requested_at)
39
39
  td
40
40
  each l in r.labels