@andrian.yablonskyy/thub-coordinator 1.0.15 → 1.0.17
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/README.md +3 -1
- package/package.json +1 -1
- package/public/js/toasts.js +33 -0
- package/src/services/jobs.js +13 -2
- package/src/services/registry.js +10 -8
- package/src/services/scheduler.js +15 -8
- package/src/services/updates.js +2 -1
- package/src/web/routes.js +49 -7
- package/test/scheduler.test.js +71 -1
- package/views/jobs/show.pug +3 -0
- package/views/layout.pug +39 -3
- package/views/login.pug +0 -2
package/README.md
CHANGED
|
@@ -157,11 +157,13 @@ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla J
|
|
|
157
157
|
| `/admin/agents` | Register/revoke CI and developer agents; token shown once. Shows each agent's last reported version. |
|
|
158
158
|
| `/profile` | Every logged-in user's own account settings: display name, avatar, timezone (renders every dashboard timestamp), theme, idle session timeout, password change. |
|
|
159
159
|
|
|
160
|
+
Notifications (results of dashboard actions, errors) appear as toasts in the top-right corner and close themselves by severity: **errors stay until closed**, warnings close after **30 s**, info and success after **10 s**. A one-time secret such as a rotated resource token never closes on its own.
|
|
161
|
+
|
|
160
162
|
**Resource card.** Clicking a resource on `/` or `/resources` opens a card with its name, type, status, host uptime and current task/status with its duration (both from the Client's heartbeats, anchored to the Coordinator's clock; as of the last heartbeat for an OUT_OF_SERVICE resource), Client version, IP addresses, capabilities (reported at registration: HW udev devices with a **missing** flag for any whose device node doesn't exist, relay channels and power control; SW image, source and limits), labels, groups, busy source/reason, last heartbeat and (admins only) the maintenance/rotate-token actions. IP addresses are the Client host's interface addresses except loopback (reported at registration and on every heartbeat) plus the external address — the one the Coordinator last saw the Client connect from, taken from `X-Forwarded-For` when the request came through a proxy `trustProxy` trusts. Admins get a **Cancel** button on it, enabled only while the Client is doing something: it cancels its current job, releases a manual local lock, or aborts a self-update hold on its host (the last two delivered as `unlock` / `cancel-update` heartbeat commands).
|
|
161
163
|
|
|
162
164
|
The Coordinator's own version (`ver. X.Y.Z`) is shown under the TestHub logo, top left of every page.
|
|
163
165
|
|
|
164
|
-
**Versions and self-update.** The Coordinator checks the npm registry for the latest Coordinator, Agent and Client every `updates.checkIntervalMin` (default 15 minutes, `0` disables; the older `checkIntervalHours` is ignored; `updates.registry` for a private mirror) and
|
|
166
|
+
**Versions and self-update.** The Coordinator checks the npm registry for the latest Coordinator, Agent and Client every `updates.checkIntervalMin` (default 15 minutes, `0` disables; the older `checkIntervalHours` is ignored; `updates.registry` for a private mirror) and immediately with the navbar's **check for updates** button (admins; also on the Agents and Resources pages), which reports in a toast whether the Coordinator is current and which connected resources can update their Client. A newer Coordinator shows as an **Update app to X.Y.Z** button in the navbar for admins (a badge under the logo for viewers); Agents and Resources show each one's version with an **update available** or pending **→ vX.Y.Z** badge. Admins request self-updates per agent/resource (**Update**, which becomes **Cancel update**) or with **Update all agents** / **Update all clients**, always to the latest version:
|
|
165
167
|
- Clients get a `self-update` command on their next heartbeat and install it through their root `thub-client-update` helper, once none of the host's instances is busy.
|
|
166
168
|
- Agents install it at the start of their next run (`GET /api/v1/agents/me/update`), then re-run the command on the new version.
|
|
167
169
|
- The Coordinator itself: the **Update app** button writes `<dataDir>/update-request.json`; the root `thub-coordinator-update.path` unit (installed by `sudo npm i -g`) runs `npm i -g` for it, and the postinstall restarts the service (the dashboard and API are briefly down). Logs: `journalctl -u thub-coordinator-update`. Without that unit, use `thub-admin self-update` on the host.
|
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.17",
|
|
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": {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/public/js/toasts.js
|
|
3
|
+
* @description Dashboard: shows flash messages as Bootstrap toasts that close themselves by severity
|
|
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
|
+
(function (){
|
|
17
|
+
// Auto-close delay per severity; null = stays until closed. Errors never
|
|
18
|
+
// close on their own, and neither does a toast marked sticky (e.g. a
|
|
19
|
+
// one-time token the user still has to copy).
|
|
20
|
+
const DELAY_MS = { danger: null, warning: 30_000, info: 10_000, success: 10_000 };
|
|
21
|
+
|
|
22
|
+
document.querySelectorAll('[data-thub-toast]').forEach((el) => {
|
|
23
|
+
const type = el.dataset.thubToast in DELAY_MS ? el.dataset.thubToast : 'info',
|
|
24
|
+
delay = el.dataset.sticky ? null : DELAY_MS[type],
|
|
25
|
+
toast = new bootstrap.Toast(el, { autohide: delay !== null, delay: delay ?? 0 });
|
|
26
|
+
// "Self-destroying": gone from the page once hidden, not just invisible.
|
|
27
|
+
el.addEventListener('hidden.bs.toast', () => {
|
|
28
|
+
toast.dispose();
|
|
29
|
+
el.remove();
|
|
30
|
+
});
|
|
31
|
+
toast.show();
|
|
32
|
+
});
|
|
33
|
+
})();
|
package/src/services/jobs.js
CHANGED
|
@@ -91,11 +91,22 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
91
91
|
}
|
|
92
92
|
spec.source = source;
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
// `target.client` (thub run --client) accepts a resource id or name;
|
|
95
|
+
// store the id so the job stays pinned to the same Client across renames.
|
|
96
|
+
if (spec.target.client){
|
|
97
|
+
const resource = registry.get(spec.target.client) || registry.getByName(spec.target.client);
|
|
98
|
+
if (!resource){
|
|
99
|
+
throw Object.assign(new Error(`Unknown client "${spec.target.client}"`), { status: 422 });
|
|
100
|
+
}
|
|
101
|
+
spec.target.client = resource.id;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!registry.everSatisfiable(spec.target.type, spec.target.labels, spec.target.group, spec.target.client)){
|
|
95
105
|
throw Object.assign(
|
|
96
106
|
new Error(
|
|
97
107
|
`No registered resource can ever satisfy type=${spec.target.type} labels=${spec.target.labels.join(',')}` +
|
|
98
|
-
(spec.target.group ? ` group=${spec.target.group}` : '')
|
|
108
|
+
(spec.target.group ? ` group=${spec.target.group}` : '') +
|
|
109
|
+
(spec.target.client ? ` client=${spec.target.client}` : '')
|
|
99
110
|
),
|
|
100
111
|
{ status: 422 }
|
|
101
112
|
);
|
package/src/services/registry.js
CHANGED
|
@@ -468,21 +468,23 @@ function createRegistryService(db, { bus, events }){
|
|
|
468
468
|
);
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
-
function
|
|
471
|
+
function matchesTarget(r, labels, groupId, resourceId){
|
|
472
|
+
return labels.every((l) => r.labels.includes(l)) &&
|
|
473
|
+
(!groupId || r.group_ids.includes(groupId)) &&
|
|
474
|
+
(!resourceId || r.id === resourceId);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function findIdleCandidates(type, labels, groupId, resourceId){
|
|
472
478
|
const rows = db
|
|
473
479
|
.prepare('SELECT * FROM resources WHERE status = ? AND type = ?')
|
|
474
480
|
.all(RESOURCE_STATES.IDLE, type)
|
|
475
481
|
.map(rowToResource);
|
|
476
|
-
return rows.filter(
|
|
477
|
-
(r) => labels.every((l) => r.labels.includes(l)) && (!groupId || r.group_ids.includes(groupId))
|
|
478
|
-
);
|
|
482
|
+
return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
479
483
|
}
|
|
480
484
|
|
|
481
|
-
function everSatisfiable(type, labels, groupId){
|
|
485
|
+
function everSatisfiable(type, labels, groupId, resourceId){
|
|
482
486
|
const rows = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource);
|
|
483
|
-
return rows.some(
|
|
484
|
-
(r) => labels.every((l) => r.labels.includes(l)) && (!groupId || r.group_ids.includes(groupId))
|
|
485
|
-
);
|
|
487
|
+
return rows.some((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
486
488
|
}
|
|
487
489
|
|
|
488
490
|
return {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file packages/coordinator/src/services/scheduler.js
|
|
3
|
-
* @description Scheduler: matches queued jobs to idle resources by type/labels/group (README §5.4)
|
|
3
|
+
* @description Scheduler: matches queued jobs to idle resources by type/labels/group/client (README §5.4)
|
|
4
4
|
*
|
|
5
5
|
* @author Andrian Yablonskyy
|
|
6
6
|
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
@@ -34,21 +34,28 @@ function createScheduler(db, { bus, events, registry, config }){
|
|
|
34
34
|
.map(rowToJob),
|
|
35
35
|
|
|
36
36
|
assignments = [],
|
|
37
|
-
claimedResourceIds = new Set()
|
|
37
|
+
claimedResourceIds = new Set(),
|
|
38
|
+
// Resources that some queued job is pinned to (`target.client`) —
|
|
39
|
+
// an unpinned job only takes one of these when nothing else fits,
|
|
40
|
+
// so it doesn't jump the pinned job's per-client queue.
|
|
41
|
+
reservedResourceIds = new Set(queued.map((j) => j.spec.target.client).filter(Boolean));
|
|
38
42
|
|
|
39
43
|
for (const job of queued){
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
const { type, labels = [], group, client } = job.spec.target,
|
|
45
|
+
candidates = registry
|
|
46
|
+
.findIdleCandidates(type, labels, group, client)
|
|
47
|
+
.filter((r) => !claimedResourceIds.has(r.id));
|
|
43
48
|
if (candidates.length === 0){
|
|
44
49
|
continue;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
|
-
//
|
|
52
|
+
// Unreserved first, then least-recently-used: spread wear across benches (§5.4).
|
|
48
53
|
candidates.sort((a, b) => {
|
|
49
|
-
const
|
|
54
|
+
const ra = reservedResourceIds.has(a.id) ? 1 : 0,
|
|
55
|
+
rb = reservedResourceIds.has(b.id) ? 1 : 0,
|
|
56
|
+
ta = a.last_job_finished_at ? new Date(a.last_job_finished_at).getTime() : 0,
|
|
50
57
|
tb = b.last_job_finished_at ? new Date(b.last_job_finished_at).getTime() : 0;
|
|
51
|
-
return ta - tb;
|
|
58
|
+
return ra - rb || ta - tb;
|
|
52
59
|
});
|
|
53
60
|
const resource = candidates[0];
|
|
54
61
|
claimedResourceIds.add(resource.id);
|
package/src/services/updates.js
CHANGED
|
@@ -46,7 +46,8 @@ function createUpdatesService({ config }){
|
|
|
46
46
|
state.latest = { ...state.latest, ...latest };
|
|
47
47
|
state.checkedAt = new Date().toISOString();
|
|
48
48
|
state.error = errors.length ? errors.join('; ') : null;
|
|
49
|
-
|
|
49
|
+
// What this check itself got — `latest` keeps earlier results.
|
|
50
|
+
return { ...status(), fetched: Object.keys(latest) };
|
|
50
51
|
})().finally(() => {
|
|
51
52
|
inFlight = null;
|
|
52
53
|
});
|
package/src/web/routes.js
CHANGED
|
@@ -40,9 +40,11 @@ const AVATAR_MAX_BYTES = 2 * 1024 * 1024,
|
|
|
40
40
|
function createWebRouter({ services, config }){
|
|
41
41
|
const router = express.Router();
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
// Shown as a toast (layout.pug): `danger` stays until closed, `warning`
|
|
44
|
+
// closes after 30 s, `info`/`success` after 10 s — unless `sticky`.
|
|
45
|
+
function flash(req, type, text, { sticky = false } = {}){
|
|
44
46
|
req.session.flash = req.session.flash || [];
|
|
45
|
-
req.session.flash.push({ type, text });
|
|
47
|
+
req.session.flash.push({ type, text, ...(sticky ? { sticky } : {}) });
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
// Where a resource action (maintenance, rotate token) came from — the
|
|
@@ -107,7 +109,7 @@ function createWebRouter({ services, config }){
|
|
|
107
109
|
const { username, password } = req.body,
|
|
108
110
|
user = services.adminUsers.verify(username, password);
|
|
109
111
|
if (!user){
|
|
110
|
-
return res.status(401).render('login', { title: 'Sign in',
|
|
112
|
+
return res.status(401).render('login', { title: 'Sign in', messages: [{ type: 'danger', text: 'Invalid credentials' }] });
|
|
111
113
|
}
|
|
112
114
|
req.session.user = user;
|
|
113
115
|
req.session.cookie.maxAge = user.sessionTimeoutMin * 60 * 1000;
|
|
@@ -287,7 +289,8 @@ function createWebRouter({ services, config }){
|
|
|
287
289
|
const token = generateToken('res');
|
|
288
290
|
services.db.prepare('UPDATE resources SET token_hash = ? WHERE id = ?').run(hashToken(token), req.params.id);
|
|
289
291
|
services.events.record('resource', req.params.id, 'resource.token_rotated', {});
|
|
290
|
-
|
|
292
|
+
// Sticky: shown only once, so it mustn't close before it's copied.
|
|
293
|
+
flash(req, 'warning', `New resource token (copy it now): ${token}`, { sticky: true });
|
|
291
294
|
res.redirect(returnTo(req, '/resources'));
|
|
292
295
|
});
|
|
293
296
|
|
|
@@ -297,7 +300,10 @@ function createWebRouter({ services, config }){
|
|
|
297
300
|
function updateAction(fn){
|
|
298
301
|
return async (req, res) => {
|
|
299
302
|
try {
|
|
300
|
-
|
|
303
|
+
// `fn` returns the message, or { type, text } for another severity.
|
|
304
|
+
const result = await fn(req),
|
|
305
|
+
{ type, text } = typeof result === 'string' ? { type: 'info', text: result } : result;
|
|
306
|
+
flash(req, type, text);
|
|
301
307
|
}
|
|
302
308
|
catch (err){
|
|
303
309
|
flash(req, 'danger', err.message);
|
|
@@ -306,10 +312,45 @@ function createWebRouter({ services, config }){
|
|
|
306
312
|
};
|
|
307
313
|
}
|
|
308
314
|
|
|
315
|
+
// Check now (navbar, Agents and Resources pages): fetch the latest
|
|
316
|
+
// versions, then report whether the Coordinator and the connected
|
|
317
|
+
// (heartbeating) Clients are behind — as a warning if anything is.
|
|
309
318
|
router.post('/updates/check', requireAdminRole, updateAction(async () => {
|
|
310
319
|
const s = await services.updates.checkNow(),
|
|
311
|
-
|
|
312
|
-
|
|
320
|
+
parts = [];
|
|
321
|
+
if (!s.fetched.length){
|
|
322
|
+
return { type: 'danger', text: `Update check failed — the npm registry couldn't be reached (${s.error}). Nothing was checked.` };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
parts.push(s.coordinatorUpdate
|
|
326
|
+
? `Coordinator v${s.coordinatorVersion} → v${s.coordinatorUpdate} available (Update app button).`
|
|
327
|
+
: `Coordinator is up to date (v${s.coordinatorVersion}).`);
|
|
328
|
+
|
|
329
|
+
const connected = services.registry.list().filter((r) => r.status !== RESOURCE_STATES.OUT_OF_SERVICE && r.last_heartbeat_at),
|
|
330
|
+
outdated = connected.filter((r) => r.client_version && isNewer(s.latest.client, r.client_version)),
|
|
331
|
+
unknown = connected.filter((r) => !r.client_version),
|
|
332
|
+
names = (list) => list.slice(0, 5).map((r) => `${r.name}${r.client_version ? ` (v${r.client_version})` : ''}`).join(', ') +
|
|
333
|
+
(list.length > 5 ? `, +${list.length - 5} more` : '');
|
|
334
|
+
if (!s.latest.client){
|
|
335
|
+
parts.push('Latest Client version unknown.');
|
|
336
|
+
}
|
|
337
|
+
else if (!connected.length){
|
|
338
|
+
parts.push('No connected resources.');
|
|
339
|
+
}
|
|
340
|
+
else if (outdated.length){
|
|
341
|
+
parts.push(`${outdated.length} of ${connected.length} connected resource(s) can update to Client v${s.latest.client}: ${names(outdated)}.`);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
parts.push(`All ${connected.length} connected resource(s) run the latest Client (v${s.latest.client}).`);
|
|
345
|
+
}
|
|
346
|
+
if (unknown.length){
|
|
347
|
+
parts.push(`Version unknown (Client too old to report it): ${names(unknown)}.`);
|
|
348
|
+
}
|
|
349
|
+
if (s.error){
|
|
350
|
+
parts.push(`Some checks failed: ${s.error}`);
|
|
351
|
+
}
|
|
352
|
+
const behind = s.coordinatorUpdate || outdated.length || unknown.length;
|
|
353
|
+
return { type: behind || s.error ? 'warning' : 'success', text: parts.join(' ') };
|
|
313
354
|
}));
|
|
314
355
|
|
|
315
356
|
router.post('/updates/coordinator', requireAdminRole, updateAction(async (req) => {
|
|
@@ -392,6 +433,7 @@ function createWebRouter({ services, config }){
|
|
|
392
433
|
title: job.id,
|
|
393
434
|
active: 'jobs',
|
|
394
435
|
job,
|
|
436
|
+
pinnedClient: job.spec.target.client ? services.registry.get(job.spec.target.client) : null,
|
|
395
437
|
artifacts,
|
|
396
438
|
jobActive,
|
|
397
439
|
canCancel: req.session.user.role === 'admin' && jobActive
|
package/test/scheduler.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file packages/coordinator/test/scheduler.test.js
|
|
3
|
-
* @description Tests: scheduler, registry, groups, job id allocation, and admin queue operations
|
|
3
|
+
* @description Tests: scheduler, registry, groups, client pinning, job id allocation, and admin queue operations
|
|
4
4
|
*
|
|
5
5
|
* @author Andrian Yablonskyy
|
|
6
6
|
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
@@ -349,6 +349,76 @@ test('a job with target.group is rejected (422) when no resource is ever a membe
|
|
|
349
349
|
);
|
|
350
350
|
});
|
|
351
351
|
|
|
352
|
+
test('a job with target.client (by name) waits for that client even when another matching one is idle', () => {
|
|
353
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
354
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
355
|
+
pinned = registerResource(registry, { name: 'lab-pinned', type: 'sw' }),
|
|
356
|
+
other = registerResource(registry, { name: 'lab-other', type: 'sw' });
|
|
357
|
+
registry.heartbeat(other.id, { state: 'idle' }); // pinned is still REGISTERED, not IDLE
|
|
358
|
+
|
|
359
|
+
const job = jobs.create({
|
|
360
|
+
agentId: agent.id,
|
|
361
|
+
source: 'cli',
|
|
362
|
+
spec: makeSpec({ target: { type: 'sw', labels: [], client: 'lab-pinned' } })
|
|
363
|
+
});
|
|
364
|
+
assert.equal(job.spec.target.client, pinned.id); // name resolved to the stable resource id
|
|
365
|
+
|
|
366
|
+
scheduler.runPass();
|
|
367
|
+
assert.equal(jobs.get(job.id).state, JOB_STATES.QUEUED); // not grabbed by lab-other
|
|
368
|
+
|
|
369
|
+
registry.heartbeat(pinned.id, { state: 'idle' });
|
|
370
|
+
scheduler.runPass();
|
|
371
|
+
const assigned = jobs.get(job.id);
|
|
372
|
+
assert.equal(assigned.state, JOB_STATES.ASSIGNED);
|
|
373
|
+
assert.equal(assigned.resource_id, pinned.id);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('an unpinned job prefers a resource no queued job is pinned to', () => {
|
|
377
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
378
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
379
|
+
pinned = registerResource(registry, { name: 'lab-pinned', type: 'sw' }),
|
|
380
|
+
other = registerResource(registry, { name: 'lab-other', type: 'sw' });
|
|
381
|
+
registry.heartbeat(pinned.id, { state: 'idle' });
|
|
382
|
+
registry.heartbeat(other.id, { state: 'idle' });
|
|
383
|
+
|
|
384
|
+
// Unpinned job has higher priority, so it's considered first; it must not
|
|
385
|
+
// take lab-pinned (which is also least-recently-used) away from the pinned job.
|
|
386
|
+
const unpinned = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ priority: 90 }) }),
|
|
387
|
+
pinnedJob = jobs.create({
|
|
388
|
+
agentId: agent.id,
|
|
389
|
+
source: 'cli',
|
|
390
|
+
spec: makeSpec({ target: { type: 'sw', labels: [], client: pinned.id }, priority: 10 })
|
|
391
|
+
});
|
|
392
|
+
scheduler.runPass();
|
|
393
|
+
|
|
394
|
+
assert.equal(jobs.get(unpinned.id).resource_id, other.id);
|
|
395
|
+
assert.equal(jobs.get(pinnedJob.id).resource_id, pinned.id);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('a job with target.client is rejected (422) when the client is unknown or cannot satisfy the target', () => {
|
|
399
|
+
const { registry, agents, jobs } = buildTestServices(),
|
|
400
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' });
|
|
401
|
+
registerResource(registry, { name: 'lab-sw', type: 'sw', labels: ['board:a'] });
|
|
402
|
+
|
|
403
|
+
assert.throws(
|
|
404
|
+
() => jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', client: 'nope' } }) }),
|
|
405
|
+
/Unknown client "nope"/
|
|
406
|
+
);
|
|
407
|
+
assert.throws(
|
|
408
|
+
() => jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'hw', client: 'lab-sw' } }) }),
|
|
409
|
+
/No registered resource/
|
|
410
|
+
);
|
|
411
|
+
assert.throws(
|
|
412
|
+
() =>
|
|
413
|
+
jobs.create({
|
|
414
|
+
agentId: agent.id,
|
|
415
|
+
source: 'cli',
|
|
416
|
+
spec: makeSpec({ target: { type: 'sw', labels: ['board:b'], client: 'lab-sw' } })
|
|
417
|
+
}),
|
|
418
|
+
/No registered resource/
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
|
|
352
422
|
test('a resource can belong to several groups at once', () => {
|
|
353
423
|
const { registry, groups } = buildTestServices(),
|
|
354
424
|
g1 = groups.create({ name: 'g1' }),
|
package/views/jobs/show.pug
CHANGED
|
@@ -22,6 +22,9 @@ block content
|
|
|
22
22
|
dd.col-sm-10= job.resource ? job.resource.name : '—'
|
|
23
23
|
dt.col-sm-2 Target
|
|
24
24
|
dd.col-sm-10= `${job.spec.target.type} — ${(job.spec.target.labels || []).join(', ')}`
|
|
25
|
+
if job.spec.target.client
|
|
26
|
+
dt.col-sm-2 Client
|
|
27
|
+
dd.col-sm-10= pinnedClient ? pinnedClient.name : job.spec.target.client
|
|
25
28
|
dt.col-sm-2 Created
|
|
26
29
|
dd.col-sm-10= fmtDate(job.created_at)
|
|
27
30
|
if job.message
|
package/views/layout.pug
CHANGED
|
@@ -37,6 +37,18 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
37
37
|
li.nav-item
|
|
38
38
|
a.nav-link(href="/admin/agents" class=active === 'agents' ? 'active' : '') Agents
|
|
39
39
|
ul.navbar-nav
|
|
40
|
+
if user && user.role === 'admin'
|
|
41
|
+
li.nav-item.d-flex.align-items-center.me-2
|
|
42
|
+
form(method="post" action="/updates/check" onsubmit="this.querySelector('button').disabled = true; this.querySelector('i').className = 'spinner-border spinner-border-sm';")
|
|
43
|
+
input(type="hidden" name="returnTo" value=currentPath)
|
|
44
|
+
button.btn.btn-sm.btn-outline-secondary(
|
|
45
|
+
type="submit"
|
|
46
|
+
aria-label="Check for updates"
|
|
47
|
+
data-bs-toggle="tooltip"
|
|
48
|
+
data-bs-placement="bottom"
|
|
49
|
+
data-bs-title=updates.checkedAt ? `Check for updates now — the Coordinator and connected resources (last checked ${fmtDate(updates.checkedAt)})` : 'Check for updates now — the Coordinator and connected resources'
|
|
50
|
+
)
|
|
51
|
+
i.bi.bi-arrow-repeat
|
|
40
52
|
if user && user.role === 'admin' && updates.coordinatorUpdate
|
|
41
53
|
li.nav-item.d-flex.align-items-center.me-3
|
|
42
54
|
if updates.coordinatorPending
|
|
@@ -91,13 +103,37 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
91
103
|
data-bs-title="Sign out of the dashboard"
|
|
92
104
|
)
|
|
93
105
|
i.bi.bi-box-arrow-right
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
//- Flash messages as toasts (public/js/toasts.js): errors stay until
|
|
107
|
+
//- closed, warnings close after 30 s, info/success after 10 s. Rendered
|
|
108
|
+
//- visible (.show), so they're readable even if the script fails.
|
|
109
|
+
if messages && messages.length
|
|
110
|
+
.toast-container.position-fixed.end-0.p-3(style="top: 3.5rem; z-index: 1090;")
|
|
96
111
|
each m in messages
|
|
97
|
-
.
|
|
112
|
+
- const type = ['danger', 'warning', 'info', 'success'].includes(m.type) ? m.type : 'info'
|
|
113
|
+
- const icon = { danger: 'x-octagon-fill', warning: 'exclamation-triangle-fill', info: 'info-circle-fill', success: 'check-circle-fill' }[type]
|
|
114
|
+
.toast.show.border-0(
|
|
115
|
+
class=`text-bg-${type}`
|
|
116
|
+
role=type === 'danger' ? 'alert' : 'status'
|
|
117
|
+
aria-live=type === 'danger' ? 'assertive' : 'polite'
|
|
118
|
+
aria-atomic="true"
|
|
119
|
+
data-thub-toast=type
|
|
120
|
+
data-sticky=m.sticky ? '1' : false
|
|
121
|
+
)
|
|
122
|
+
.d-flex
|
|
123
|
+
.toast-body.d-flex.gap-2
|
|
124
|
+
i.bi(class=`bi-${icon}`)
|
|
125
|
+
span= m.text
|
|
126
|
+
button.btn-close.me-2.m-auto(
|
|
127
|
+
type="button"
|
|
128
|
+
class=type === 'danger' || type === 'success' ? 'btn-close-white' : ''
|
|
129
|
+
data-bs-dismiss="toast"
|
|
130
|
+
aria-label="Close"
|
|
131
|
+
)
|
|
132
|
+
main.container.py-4
|
|
98
133
|
block content
|
|
99
134
|
script(src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js")
|
|
100
135
|
script(src="/js/theme.js")
|
|
101
136
|
script(src="/js/tooltips.js")
|
|
137
|
+
script(src="/js/toasts.js")
|
|
102
138
|
script(src="/js/copy-to-clipboard.js")
|
|
103
139
|
block scripts
|