@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 +1 -1
- package/public/css/thub.css +15 -10
- package/src/api/admin.js +20 -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/agents.js +19 -1
- package/src/services/registry.js +60 -5
- package/src/web/routes.js +63 -6
- package/test/agents.test.js +38 -0
- package/test/dev-mode.test.js +63 -0
- package/test/scheduler.test.js +20 -0
- package/views/admin/agents.pug +25 -4
- package/views/jobs/show.pug +3 -3
- package/views/layout.pug +4 -3
- package/views/mixins/resource.pug +160 -69
- package/views/mixins/updates.pug +15 -2
- package/views/resources/list.pug +1 -1
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.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": {
|
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,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.
|
|
54
|
-
vertical-align:
|
|
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);
|
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
|
+
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}`);
|
package/src/services/agents.js
CHANGED
|
@@ -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 };
|
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){
|
|
@@ -73,7 +73,15 @@ function sanitizeCapabilities(caps){
|
|
|
73
73
|
return null;
|
|
74
74
|
}
|
|
75
75
|
const typed = sanitizeTypedCapabilities(caps);
|
|
76
|
-
return typed
|
|
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 = {
|
|
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 ?
|
|
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)
|
|
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.
|
|
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 })}
|
|
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
|
+
});
|
package/test/scheduler.test.js
CHANGED
|
@@ -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' }),
|
package/views/admin/agents.pug
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
38
|
-
.
|
|
39
|
-
|
|
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
|
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)
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
//-
|
|
6
|
-
|
|
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
|
-
//-
|
|
212
|
-
//-
|
|
213
|
-
//-
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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)
|
package/views/mixins/updates.pug
CHANGED
|
@@ -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
|
|
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
|
package/views/resources/list.pug
CHANGED
|
@@ -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: +
|
|
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
|