@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 +1 -1
- package/public/css/thub.css +7 -10
- package/public/js/confirm.js +10 -1
- package/src/api/admin.js +22 -0
- package/src/api/resource.js +8 -2
- package/src/db/migrations/017_add_reboot_schedule.sql +7 -0
- package/src/dev/virtual.js +286 -0
- package/src/server.js +4 -0
- package/src/services/jobs.js +39 -16
- package/src/services/registry.js +61 -10
- package/src/services/scheduler.js +1 -1
- package/src/web/routes.js +38 -2
- package/test/cleanup.test.js +1 -1
- package/test/dev-mode.test.js +63 -0
- package/test/list-prefs.test.js +1 -1
- package/test/scheduler.test.js +69 -16
- package/views/jobs/show.pug +3 -3
- package/views/mixins/resource.pug +186 -55
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.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": {
|
package/public/css/thub.css
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#log-viewer {
|
|
2
2
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
3
|
-
font-size: 0.
|
|
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.
|
|
54
|
-
vertical-align:
|
|
46
|
+
font-size: 0.6rem;
|
|
47
|
+
vertical-align: super;
|
|
55
48
|
}
|
|
56
49
|
|
|
57
50
|
/* Toast text (layout.pug): "\n" in a flash message is a line break, and a
|
|
@@ -61,3 +54,7 @@
|
|
|
61
54
|
overflow-wrap: anywhere;
|
|
62
55
|
min-width: 0;
|
|
63
56
|
}
|
|
57
|
+
|
|
58
|
+
.resource-capabilities {
|
|
59
|
+
font-size: 0.75rem;
|
|
60
|
+
}
|
package/public/js/confirm.js
CHANGED
|
@@ -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.
|
|
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);
|
package/src/api/resource.js
CHANGED
|
@@ -84,7 +84,7 @@ function createResourceRouter({ services, config }){
|
|
|
84
84
|
|
|
85
85
|
router.post('/resources/:id/heartbeat', auth, requireOwnResource, (req, res, next) => {
|
|
86
86
|
try {
|
|
87
|
-
const { state, activeJobId, localLock, metrics, addresses, 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}`);
|
package/src/services/jobs.js
CHANGED
|
@@ -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
|
|
149
|
-
//
|
|
150
|
-
const needs = { jobImage: Boolean(spec.
|
|
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 (
|
|
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 ${
|
|
163
|
-
'
|
|
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
|
-
|
|
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,
|
package/src/services/registry.js
CHANGED
|
@@ -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
|
|
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 = {
|
|
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 (
|
|
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.
|
|
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');
|
package/test/cleanup.test.js
CHANGED
|
@@ -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' },
|
|
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
|
+
});
|
package/test/list-prefs.test.js
CHANGED
|
@@ -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' },
|
|
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);
|
package/test/scheduler.test.js
CHANGED
|
@@ -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
|
-
|
|
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: [] },
|
|
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); //
|
|
546
|
+
assert.equal(jobs.get(fileJob.id).resource_id, strict.id); // jobs without an image still go anywhere
|
|
548
547
|
});
|
|
549
548
|
|
|
550
|
-
test('
|
|
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
|
-
|
|
554
|
-
|
|
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
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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', () => {
|
package/views/jobs/show.pug
CHANGED
|
@@ -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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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)
|