@andrian.yablonskyy/thub-coordinator 1.1.8 → 1.1.9
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 -3
- package/package.json +1 -1
- package/public/js/remove-resource.js +2 -2
- package/src/web/routes.js +47 -38
- package/test/groups-tab.test.js +23 -9
- package/test/live.test.js +2 -2
- package/test/users.test.js +2 -2
- package/views/groups/list.pug +3 -3
- package/views/help/_agent-cli.pug +1 -1
- package/views/help/_agent-setup.pug +1 -1
- package/views/help/_client-machines.pug +4 -4
- package/views/help/_client-setup.pug +3 -3
- package/views/help/_coordinator.pug +3 -3
- package/views/help/_docker.pug +1 -1
- package/views/help/_overview.pug +3 -3
- package/views/help/_quick-start.pug +1 -1
- package/views/help/_troubleshooting.pug +5 -5
- package/views/index.pug +1 -1
- package/views/jobs/list.pug +3 -3
- package/views/jobs/show.pug +1 -1
- package/views/layout.pug +2 -2
- package/views/mixins/client-config.pug +2 -2
- package/views/mixins/group-select.pug +2 -2
- package/views/mixins/resource.pug +17 -17
- package/views/mixins/updates.pug +1 -1
- package/views/resources/list.pug +6 -6
package/README.md
CHANGED
|
@@ -149,8 +149,8 @@ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla J
|
|
|
149
149
|
|
|
150
150
|
| Page | Content |
|
|
151
151
|
|---|---|
|
|
152
|
-
| `/` |
|
|
153
|
-
| `/
|
|
152
|
+
| `/` | Runner cards by status, queue length, jobs in the last 24 h. Click a card for its runner card (below). |
|
|
153
|
+
| `/runners` | **Runners** (formerly Resources; `/resources` redirects): Type, Client version, labels, groups, status, busy source/reason, last heartbeat age; admin maintenance/rotate-token actions. Click a row for its resource card (below). |
|
|
154
154
|
| `/groups` | Resource groups — id/name/comment, member count; admin create/rename/delete. Membership itself is set per-resource in the Client's own config. |
|
|
155
155
|
| `/jobs` | Filterable job list (state, source, resource), with the job's `user` label if set; admin **Reset queue** / **Clean history**. |
|
|
156
156
|
| `/jobs/:id` | Spec, timeline, live log viewer with stream filter, artifact downloads, cancel button. |
|
|
@@ -159,7 +159,7 @@ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla J
|
|
|
159
159
|
|
|
160
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
161
|
|
|
162
|
-
**
|
|
162
|
+
**Runner card.** Clicking a runner on `/` or `/runners` 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; 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).
|
|
163
163
|
|
|
164
164
|
The Coordinator's own version (`ver. X.Y.Z`) is shown under the TestHub logo, top left of every page.
|
|
165
165
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.9",
|
|
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": {
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
field('job').textContent = job;
|
|
30
30
|
field('job').href = `/jobs/${encodeURIComponent(job)}`;
|
|
31
31
|
field('job-text').textContent = job;
|
|
32
|
-
field('form').action = `/
|
|
33
|
-
field('return').value = returnTo || '/
|
|
32
|
+
field('form').action = `/runners/${encodeURIComponent(id)}/remove`;
|
|
33
|
+
field('return').value = returnTo || '/runners';
|
|
34
34
|
|
|
35
35
|
// Clicked from inside a resource card: close that first — Bootstrap
|
|
36
36
|
// doesn't stack modals.
|
package/src/web/routes.js
CHANGED
|
@@ -292,6 +292,15 @@ function createWebRouter({ services, config }){
|
|
|
292
292
|
req.session.destroy(() => res.redirect('/login'));
|
|
293
293
|
});
|
|
294
294
|
|
|
295
|
+
// Resources were renamed Runners on the dashboard: old links and
|
|
296
|
+
// bookmarks (and a form from a page loaded before) still land.
|
|
297
|
+
router.use((req, res, next) => {
|
|
298
|
+
if (!/^\/resources(\/|$)/.test(req.path)){
|
|
299
|
+
return next();
|
|
300
|
+
}
|
|
301
|
+
res.redirect(req.method === 'GET' || req.method === 'HEAD' ? 301 : 308, req.originalUrl.replace(/^\/resources/, '/runners'));
|
|
302
|
+
});
|
|
303
|
+
|
|
295
304
|
router.use(requireAdminSession);
|
|
296
305
|
router.use((req, res, next) => {
|
|
297
306
|
const u = req.session.user;
|
|
@@ -496,7 +505,7 @@ function createWebRouter({ services, config }){
|
|
|
496
505
|
res.render('index', { title: 'Overview', active: 'overview', liveTopics: 'resources jobs', resources, queueLength, jobsLast24h, onlineCount, groupsById });
|
|
497
506
|
});
|
|
498
507
|
|
|
499
|
-
router.get('/
|
|
508
|
+
router.get('/runners', (req, res) => {
|
|
500
509
|
const groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g])),
|
|
501
510
|
q = search.normalizeQuery(req.query.q),
|
|
502
511
|
terms = search.parseTerms(q),
|
|
@@ -518,7 +527,7 @@ function createWebRouter({ services, config }){
|
|
|
518
527
|
pagination = listPrefs.paginate(sorted.length, size, req.query.page),
|
|
519
528
|
pageUrl = (changes) => view.urlFor({ page: pagination.page, ...changes });
|
|
520
529
|
res.render('resources/list', {
|
|
521
|
-
title: '
|
|
530
|
+
title: 'Runners',
|
|
522
531
|
active: 'resources',
|
|
523
532
|
liveTopics: 'resources',
|
|
524
533
|
resources: withActiveJobs(sorted.slice(pagination.offset, pagination.offset + pagination.limit)),
|
|
@@ -527,21 +536,21 @@ function createWebRouter({ services, config }){
|
|
|
527
536
|
});
|
|
528
537
|
});
|
|
529
538
|
|
|
530
|
-
router.post('/
|
|
539
|
+
router.post('/runners/:id/maintenance', (req, res) => {
|
|
531
540
|
services.registry.setMaintenance(req.params.id, req.body.enabled === '1');
|
|
532
|
-
res.redirect(returnTo(req, '/
|
|
541
|
+
res.redirect(returnTo(req, '/runners'));
|
|
533
542
|
});
|
|
534
543
|
|
|
535
544
|
// Cancel whatever the Client is doing (resource card): its job, a manual
|
|
536
545
|
// local lock, or a self-update hold (README §10). The Client applies the
|
|
537
546
|
// lock/update ones via heartbeat commands; a job is canceled right here.
|
|
538
|
-
router.post('/
|
|
547
|
+
router.post('/runners/:id/cancel-activity', (req, res) => {
|
|
539
548
|
const r = services.registry.get(req.params.id),
|
|
540
549
|
job = r && services.jobs.activeForResource(r.id),
|
|
541
550
|
activity = r?.activity?.state;
|
|
542
551
|
try {
|
|
543
552
|
if (!r){
|
|
544
|
-
throw Object.assign(new Error('Unknown
|
|
553
|
+
throw Object.assign(new Error('Unknown runner'), { status: 404 });
|
|
545
554
|
}
|
|
546
555
|
if (job){
|
|
547
556
|
services.jobs.cancel(job.id, { isAdmin: true });
|
|
@@ -572,10 +581,10 @@ function createWebRouter({ services, config }){
|
|
|
572
581
|
catch (err){
|
|
573
582
|
flash(req, 'danger', err.message);
|
|
574
583
|
}
|
|
575
|
-
res.redirect(returnTo(req, '/
|
|
584
|
+
res.redirect(returnTo(req, '/runners'));
|
|
576
585
|
});
|
|
577
586
|
|
|
578
|
-
router.post('/
|
|
587
|
+
router.post('/runners/:id/remove', (req, res) => {
|
|
579
588
|
try {
|
|
580
589
|
const { resource: r, pending, stoppedJob, canceledJobs } = services.jobs.removeResource(req.params.id, {
|
|
581
590
|
by: req.session.user.username,
|
|
@@ -592,12 +601,12 @@ function createWebRouter({ services, config }){
|
|
|
592
601
|
catch (err){
|
|
593
602
|
flash(req, 'danger', err.message);
|
|
594
603
|
}
|
|
595
|
-
res.redirect(returnTo(req, '/
|
|
604
|
+
res.redirect(returnTo(req, '/runners'));
|
|
596
605
|
});
|
|
597
606
|
|
|
598
607
|
// Reboot now (resource card footer): cancels a running job, then the
|
|
599
608
|
// Client reboots its host on its next heartbeat (jobs.requestReboot).
|
|
600
|
-
router.post('/
|
|
609
|
+
router.post('/runners/:id/reboot', (req, res) => {
|
|
601
610
|
try {
|
|
602
611
|
const { resource: r, canceledJob } = services.jobs.requestReboot(req.params.id, { by: req.session.user.username });
|
|
603
612
|
flash(req, 'warning', `Rebooting ${r.name}'s host on its next heartbeat.` + (canceledJob ? `\nCanceled its running job ${canceledJob}.` : ''));
|
|
@@ -605,7 +614,7 @@ function createWebRouter({ services, config }){
|
|
|
605
614
|
catch (err){
|
|
606
615
|
flash(req, 'danger', err.message);
|
|
607
616
|
}
|
|
608
|
-
res.redirect(returnTo(req, '/
|
|
617
|
+
res.redirect(returnTo(req, '/runners'));
|
|
609
618
|
});
|
|
610
619
|
|
|
611
620
|
// "Connected USB devices" tab (public/js/usb-scan.js): Refresh queues a
|
|
@@ -621,7 +630,7 @@ function createWebRouter({ services, config }){
|
|
|
621
630
|
};
|
|
622
631
|
}
|
|
623
632
|
|
|
624
|
-
router.post('/
|
|
633
|
+
router.post('/runners/:id/usb-scan', (req, res) => {
|
|
625
634
|
try {
|
|
626
635
|
services.registry.requestUsbScan(req.params.id, { by: req.session.user.username });
|
|
627
636
|
res.json(usbScanView(req, services.registry.get(req.params.id)));
|
|
@@ -631,16 +640,16 @@ function createWebRouter({ services, config }){
|
|
|
631
640
|
}
|
|
632
641
|
});
|
|
633
642
|
|
|
634
|
-
router.get('/
|
|
643
|
+
router.get('/runners/:id/usb-scan', (req, res) => {
|
|
635
644
|
const r = services.registry.get(req.params.id);
|
|
636
645
|
if (!r){
|
|
637
|
-
return res.status(404).json({ error: 'Unknown
|
|
646
|
+
return res.status(404).json({ error: 'Unknown runner' });
|
|
638
647
|
}
|
|
639
648
|
res.set('Cache-Control', 'no-store').json(usbScanView(req, r));
|
|
640
649
|
});
|
|
641
650
|
|
|
642
651
|
// Rename (resource card, Name row); empty = back to the Client's own name.
|
|
643
|
-
router.post('/
|
|
652
|
+
router.post('/runners/:id/rename', (req, res) => {
|
|
644
653
|
try {
|
|
645
654
|
const before = services.registry.get(req.params.id)?.name,
|
|
646
655
|
r = services.registry.rename(req.params.id, req.body.name, { by: req.session.user.username });
|
|
@@ -651,13 +660,13 @@ function createWebRouter({ services, config }){
|
|
|
651
660
|
catch (err){
|
|
652
661
|
flash(req, 'danger', err.message);
|
|
653
662
|
}
|
|
654
|
-
res.redirect(returnTo(req, '/
|
|
663
|
+
res.redirect(returnTo(req, '/runners'));
|
|
655
664
|
});
|
|
656
665
|
|
|
657
666
|
// Capabilities (resource card, Config tab): public/js/client-config.js
|
|
658
667
|
// sends the edited hw/sw section as JSON in `config`. Applied by the Client
|
|
659
668
|
// on its next heartbeat, then it restarts once idle.
|
|
660
|
-
router.post('/
|
|
669
|
+
router.post('/runners/:id/config', (req, res) => {
|
|
661
670
|
try {
|
|
662
671
|
let config;
|
|
663
672
|
try {
|
|
@@ -673,12 +682,12 @@ function createWebRouter({ services, config }){
|
|
|
673
682
|
catch (err){
|
|
674
683
|
flash(req, 'danger', err.message);
|
|
675
684
|
}
|
|
676
|
-
res.redirect(returnTo(req, '/
|
|
685
|
+
res.redirect(returnTo(req, '/runners'));
|
|
677
686
|
});
|
|
678
687
|
|
|
679
688
|
// Groups tab (resource card): the Client's group membership, as a config
|
|
680
689
|
// revision the Client writes to its file (registry.setClientGroups).
|
|
681
|
-
router.post('/
|
|
690
|
+
router.post('/runners/:id/groups', (req, res) => {
|
|
682
691
|
try {
|
|
683
692
|
const ids = [].concat(req.body.groups || []).filter((g) => typeof g === 'string' && g),
|
|
684
693
|
r = services.registry.setClientGroups(req.params.id, ids, {
|
|
@@ -691,12 +700,12 @@ function createWebRouter({ services, config }){
|
|
|
691
700
|
catch (err){
|
|
692
701
|
flash(req, 'danger', err.message);
|
|
693
702
|
}
|
|
694
|
-
res.redirect(returnTo(req, '/
|
|
703
|
+
res.redirect(returnTo(req, '/runners'));
|
|
695
704
|
});
|
|
696
705
|
|
|
697
706
|
// Export (resource card, admins — the file holds the join key): the
|
|
698
707
|
// Client's config file as JSON, secrets excluded.
|
|
699
|
-
router.get('/
|
|
708
|
+
router.get('/runners/:id/config/export', (req, res) => {
|
|
700
709
|
try {
|
|
701
710
|
const r = services.registry.get(req.params.id),
|
|
702
711
|
file = services.registry.exportClientConfig(req.params.id);
|
|
@@ -704,13 +713,13 @@ function createWebRouter({ services, config }){
|
|
|
704
713
|
}
|
|
705
714
|
catch (err){
|
|
706
715
|
flash(req, 'danger', err.message);
|
|
707
|
-
res.redirect('/
|
|
716
|
+
res.redirect('/runners');
|
|
708
717
|
}
|
|
709
718
|
});
|
|
710
719
|
|
|
711
720
|
// Import (resource card, public/js/config-import.js): a config file, applied
|
|
712
721
|
// by the Client on its next heartbeat like a Config tab Save.
|
|
713
|
-
router.post('/
|
|
722
|
+
router.post('/runners/:id/config/import', (req, res) => {
|
|
714
723
|
try {
|
|
715
724
|
let file;
|
|
716
725
|
try {
|
|
@@ -727,12 +736,12 @@ function createWebRouter({ services, config }){
|
|
|
727
736
|
catch (err){
|
|
728
737
|
flash(req, 'danger', err.message);
|
|
729
738
|
}
|
|
730
|
-
res.redirect(returnTo(req, '/
|
|
739
|
+
res.redirect(returnTo(req, '/runners'));
|
|
731
740
|
});
|
|
732
741
|
|
|
733
742
|
// Scheduled host reboot (resource card): save a cron expression, or clear
|
|
734
743
|
// it; the Client applies it on its next heartbeat.
|
|
735
|
-
router.post('/
|
|
744
|
+
router.post('/runners/:id/reboot-schedule', (req, res) => {
|
|
736
745
|
try {
|
|
737
746
|
const cron = req.body.clear === '1' ? '' : req.body.cron,
|
|
738
747
|
r = services.registry.setRebootSchedule(req.params.id, cron, { by: req.session.user.username });
|
|
@@ -743,17 +752,17 @@ function createWebRouter({ services, config }){
|
|
|
743
752
|
catch (err){
|
|
744
753
|
flash(req, 'danger', err.message);
|
|
745
754
|
}
|
|
746
|
-
res.redirect(returnTo(req, '/
|
|
755
|
+
res.redirect(returnTo(req, '/runners'));
|
|
747
756
|
});
|
|
748
757
|
|
|
749
|
-
router.post('/
|
|
758
|
+
router.post('/runners/:id/rotate-token', requireAdminRole, (req, res) => {
|
|
750
759
|
const { generateToken, hashToken } = require('../services/tokens');
|
|
751
760
|
const token = generateToken('res');
|
|
752
761
|
services.db.prepare('UPDATE resources SET token_hash = ? WHERE id = ?').run(hashToken(token), req.params.id);
|
|
753
762
|
services.events.record('resource', req.params.id, 'resource.token_rotated', {});
|
|
754
763
|
// Sticky: shown only once, so it mustn't close before it's copied.
|
|
755
|
-
flash(req, 'warning', `New
|
|
756
|
-
res.redirect(returnTo(req, '/
|
|
764
|
+
flash(req, 'warning', `New runner token (copy it now):\n${token}`, { sticky: true });
|
|
765
|
+
res.redirect(returnTo(req, '/runners'));
|
|
757
766
|
});
|
|
758
767
|
|
|
759
768
|
// Self-update (README §10.2). Clients get a `self-update` heartbeat
|
|
@@ -797,13 +806,13 @@ function createWebRouter({ services, config }){
|
|
|
797
806
|
parts.push('Latest Client version unknown.');
|
|
798
807
|
}
|
|
799
808
|
else if (!connected.length){
|
|
800
|
-
parts.push('No connected
|
|
809
|
+
parts.push('No connected runners.');
|
|
801
810
|
}
|
|
802
811
|
else if (outdated.length){
|
|
803
|
-
parts.push(`${outdated.length} of ${connected.length} connected
|
|
812
|
+
parts.push(`${outdated.length} of ${connected.length} connected runner(s) can update to Client v${s.latest.client}: ${names(outdated)}.`);
|
|
804
813
|
}
|
|
805
814
|
else {
|
|
806
|
-
parts.push(`All ${connected.length} connected
|
|
815
|
+
parts.push(`All ${connected.length} connected runner(s) run the latest Client (v${s.latest.client}).`);
|
|
807
816
|
}
|
|
808
817
|
if (unknown.length){
|
|
809
818
|
parts.push(`Version unknown (Client too old to report it): ${names(unknown)}.`);
|
|
@@ -833,20 +842,20 @@ function createWebRouter({ services, config }){
|
|
|
833
842
|
});
|
|
834
843
|
});
|
|
835
844
|
|
|
836
|
-
router.post('/
|
|
845
|
+
router.post('/runners/update-all', updateAction(async () => {
|
|
837
846
|
const version = await services.updates.targetVersion('client'),
|
|
838
847
|
n = services.registry.requestUpdateAll(version);
|
|
839
|
-
return `Requested update to client v${version} on ${n}
|
|
848
|
+
return `Requested update to client v${version} on ${n} runner(s).`;
|
|
840
849
|
}));
|
|
841
850
|
|
|
842
|
-
router.post('/
|
|
851
|
+
router.post('/runners/:id/update', updateAction(async (req) => {
|
|
843
852
|
// Cancel (next to the version): withdraw the request and, if the host is
|
|
844
853
|
// already holding for the update, abort that too (the Client's next
|
|
845
854
|
// heartbeat) — the one control for canceling a Client update.
|
|
846
855
|
if (req.body.cancel === '1'){
|
|
847
856
|
const r = services.registry.get(req.params.id);
|
|
848
857
|
if (!r){
|
|
849
|
-
throw Object.assign(new Error('Unknown
|
|
858
|
+
throw Object.assign(new Error('Unknown runner'), { status: 404 });
|
|
850
859
|
}
|
|
851
860
|
services.registry.setUpdateTo(r.id, null);
|
|
852
861
|
if (r.activity?.state === 'update-hold'){
|
|
@@ -1069,7 +1078,7 @@ function createWebRouter({ services, config }){
|
|
|
1069
1078
|
|
|
1070
1079
|
router.post('/groups/:id/delete', (req, res) => {
|
|
1071
1080
|
services.groups.remove(req.params.id);
|
|
1072
|
-
flash(req, 'warning', 'Group deleted; any
|
|
1081
|
+
flash(req, 'warning', 'Group deleted; any runner that listed it just stopped matching on it.');
|
|
1073
1082
|
res.redirect('/groups');
|
|
1074
1083
|
});
|
|
1075
1084
|
|
|
@@ -1469,7 +1478,7 @@ function createWebRouter({ services, config }){
|
|
|
1469
1478
|
if (req.body.groupId !== undefined && (req.body.groupId || null) !== (before.group_id || null)){
|
|
1470
1479
|
const group = req.body.groupId ? services.groups.get(req.body.groupId) : null;
|
|
1471
1480
|
services.agents.setGroup(req.params.id, req.body.groupId);
|
|
1472
|
-
flash(req, 'success', group ? `"${agent.name}" now runs its jobs in group ${group.name}.` : `"${agent.name}" now runs its jobs on any
|
|
1481
|
+
flash(req, 'success', group ? `"${agent.name}" now runs its jobs in group ${group.name}.` : `"${agent.name}" now runs its jobs on any runner.`);
|
|
1473
1482
|
}
|
|
1474
1483
|
}
|
|
1475
1484
|
catch (err){
|
package/test/groups-tab.test.js
CHANGED
|
@@ -52,10 +52,10 @@ test('Groups tab: in effect at once, sent to the Client as its next config revis
|
|
|
52
52
|
nightly = services.groups.create({ name: 'nightly' }),
|
|
53
53
|
pr = services.groups.create({ name: 'pr-pool', comment: 'pull requests' }),
|
|
54
54
|
id = register(services, 'lab-sw-01'),
|
|
55
|
-
save = (cookie, groups) => fetch(`${base}/
|
|
56
|
-
method: 'POST', headers: { cookie }, body: new URLSearchParams([...groups.map((g) => ['groups', g]), ['returnTo', '/
|
|
55
|
+
save = (cookie, groups) => fetch(`${base}/runners/${id}/groups`, {
|
|
56
|
+
method: 'POST', headers: { cookie }, body: new URLSearchParams([...groups.map((g) => ['groups', g]), ['returnTo', '/runners']]), redirect: 'manual'
|
|
57
57
|
}),
|
|
58
|
-
page = async () => (await fetch(`${base}/
|
|
58
|
+
page = async () => (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text();
|
|
59
59
|
|
|
60
60
|
assert.equal((await save(viewer, [nightly.id])).status, 302); // a maintainer may (§10.3)
|
|
61
61
|
assert.equal((await save(admin, [nightly.id, pr.id])).status, 302);
|
|
@@ -105,7 +105,7 @@ test('device tabs show what the Client runs — not an old saved revision; group
|
|
|
105
105
|
}).resourceId,
|
|
106
106
|
id = register('1.1'),
|
|
107
107
|
devpaths = async () => {
|
|
108
|
-
const html = await (await fetch(`${base}/
|
|
108
|
+
const html = await (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text(),
|
|
109
109
|
table = html.slice(html.indexOf(`id="cfg-${id}-stlinks"`), html.indexOf(`id="cfg-${id}-uarts"`));
|
|
110
110
|
return [...table.matchAll(/data-f="devpath" value="([^"]*)"/g)].map((m) => m[1]).filter(Boolean); // not the blank add-row template
|
|
111
111
|
};
|
|
@@ -134,7 +134,7 @@ test('ST-Link tab has no Serial field; a serial set in the config file is kept o
|
|
|
134
134
|
clientId: 'hw2', name: 'lab-hw-02', type: 'hw', labels: [],
|
|
135
135
|
config: { stlinks: [{ index: 1, serial: '066DFF48', devpath: '1.2' }] }, configFile: { name: 'lab-hw-02', type: 'hw' }
|
|
136
136
|
}).resourceId,
|
|
137
|
-
html = await (await fetch(`${base}/
|
|
137
|
+
html = await (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text(),
|
|
138
138
|
table = html.slice(html.indexOf(`id="cfg-${id}-stlinks"`), html.indexOf(`id="cfg-${id}-uarts"`));
|
|
139
139
|
assert.doesNotMatch(table, /data-f="serial"|>Serial</);
|
|
140
140
|
// Rides along in data-extra, which public/js/client-config.js spreads back into the row.
|
|
@@ -147,7 +147,7 @@ test('resource card: USB actions update live and every disabled button says why'
|
|
|
147
147
|
clientId: 'hw3', name: 'lab-hw-03', type: 'hw', labels: [], config: { stlinks: [{ index: 1 }] }, configFile: { name: 'lab-hw-03', type: 'hw' }
|
|
148
148
|
}),
|
|
149
149
|
card = async () => {
|
|
150
|
-
const html = await (await fetch(`${base}/
|
|
150
|
+
const html = await (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text();
|
|
151
151
|
return html.slice(html.indexOf(`id="resource-card-${reg.resourceId}"`));
|
|
152
152
|
};
|
|
153
153
|
let html = await card();
|
|
@@ -169,7 +169,7 @@ test('Import and Export work for a Client that doesn\'t re-register (no joinKey)
|
|
|
169
169
|
const { base, services, admin } = await start(t),
|
|
170
170
|
{ resourceId, resourceToken } = services.registry.registerAuto({ clientId: 'hw4', name: 'lab-hw-04', type: 'hw', labels: [] }),
|
|
171
171
|
card = async () => {
|
|
172
|
-
const html = await (await fetch(`${base}/
|
|
172
|
+
const html = await (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text();
|
|
173
173
|
return html.slice(html.indexOf(`id="resource-card-${resourceId}"`));
|
|
174
174
|
};
|
|
175
175
|
assert.match(await card(), /Import unavailable: this Client is too old/); // nothing reported, no version known
|
|
@@ -188,7 +188,7 @@ test('Import and Export work for a Client that doesn\'t re-register (no joinKey)
|
|
|
188
188
|
const html = await card();
|
|
189
189
|
assert.match(html, /data-config-import-pick/); // Import enabled
|
|
190
190
|
assert.doesNotMatch(html, /Import unavailable/);
|
|
191
|
-
const exported = await (await fetch(`${base}/
|
|
191
|
+
const exported = await (await fetch(`${base}/runners/${resourceId}/config/export`, { headers: { cookie: admin } })).json();
|
|
192
192
|
assert.equal(exported.joinKey, undefined);
|
|
193
193
|
assert.deepEqual(exported['hw-devices'], { stlinks: [{ index: 1, devpath: '1.1' }] });
|
|
194
194
|
|
|
@@ -238,7 +238,7 @@ test('agent groups are set on the dashboard (Users, CI tokens) and shown by name
|
|
|
238
238
|
assert.match(agents, /<span class="badge text-bg-info">nightly-pool<\/span>/);
|
|
239
239
|
assert.match(groups, /data-bs-title="dev, ci-night"[^>]*>2</); // Agents column: users, then CI tokens
|
|
240
240
|
// The id is never shown as text on a page (it's only in values and URLs).
|
|
241
|
-
for (const html of [users, agents, groups, await page(admin, '/
|
|
241
|
+
for (const html of [users, agents, groups, await page(admin, '/runners'), await page(admin, `/jobs/${job.id}`)]){
|
|
242
242
|
const text = html.replace(/<[^>]*>/g, ' ');
|
|
243
243
|
assert.ok(!text.includes(g.id), 'a group id shows as text');
|
|
244
244
|
}
|
|
@@ -275,3 +275,17 @@ test('CI tokens: the name opens Edit; a new token replaces the old one; delete k
|
|
|
275
275
|
assert.match(await (await form(`/admin/agents/${agent.id}/token`)).headers.get('location'), /agents/); // can't come back
|
|
276
276
|
assert.equal(services.agents.get(agent.id).revoked_at !== null, true);
|
|
277
277
|
});
|
|
278
|
+
|
|
279
|
+
test('Resources renamed Runners: the page is /runners; old /resources links and forms still land', async (t) => {
|
|
280
|
+
const { base, admin } = await start(t),
|
|
281
|
+
old = await fetch(`${base}/resources?q=lab`, { headers: { cookie: admin }, redirect: 'manual' });
|
|
282
|
+
assert.equal(old.status, 301);
|
|
283
|
+
assert.equal(old.headers.get('location'), '/runners?q=lab');
|
|
284
|
+
const post = await fetch(`${base}/resources/abc/rename`, { method: 'POST', headers: { cookie: admin }, redirect: 'manual' });
|
|
285
|
+
assert.equal(post.status, 308); // keeps the method and body
|
|
286
|
+
assert.equal(post.headers.get('location'), '/runners/abc/rename');
|
|
287
|
+
const html = await (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text();
|
|
288
|
+
assert.match(html, /<title>Runners · TestHub<\/title>/);
|
|
289
|
+
assert.match(html, /href="\/runners"[^>]*>(<i [^>]*><\/i>)?Runners</);
|
|
290
|
+
assert.doesNotMatch(html.replace(/<[^>]*>/g, ' '), /\bResources?\b/); // no visible "Resource(s)" left on the page
|
|
291
|
+
});
|
package/test/live.test.js
CHANGED
|
@@ -133,7 +133,7 @@ test('live: /live streams hello and changes; passive re-fetches keep the idle ti
|
|
|
133
133
|
await new Promise((r) => setTimeout(r, 1100));
|
|
134
134
|
|
|
135
135
|
// …survives a passive re-fetch, which also doesn't push the expiry out.
|
|
136
|
-
const passive = await fetch(`${base}/
|
|
136
|
+
const passive = await fetch(`${base}/runners`, { headers: { cookie, 'X-Thub-Live': '1' } });
|
|
137
137
|
assert.equal(passive.status, 200);
|
|
138
138
|
assert.match(passive.headers.get('cache-control'), /no-store/);
|
|
139
139
|
const passiveExpires = expiresOf(passive);
|
|
@@ -149,5 +149,5 @@ test('live: /live streams hello and changes; passive re-fetches keep the idle ti
|
|
|
149
149
|
// passive re-fetch is redirected to the login page.
|
|
150
150
|
await fetch(`${base}/logout`, { method: 'POST', headers: { cookie }, redirect: 'manual' });
|
|
151
151
|
assert.equal((await fetch(`${base}/live`, { headers: { cookie, accept: 'text/event-stream' } })).status, 401);
|
|
152
|
-
assert.equal((await fetch(`${base}/
|
|
152
|
+
assert.equal((await fetch(`${base}/runners`, { headers: { cookie, 'X-Thub-Live': '1' }, redirect: 'manual' })).status, 302);
|
|
153
153
|
});
|
package/test/users.test.js
CHANGED
|
@@ -185,7 +185,7 @@ test('dashboard: only maintainers and admins sign in; security pages are admins\
|
|
|
185
185
|
assert.match(dev.text, /no dashboard access/);
|
|
186
186
|
|
|
187
187
|
const mo = await signIn('mo', 'pw');
|
|
188
|
-
assert.equal((await get(mo.cookie, '/
|
|
188
|
+
assert.equal((await get(mo.cookie, '/runners')).status, 200);
|
|
189
189
|
assert.equal((await get(mo.cookie, '/admin/settings')).status, 403); // admins' only
|
|
190
190
|
for (const url of ['/admin/users', '/admin/agents']){
|
|
191
191
|
assert.equal((await get(mo.cookie, url)).status, 200, url); // within limits — see below
|
|
@@ -195,7 +195,7 @@ test('dashboard: only maintainers and admins sign in; security pages are admins\
|
|
|
195
195
|
|
|
196
196
|
// Blocked while signed in: the next click goes to the sign-in page.
|
|
197
197
|
u.setBlocked(maint.id, true, { actorId: admin.id });
|
|
198
|
-
const after = await get(mo.cookie, '/
|
|
198
|
+
const after = await get(mo.cookie, '/runners');
|
|
199
199
|
assert.equal(after.status, 302);
|
|
200
200
|
assert.equal(after.headers.get('location'), '/login');
|
|
201
201
|
assert.match((await signIn('mo', 'pw')).text, /blocked/);
|
package/views/groups/list.pug
CHANGED
|
@@ -9,7 +9,7 @@ block content
|
|
|
9
9
|
|
|
10
10
|
p.text-body-secondary.small: i.
|
|
11
11
|
Give an agent a group — a user on #[a(href="/admin/users") Users], a CI token on #[a(href="/admin/agents") CI tokens] —
|
|
12
|
-
and its jobs run only on
|
|
12
|
+
and its jobs run only on runners in that group (§13.1). Which groups a Client is in is set on its runner card (Groups tab).
|
|
13
13
|
|
|
14
14
|
if totalCount
|
|
15
15
|
.d-flex.flex-wrap.align-items-center.gap-2.mb-2
|
|
@@ -25,7 +25,7 @@ block content
|
|
|
25
25
|
tr
|
|
26
26
|
th Name
|
|
27
27
|
th Comment
|
|
28
|
-
th
|
|
28
|
+
th Runners
|
|
29
29
|
th Agents
|
|
30
30
|
th Created
|
|
31
31
|
if canManage
|
|
@@ -44,7 +44,7 @@ block content
|
|
|
44
44
|
form.d-inline.ms-1(
|
|
45
45
|
method="post"
|
|
46
46
|
action=`/groups/${g.id}/delete`
|
|
47
|
-
data-confirm=`Delete group "${g.name}"?
|
|
47
|
+
data-confirm=`Delete group "${g.name}"? Runners listing it just stop matching on it, and its agents go back to running their jobs on any runner.`
|
|
48
48
|
data-confirm-title="Delete group?"
|
|
49
49
|
data-confirm-ok="Delete group"
|
|
50
50
|
)
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
-
|
|
19
19
|
const groups = [
|
|
20
20
|
{ title: 'Where to run', rows: [
|
|
21
|
-
['--type hw|sw', 'Required.
|
|
21
|
+
['--type hw|sw', 'Required. Runner type.', '--type hw'],
|
|
22
22
|
['--board <name>', 'Shorthand for --label board:<name>.', '--board nucleo-f401re'],
|
|
23
23
|
['--label <l>', 'Required label (repeatable). The Client must have all of them.', '--label uart --label stlink'],
|
|
24
24
|
['--client <name|id>', 'Only this Client; waits for it even if others are idle.', '--client lab-hw-01']
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
thub config set key thk_…
|
|
45
45
|
thub config set user "Your Name" # optional: job owner label
|
|
46
46
|
p.small.
|
|
47
|
-
These are saved in #[code ~/.config/thub/agent.json]. Which
|
|
47
|
+
These are saved in #[code ~/.config/thub/agent.json]. Which runner group your jobs run in isn't an Agent
|
|
48
48
|
setting: an admin or maintainer sets it for your user on the dashboard (#[code thub whoami] shows it). Order of precedence: flags (#[code --url], #[code --key]) →
|
|
49
49
|
environment → config file.
|
|
50
50
|
table.table.table-sm.small
|
|
@@ -40,14 +40,14 @@
|
|
|
40
40
|
lsusb -t # USB tree
|
|
41
41
|
udevadm info -a -n /dev/ttyUSB0 | grep -m1 'ATTRS{devpath}'
|
|
42
42
|
st-info --probe # ST-Link serials
|
|
43
|
-
| Or open the
|
|
43
|
+
| Or open the runner card → #[strong Connected USB devices] → #[strong Refresh], then #[strong Import to config].
|
|
44
44
|
li
|
|
45
45
|
| Put the devpaths into #[code hw-devices] and restart. On every start the Client writes
|
|
46
46
|
| #[code /etc/udev/rules.d/99-thub-<instance>.rules], so the devices appear as #[code /dev/thub/dut<N>-stlink|uart|usb]:
|
|
47
47
|
+code('HW machine').
|
|
48
48
|
sudo systemctl restart thub-client@client
|
|
49
49
|
ls -l /dev/thub/
|
|
50
|
-
li Check the
|
|
50
|
+
li Check the runner card's #[strong Capabilities]: each configured device must be present (a missing one is flagged).
|
|
51
51
|
li Your job's #[code --command] flashes the board. The Client never flashes by itself; it passes the ST-Link serials and the downloaded files in the environment (#[a(href="#env") Environment variables]).
|
|
52
52
|
+code('A typical HW job').
|
|
53
53
|
thub run --type hw --board nucleo-f401re \
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
p.small.
|
|
72
72
|
To scale out, clone the VM template. In the clone, #[strong before its first start], run
|
|
73
73
|
#[code rm -f ~/var/lib/thub/client/*/.client-id ~/var/lib/thub/client/*.token] and give it its own #[code name].
|
|
74
|
-
Otherwise both VMs register as the same
|
|
74
|
+
Otherwise both VMs register as the same runner.
|
|
75
75
|
+code('A typical SW job').
|
|
76
76
|
thub run --type sw \
|
|
77
77
|
--docker-image registry.lab.local:5000/dut-emulator:2026.08 \
|
|
@@ -84,4 +84,4 @@
|
|
|
84
84
|
li Outbound HTTPS to the Coordinator; test it with #[code curl -sI #{coordinatorUrl}/login].
|
|
85
85
|
li The Client's service user has SSH keys and git credentials for private repos (#[a(href="#git") Git repositories]) and Docker logins for private registries (#[a(href="#docker") Docker registry]).
|
|
86
86
|
li Under systemd, the service can only write to its state directory. Jobs write to #[code $THUB_WORK_DIR], not to #[code ~].
|
|
87
|
-
li Optional: a nightly reboot from the
|
|
87
|
+
li Optional: a nightly reboot from the runner card's #[strong Reboot] tab (cron, host-local time). It waits for running jobs.
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
tr
|
|
75
75
|
td: code name
|
|
76
76
|
td No
|
|
77
|
-
td
|
|
77
|
+
td Runner name. Under systemd, the instance name (#[code thub-client@dut1] → #[code dut1]) is used unless an admin renames it on the dashboard.
|
|
78
78
|
tr
|
|
79
79
|
td: code labels
|
|
80
80
|
td No
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
tr
|
|
83
83
|
td: code groups
|
|
84
84
|
td No
|
|
85
|
-
td The
|
|
85
|
+
td The runner groups this Client belongs to. Set them on its runner card (Groups tab), which writes them here.
|
|
86
86
|
tr
|
|
87
87
|
td: code varDir
|
|
88
88
|
td No
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
td An explicit identity UUID (or #[code THUB_CLIENT_ID]). Normally generated once and kept in #[code .client-id].
|
|
102
102
|
p.small.
|
|
103
103
|
SW Clients have no settings of their own: they run whatever DUT image a job brings (#[code --docker-image]).
|
|
104
|
-
Admins can also edit an HW Client's devices from its
|
|
104
|
+
Admins can also edit an HW Client's devices from its runner card (#[strong ST-Link | UART | DUT USB]
|
|
105
105
|
tabs). The tabs show the config the Client runs, as it reported it at its last start, or a saved
|
|
106
106
|
change it hasn't applied yet. #[strong Connected USB devices → Import to config] fills them from the host's #[code lsusb].
|
|
107
107
|
The card's #[strong Export] and #[strong Import] icons download a Client's config file (never its
|
|
@@ -63,13 +63,13 @@
|
|
|
63
63
|
td The shared secret Clients self-register with. Unset or #[code null] turns registration off (#[code 503]).
|
|
64
64
|
tr
|
|
65
65
|
td: code heartbeat.*
|
|
66
|
-
td Clients heartbeat every #[code intervalSec] — the Coordinator tells them, so it's set here only (and on Settings). After #[code missedLimit] misses a
|
|
66
|
+
td Clients heartbeat every #[code intervalSec] — the Coordinator tells them, so it's set here only (and on Settings). After #[code missedLimit] misses a runner goes #[code OUT_OF_SERVICE] and its job becomes #[code LOST]; #[code sweepIntervalSec] is how often that's checked.
|
|
67
67
|
tr
|
|
68
68
|
td: code scheduler.*
|
|
69
69
|
td #[code assignAckTimeoutSec]: how long an assignment waits for the Client to accept. #[code requeueOnLost]: retry a #[code LOST] job once. #[code maxQueuedPerAgent]: queue limit per agent.
|
|
70
70
|
tr
|
|
71
71
|
td: code rateLimit.*
|
|
72
|
-
td Requests per minute per caller (agent/
|
|
72
|
+
td Requests per minute per caller (agent/runner token, dashboard user, else IP; default 500), and sign-in attempts per IP (default 10). Past it: #[code 429] with #[code Retry-After]. #[code 0] turns a limit off.
|
|
73
73
|
tr
|
|
74
74
|
td: code jobs.*
|
|
75
75
|
td Default and maximum job timeout, in seconds.
|
|
@@ -152,5 +152,5 @@
|
|
|
152
152
|
p.small.mb-0.
|
|
153
153
|
The Coordinator checks npm for new Coordinator, Agent and Client versions. Admins update the Coordinator
|
|
154
154
|
with #[strong Update app] in the navbar, and Agents and Clients from the #[a(href="/admin/agents") Agents]
|
|
155
|
-
and #[a(href="/
|
|
155
|
+
and #[a(href="/runners") Runners] pages. Clients install an update only between jobs.
|
|
156
156
|
Manual alternatives: #[code thub-admin self-update], #[code thub self-update] and #[code thub-client self-update].
|
package/views/help/_docker.pug
CHANGED
|
@@ -121,4 +121,4 @@
|
|
|
121
121
|
td Files written to #[code /work] stay owned by the Client user, so the workspace can be cleaned up.
|
|
122
122
|
+note('info').
|
|
123
123
|
Docker access is effectively root access on that host. The Client host must have Docker and the service user must be in the
|
|
124
|
-
#[code docker] group. The
|
|
124
|
+
#[code docker] group. The runner card's #[strong Capabilities] show whether a Client can use Docker, and why not.
|
package/views/help/_overview.pug
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
i.bi.bi-hdd-network.me-1
|
|
13
13
|
| Coordinator
|
|
14
14
|
p.card-text.small.mb-0.
|
|
15
|
-
This service. It holds the job queue and the
|
|
15
|
+
This service. It holds the job queue and the runner registry, and runs the scheduler and
|
|
16
16
|
the heartbeat monitor. It stores job state and logs in SQLite,
|
|
17
17
|
and serves this dashboard. It's the only thing that needs a public HTTPS port.
|
|
18
18
|
.col-md-4
|
|
@@ -51,13 +51,13 @@
|
|
|
51
51
|
li #[strong The Coordinator is the single source of truth.] It assigns a job inside a single SQLite transaction, so a job can never be assigned twice.
|
|
52
52
|
li #[strong A job is a command, not a script upload.] The Client runs your #[code --command] as its service user, never as root. Issue agent tokens only to trusted pipelines and people.
|
|
53
53
|
|
|
54
|
-
h3.h6
|
|
54
|
+
h3.h6 Runner and job states
|
|
55
55
|
.row.g-3.small
|
|
56
56
|
.col-md-6
|
|
57
57
|
table.table.table-sm.mb-0
|
|
58
58
|
thead
|
|
59
59
|
tr
|
|
60
|
-
th
|
|
60
|
+
th Runner
|
|
61
61
|
th Meaning
|
|
62
62
|
tbody
|
|
63
63
|
tr
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
sudo npm i -g @andrian.yablonskyy/thub-client
|
|
19
19
|
nano ~/.config/thub/client.json # coordinatorUrl, type, joinKey
|
|
20
20
|
sudo systemctl enable --now thub-client@client
|
|
21
|
-
| It shows up on #[a(href="/
|
|
21
|
+
| It shows up on #[a(href="/runners") Runners] by itself, usually within 10 s.
|
|
22
22
|
li
|
|
23
23
|
strong Create users and their access keys
|
|
24
24
|
| on #[a(href="/admin/users") Users] (admins and maintainers), or on the Coordinator host with
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
//- <code>, with everything else HTML-escaped first (so `<instance>` shows as text).
|
|
4
4
|
-
|
|
5
5
|
const faq = [
|
|
6
|
-
['A Client doesn\'t appear on
|
|
7
|
-
['A Client flaps or two machines show as one
|
|
8
|
-
['Name already in use (`409`)', 'Another live Client holds that name. Rename one of them in its config or from the
|
|
6
|
+
['A Client doesn\'t appear on Runners', 'Check `journalctl -u thub-client@<instance> -f`. A `503` at registration means the Coordinator has no `clientJoinKey`; a `401` means the `joinKey` differs. Also check that `coordinatorUrl` is reachable from the lab (`curl -sI <url>/login`).'],
|
|
7
|
+
['A Client flaps or two machines show as one runner', 'Both use the same `.client-id` (a cloned VM or a shared config file). Delete `<varDir>/<instance>/.client-id` on one of them and restart it, or set a distinct `clientId`.'],
|
|
8
|
+
['Name already in use (`409`)', 'Another live Client holds that name. Rename one of them in its config or from the runner card. A name held by an `OUT_OF_SERVICE` runner is reclaimed automatically.'],
|
|
9
9
|
['The job is rejected with `422`', 'No registered Client can ever match its type, labels, group or client. Compare `thub resources` with your `--type` and `--board`/`--label`; your group (set on the dashboard, see `thub whoami`) must have a Client of that kind.'],
|
|
10
10
|
['The job stays `QUEUED`', 'Matching Clients are all `BUSY`, locked (`local`), in `MAINTENANCE` or offline, or the job is pinned with `--client`. It times out after `--timeout`.'],
|
|
11
11
|
['`ERROR` during prepare', 'The clone, download or DUT image pull failed; the job log names the reason. For git, check the Client user\'s keys and `known_hosts` (`ssh -T git@<host>` as that user). For images, check the Client user\'s `docker login`.'],
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
['Job `LOST`', 'The Client missed 3 heartbeats (network, reboot, crash). With `requeueOnLost` the job is retried once on another Client.'],
|
|
15
15
|
['A job can\'t write to `~` (read-only file system)', 'Under systemd the Client can only write to its state directory. Write to `$THUB_WORK_DIR`, and set `DOCKER_CONFIG` there for `docker login`.'],
|
|
16
16
|
['Live logs don\'t stream through the proxy', 'Turn off response buffering for the Coordinator (nginx: `proxy_buffering off;`, Caddy: `flush_interval -1`).'],
|
|
17
|
-
['Finding a job,
|
|
17
|
+
['Finding a job, runner, group or agent', 'Use the search box on its list page (press `/` to jump to it). Every word must match, e.g. `lab-hw-01 FAILED` or a git ref. Job search covers ids, users, runners, agents, commands, repos and `--meta`, but not `--env` values (secrets). On a job page, search the log above the log viewer: `Enter`/`Shift+Enter` step through matches.'],
|
|
18
18
|
['Lists don\'t update by themselves (navbar shows Offline or Paused)', '`Paused`: you\'re editing something on the page (a field, the inline rename, a dialog); it resumes when you\'re done. `Offline`: the browser can\'t hold the `/live` stream open, often a proxy buffering it (nginx: `proxy_buffering off;`); reloading the page always shows current data. `Session ended`: sign in again. Background updates never extend the session\'s idle timeout.'],
|
|
19
19
|
['Sign-in says it needs HTTPS', 'The session cookie is HTTPS-only because `publicUrl` is `https://`, but the request arrived as plain HTTP. Open the dashboard through its https:// address; behind a reverse proxy, make it send `X-Forwarded-Proto: https` (nginx: `proxy_set_header X-Forwarded-Proto $scheme;`) and check `trustProxy`. To allow plain HTTP, set `session.secureCookie` to `false`.'],
|
|
20
20
|
['Where are a job\'s test reports and output files?', 'The Coordinator keeps only the log and the result (verdict, exit code, JUnit test counts). Files the job creates stay in its workspace on the Client and are deleted when the job ends. To keep them, publish them from `--command`, e.g. `curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T results/report.xml "https://artifactory.example.com/qa/$THUB_JOB_ID/report.xml"` with `--env ART_TOKEN`. To show them on the job page and in `thub status`, list them in `$THUB_ARTIFACTS_FILE` as `[{"name", "size", "link", "timestamp"}]` — only that metadata is kept. The full log is always available from the job page\'s Raw button.'],
|
|
21
|
-
['429 Too Many Requests', 'A caller went over `rateLimit.requestsPerMinute` (per agent or
|
|
21
|
+
['429 Too Many Requests', 'A caller went over `rateLimit.requestsPerMinute` (per agent or runner token, per dashboard user, else per IP; default 500). The Agent and Client wait for `Retry-After` and retry by themselves; a script calling the API should do the same. Raise the limit in the config file or on Settings if your normal traffic needs more (a Client streaming logs makes up to ~240 requests a minute). Too many sign-in attempts from one address: wait a minute (`rateLimit.loginPerMinute`).'],
|
|
22
22
|
['Sign-in says “Too many failed sign-ins”', 'After repeated wrong passwords the Coordinator makes each further attempt wait, doubling each time: per address from the 5th failure (up to 15 min), per username from the 10th from any address (up to 5 min). Wait the time shown, or sign in from another address; a successful sign-in clears it, and an hour without failures forgets it. The Coordinator never tells whether a username exists.'],
|
|
23
23
|
['Agent says “Invalid or revoked access key” or “… is blocked”', 'The key was replaced (`thub key rotate`, an admin, your profile) or revoked, or your user is blocked. Ask an admin for a new key (Users), or create one on your profile if you can sign in. Then `thub config set key <key>`; `thub whoami` checks it.'],
|
|
24
24
|
['The Coordinator won\'t start after an upgrade: `SqliteError: disk I/O error` (`SQLITE_IOERR_GETTEMPPATH`)', 'Its systemd unit is older than 1.1.1: it has `ProtectSystem=strict` without `PrivateTmp=yes`, so SQLite has no writable temp directory for a large migration. The migration rolls back, so no data is lost. Install the latest version with `sudo npm i -g @andrian.yablonskyy/thub-coordinator` (the dashboard can\'t update a Coordinator that\'s down), or run `sudo systemctl edit thub-coordinator`, add `[Service]` and `PrivateTmp=yes`, then `sudo systemctl restart thub-coordinator`.'],
|
package/views/index.pug
CHANGED
|
@@ -21,7 +21,7 @@ block content
|
|
|
21
21
|
.col-md-4
|
|
22
22
|
.card
|
|
23
23
|
.card-body
|
|
24
|
-
h6.card-subtitle.mb-2.text-body-secondary
|
|
24
|
+
h6.card-subtitle.mb-2.text-body-secondary Runners online
|
|
25
25
|
p.display-6= `${onlineCount} / ${resources.length}`
|
|
26
26
|
|
|
27
27
|
.row.g-3(data-live="overview-cards")
|
package/views/jobs/list.pug
CHANGED
|
@@ -13,7 +13,7 @@ block content
|
|
|
13
13
|
|
|
14
14
|
form.row.g-2.mb-3(method="get" role="search")
|
|
15
15
|
.col-12.col-md-5.col-lg-4
|
|
16
|
-
+searchBox(filters.q, 'Search jobs: id, user,
|
|
16
|
+
+searchBox(filters.q, 'Search jobs: id, user, runner, command, repo…', list.urlFor({ q: '' }))
|
|
17
17
|
.col-auto
|
|
18
18
|
select.form-select.form-select-sm(name="state" data-autosubmit)
|
|
19
19
|
option(value="") any state
|
|
@@ -37,7 +37,7 @@ block content
|
|
|
37
37
|
+sortHeader(list, 'source', 'Source')
|
|
38
38
|
+sortHeader(list, 'user', 'User')
|
|
39
39
|
+sortHeader(list, 'state', 'State')
|
|
40
|
-
+sortHeader(list, 'resource', '
|
|
40
|
+
+sortHeader(list, 'resource', 'Runner')
|
|
41
41
|
+sortHeader(list, 'created', 'Created', 'desc')
|
|
42
42
|
+sortHeader(list, 'duration', 'Duration', 'desc')
|
|
43
43
|
if canManage
|
|
@@ -132,7 +132,7 @@ block content
|
|
|
132
132
|
data-value=preset.value
|
|
133
133
|
)= preset.label
|
|
134
134
|
p
|
|
135
|
-
| Deletes finished jobs that ended before then, with their logs, and the
|
|
135
|
+
| Deletes finished jobs that ended before then, with their logs, and the runner, agent and group history logged before then — then compacts the database file.
|
|
136
136
|
|
|
|
137
137
|
strong This cannot be undone.
|
|
138
138
|
| Active jobs are never touched.
|
package/views/jobs/show.pug
CHANGED
|
@@ -29,7 +29,7 @@ block content
|
|
|
29
29
|
if job.spec.user
|
|
30
30
|
dt.col-sm-2 User
|
|
31
31
|
dd.col-sm-10= job.spec.user
|
|
32
|
-
dt.col-sm-2
|
|
32
|
+
dt.col-sm-2 Runner
|
|
33
33
|
dd.col-sm-10= job.resource ? job.resource.name : job.resource_name ? `${job.resource_name} (removed)` : '—'
|
|
34
34
|
dt.col-sm-2 Target
|
|
35
35
|
dd.col-sm-10= `${job.spec.target.type} — ${(job.spec.target.labels || []).join(', ')}`
|
package/views/layout.pug
CHANGED
|
@@ -53,7 +53,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
53
53
|
//- Below lg the menu drops down over the page (thub.css .thub-nav,
|
|
54
54
|
//- closed by public/js/actions.js): pages as a two-column grid with
|
|
55
55
|
//- icons, then the tools in a row, then the account row.
|
|
56
|
-
- const pages = user ? [['/', 'overview', 'Overview', 'speedometer2'], ['/
|
|
56
|
+
- const pages = user ? [['/', 'overview', 'Overview', 'speedometer2'], ['/runners', 'resources', 'Runners', 'hdd-rack'], ['/groups', 'groups', 'Groups', 'collection'], ['/jobs', 'jobs', 'Jobs', 'list-task'], ['/admin/users', 'users', 'Users', 'people'], ['/admin/agents', 'agents', 'CI tokens', 'key']].concat(user.role === 'admin' ? [['/admin/settings', 'settings', 'Settings', 'gear']] : []) : []
|
|
57
57
|
ul.navbar-nav.me-auto.thub-nav-pages
|
|
58
58
|
each pg in pages
|
|
59
59
|
li.nav-item
|
|
@@ -88,7 +88,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
88
88
|
aria-label="Check for updates"
|
|
89
89
|
data-bs-toggle="tooltip"
|
|
90
90
|
data-bs-placement="bottom"
|
|
91
|
-
data-bs-title=updates.checkedAt ? `Check for updates now — the Coordinator and connected
|
|
91
|
+
data-bs-title=updates.checkedAt ? `Check for updates now — the Coordinator and connected runners (last checked ${fmtDate(updates.checkedAt)})` : 'Check for updates now — the Coordinator and connected runners'
|
|
92
92
|
)
|
|
93
93
|
i.bi.bi-arrow-repeat
|
|
94
94
|
if user && can.admin && updates.coordinatorUpdate
|
|
@@ -129,7 +129,7 @@ mixin groupsPane(r, returnTo, groupsById)
|
|
|
129
129
|
else
|
|
130
130
|
form(
|
|
131
131
|
method="post"
|
|
132
|
-
action=`/
|
|
132
|
+
action=`/runners/${r.id}/groups`
|
|
133
133
|
data-confirm=`Save the groups of ${r.name}? They apply at once; the Client also writes them to its config file and restarts once it has no job running.`
|
|
134
134
|
data-confirm-title="Save Client groups?"
|
|
135
135
|
data-confirm-ok="Save"
|
|
@@ -173,7 +173,7 @@ mixin configPanes(r, returnTo, groupsById)
|
|
|
173
173
|
else
|
|
174
174
|
form(
|
|
175
175
|
method="post"
|
|
176
|
-
action=`/
|
|
176
|
+
action=`/runners/${r.id}/config`
|
|
177
177
|
data-client-config="hw"
|
|
178
178
|
data-confirm=`Apply these devices to ${r.name}? The Client writes them to its config file on its next heartbeat and restarts once it has no job running.`
|
|
179
179
|
data-confirm-title="Save Client devices?"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//- --group. Groups by name; none = any resource.
|
|
4
4
|
mixin groupSelect(id, selected, groups)
|
|
5
5
|
select.form-select(id=id name="groupId")
|
|
6
|
-
option(value="" selected=!selected) Any
|
|
6
|
+
option(value="" selected=!selected) Any runner (no group)
|
|
7
7
|
each g in groups
|
|
8
8
|
option(value=g.id selected=g.id === selected)= g.name
|
|
9
|
-
.form-text Its jobs run only on
|
|
9
|
+
.form-text Its jobs run only on runners in this group. Manage groups on #[a(href="/groups") Groups].
|
|
@@ -6,7 +6,7 @@ include client-config
|
|
|
6
6
|
|
|
7
7
|
//- Update isn't here: it sits next to the version (+versionWithUpdate).
|
|
8
8
|
mixin resourceActions(r, returnTo)
|
|
9
|
-
form.d-inline(method="post" action=`/
|
|
9
|
+
form.d-inline(method="post" action=`/runners/${r.id}/maintenance`)
|
|
10
10
|
input(type="hidden" name="enabled" value=r.status === 'MAINTENANCE' ? '0' : '1')
|
|
11
11
|
input(type="hidden" name="returnTo" value=returnTo)
|
|
12
12
|
if r.status === 'MAINTENANCE'
|
|
@@ -14,7 +14,7 @@ mixin resourceActions(r, returnTo)
|
|
|
14
14
|
type="submit"
|
|
15
15
|
aria-label="Enable"
|
|
16
16
|
data-bs-toggle="tooltip"
|
|
17
|
-
data-bs-title="Bring this
|
|
17
|
+
data-bs-title="Bring this runner back into rotation so the scheduler can assign it jobs again"
|
|
18
18
|
)
|
|
19
19
|
i.bi.bi-play-circle
|
|
20
20
|
else
|
|
@@ -22,14 +22,14 @@ mixin resourceActions(r, returnTo)
|
|
|
22
22
|
type="submit"
|
|
23
23
|
aria-label="Disable"
|
|
24
24
|
data-bs-toggle="tooltip"
|
|
25
|
-
data-bs-title="Take this
|
|
25
|
+
data-bs-title="Take this runner out of rotation for maintenance — the scheduler will skip it"
|
|
26
26
|
)
|
|
27
27
|
i.bi.bi-tools
|
|
28
28
|
//- Credentials: admins only (§10.3).
|
|
29
29
|
if can.admin
|
|
30
30
|
form.d-inline.ms-1(
|
|
31
31
|
method="post"
|
|
32
|
-
action=`/
|
|
32
|
+
action=`/runners/${r.id}/rotate-token`
|
|
33
33
|
data-confirm=`Issue a new token for ${r.name}? Its current token stops working at once, and the Client won't reconnect until it has the new one.`
|
|
34
34
|
data-confirm-title="Rotate resource token?"
|
|
35
35
|
data-confirm-ok="Rotate token"
|
|
@@ -58,7 +58,7 @@ mixin removeResourceButton(r, returnTo)
|
|
|
58
58
|
type="button"
|
|
59
59
|
aria-label="Remove"
|
|
60
60
|
data-bs-toggle="tooltip"
|
|
61
|
-
data-bs-title="Remove this
|
|
61
|
+
data-bs-title="Remove this runner from the list — it's running a job"
|
|
62
62
|
data-remove-resource=r.id
|
|
63
63
|
data-remove-name=r.name
|
|
64
64
|
data-remove-job=r.activeJob.id
|
|
@@ -68,7 +68,7 @@ mixin removeResourceButton(r, returnTo)
|
|
|
68
68
|
else
|
|
69
69
|
form.d-inline.ms-1(
|
|
70
70
|
method="post"
|
|
71
|
-
action=`/
|
|
71
|
+
action=`/runners/${r.id}/remove`
|
|
72
72
|
data-confirm=`Remove ${r.name} from the list? Its job history is kept. A Client that's still running shows up again when it restarts and re-registers.`
|
|
73
73
|
data-confirm-title=`Remove ${r.name}?`
|
|
74
74
|
data-confirm-ok="Remove"
|
|
@@ -78,7 +78,7 @@ mixin removeResourceButton(r, returnTo)
|
|
|
78
78
|
type="submit"
|
|
79
79
|
aria-label="Remove"
|
|
80
80
|
data-bs-toggle="tooltip"
|
|
81
|
-
data-bs-title="Remove this
|
|
81
|
+
data-bs-title="Remove this runner from the list"
|
|
82
82
|
)
|
|
83
83
|
i.bi.bi-trash
|
|
84
84
|
|
|
@@ -110,7 +110,7 @@ mixin removeResourceModal()
|
|
|
110
110
|
|
|
|
111
111
|
code CANCELED
|
|
112
112
|
| ), then
|
|
113
|
-
li remove the
|
|
113
|
+
li remove the runner as soon as its Client confirms the job stopped (at most about a minute if the Client doesn't answer).
|
|
114
114
|
p.small.text-body-secondary.mb-0 Its job history is kept. A Client that's still running shows up again when it restarts and re-registers.
|
|
115
115
|
.modal-footer
|
|
116
116
|
button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Keep it
|
|
@@ -231,7 +231,7 @@ mixin rebootScheduleRow(r, returnTo)
|
|
|
231
231
|
if can.operate
|
|
232
232
|
form.d-flex.gap-1.mt-1(
|
|
233
233
|
method="post"
|
|
234
|
-
action=`/
|
|
234
|
+
action=`/runners/${r.id}/reboot-schedule`
|
|
235
235
|
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.`
|
|
236
236
|
data-confirm-title="Schedule host reboots?"
|
|
237
237
|
data-confirm-ok="Save schedule"
|
|
@@ -248,7 +248,7 @@ mixin rebootScheduleRow(r, returnTo)
|
|
|
248
248
|
)
|
|
249
249
|
button.btn.btn-sm.btn-outline-primary(type="submit" data-bs-toggle="tooltip" data-bs-title="Save this schedule — the Client applies it on its next heartbeat") Save
|
|
250
250
|
if r.reboot_schedule
|
|
251
|
-
form.d-inline(method="post" action=`/
|
|
251
|
+
form.d-inline(method="post" action=`/runners/${r.id}/reboot-schedule`)
|
|
252
252
|
input(type="hidden" name="returnTo" value=returnTo)
|
|
253
253
|
input(type="hidden" name="clear" value="1")
|
|
254
254
|
button.btn.btn-sm.btn-link.p-0.small(type="submit" data-bs-toggle="tooltip" data-bs-title="Remove the schedule — the host isn't rebooted on a schedule any more") Clear schedule
|
|
@@ -270,7 +270,7 @@ mixin cancelActivityButton(r, returnTo)
|
|
|
270
270
|
if what
|
|
271
271
|
form.d-inline.ms-2(
|
|
272
272
|
method="post"
|
|
273
|
-
action=`/
|
|
273
|
+
action=`/runners/${r.id}/cancel-activity`
|
|
274
274
|
data-confirm=`Cancel ${what} on ${r.name}?`
|
|
275
275
|
data-confirm-title=`Cancel ${what}?`
|
|
276
276
|
data-confirm-ok=`Cancel ${what}`
|
|
@@ -338,7 +338,7 @@ mixin usbScanPane(r)
|
|
|
338
338
|
//- Import to config (public/js/usb-import.js): HW Clients that report
|
|
339
339
|
//- their config only; enabled once there's a scan to import from.
|
|
340
340
|
- const importWhy = r.type !== 'hw' ? 'Only HW Clients have USB device lists to import into' : !(r.config_desired || r.client_config) ? noConfigReport[0].toUpperCase() + noConfigReport.slice(1) : null
|
|
341
|
-
div(data-usb-scan=`/
|
|
341
|
+
div(data-usb-scan=`/runners/${r.id}/usb-scan` data-pending=r.usb_scan_request_id ? '1' : null data-requested-at=r.usb_scan_requested_at)
|
|
342
342
|
.d-flex.align-items-center.gap-2.mb-2
|
|
343
343
|
if can.operate
|
|
344
344
|
//- Live (public/js/live.js): a Client that comes back online gets its
|
|
@@ -381,7 +381,7 @@ mixin rebootButton(r, returnTo)
|
|
|
381
381
|
else
|
|
382
382
|
form.d-inline.ms-1(
|
|
383
383
|
method="post"
|
|
384
|
-
action=`/
|
|
384
|
+
action=`/runners/${r.id}/reboot`
|
|
385
385
|
data-confirm=r.activeJob
|
|
386
386
|
? `${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.`
|
|
387
387
|
: `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.`
|
|
@@ -405,7 +405,7 @@ mixin rebootButton(r, returnTo)
|
|
|
405
405
|
//- a whole file, or an older HW Client's devices only.
|
|
406
406
|
mixin configTransferButtons(r, returnTo)
|
|
407
407
|
a.btn.btn-sm.btn-outline-secondary.ms-1(
|
|
408
|
-
href=`/
|
|
408
|
+
href=`/runners/${r.id}/config/export`
|
|
409
409
|
download
|
|
410
410
|
aria-label=`Export ${r.name}'s config`
|
|
411
411
|
data-bs-toggle="tooltip"
|
|
@@ -419,7 +419,7 @@ mixin configTransferButtons(r, returnTo)
|
|
|
419
419
|
else
|
|
420
420
|
form.d-inline.ms-1(
|
|
421
421
|
method="post"
|
|
422
|
-
action=`/
|
|
422
|
+
action=`/runners/${r.id}/config/import`
|
|
423
423
|
data-config-import
|
|
424
424
|
data-resource-name=r.name
|
|
425
425
|
data-confirm-title=`Import config into ${r.name}?`
|
|
@@ -461,7 +461,7 @@ mixin resourceDetails(r, groupsById, returnTo, local, external)
|
|
|
461
461
|
)
|
|
462
462
|
i.bi.bi-pencil(data-bs-toggle="tooltip" data-bs-title="Rename")
|
|
463
463
|
.collapse(id=`rename-${r.id}`)
|
|
464
|
-
form.d-flex.gap-1.mt-1(method="post" action=`/
|
|
464
|
+
form.d-flex.gap-1.mt-1(method="post" action=`/runners/${r.id}/rename`)
|
|
465
465
|
input(type="hidden" name="returnTo" value=returnTo)
|
|
466
466
|
input.form-control.form-control-sm(
|
|
467
467
|
name="name"
|
|
@@ -479,7 +479,7 @@ mixin resourceDetails(r, groupsById, returnTo, local, external)
|
|
|
479
479
|
dd.col-sm-8: +resourceBadge(r.status, r.remove_requested_at)
|
|
480
480
|
+uptimeRows(r, returnTo)
|
|
481
481
|
dt.col-sm-4 Client version
|
|
482
|
-
dd.col-sm-8: +versionWithUpdate(r.client_version, updates.latest.client, r.update_to, `/
|
|
482
|
+
dd.col-sm-8: +versionWithUpdate(r.client_version, updates.latest.client, r.update_to, `/runners/${r.id}/update`, returnTo, true, r.activity && r.activity.state === 'update-hold')
|
|
483
483
|
dt.col-sm-4 IP addresses
|
|
484
484
|
dd.col-sm-8
|
|
485
485
|
if !local.length && !external
|
package/views/mixins/updates.pug
CHANGED
|
@@ -48,7 +48,7 @@ mixin updatesToolbar(app, label, returnTo)
|
|
|
48
48
|
| Check for updates
|
|
49
49
|
form.d-inline(
|
|
50
50
|
method="post"
|
|
51
|
-
action=app === 'agent' ? '/admin/agents/update-all' : '/
|
|
51
|
+
action=app === 'agent' ? '/admin/agents/update-all' : '/runners/update-all'
|
|
52
52
|
data-confirm=`Request a self-update of every ${label} to the latest version${updates.latest[app] ? ` (v${updates.latest[app]})` : ''}?`
|
|
53
53
|
data-confirm-title=`Update all ${label}?`
|
|
54
54
|
data-confirm-ok="Request update"
|
package/views/resources/list.pug
CHANGED
|
@@ -5,19 +5,19 @@ include ../mixins/updates
|
|
|
5
5
|
include ../mixins/list-controls
|
|
6
6
|
|
|
7
7
|
block content
|
|
8
|
-
h1.h4.mb-3
|
|
8
|
+
h1.h4.mb-3 Runners
|
|
9
9
|
.d-flex.flex-wrap.align-items-center.gap-2.mb-3
|
|
10
|
-
+updatesToolbar('client', 'clients', '/
|
|
10
|
+
+updatesToolbar('client', 'clients', '/runners')
|
|
11
11
|
p.text-body-secondary.small: i.
|
|
12
|
-
|
|
12
|
+
Runners register themselves — start a Client with the shared join key and it appears here. Click a runner for its details, including IP addresses.
|
|
13
13
|
|
|
14
14
|
.d-flex.flex-wrap.align-items-center.gap-2.mb-3
|
|
15
|
-
+searchForm(list.q, 'Search
|
|
15
|
+
+searchForm(list.q, 'Search runners: name, label, group, status, IP…', list.urlFor({ q: '' }))
|
|
16
16
|
.ms-auto
|
|
17
17
|
+pageSizePicker(list)
|
|
18
18
|
|
|
19
19
|
div(data-live="list-toolbar")
|
|
20
|
-
+listToolbar(list, '
|
|
20
|
+
+listToolbar(list, 'runners')
|
|
21
21
|
|
|
22
22
|
table.table.table-hover.align-middle
|
|
23
23
|
thead
|
|
@@ -37,7 +37,7 @@ block content
|
|
|
37
37
|
tr(role="button" tabindex="0" data-resource-card=`resource-card-${r.id}`)
|
|
38
38
|
td= r.name
|
|
39
39
|
td.text-uppercase= r.type
|
|
40
|
-
td: +versionWithUpdate(r.client_version, updates.latest.client, r.update_to, `/
|
|
40
|
+
td: +versionWithUpdate(r.client_version, updates.latest.client, r.update_to, `/runners/${r.id}/update`, list.pageUrl(), true, r.activity && r.activity.state === 'update-hold')
|
|
41
41
|
td: +resourceBadge(r.status, r.remove_requested_at)
|
|
42
42
|
td
|
|
43
43
|
each l in r.labels
|