@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 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
- | `/` | Resource cards by status, queue length, jobs in the last 24 h. Click a card for its resource card (below). |
153
- | `/resources` | 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). |
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
- **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; 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).
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.8",
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 = `/resources/${encodeURIComponent(id)}/remove`;
33
- field('return').value = returnTo || '/resources';
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('/resources', (req, res) => {
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: 'Resources',
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('/resources/:id/maintenance', (req, res) => {
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, '/resources'));
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('/resources/:id/cancel-activity', (req, res) => {
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 resource'), { status: 404 });
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, '/resources'));
584
+ res.redirect(returnTo(req, '/runners'));
576
585
  });
577
586
 
578
- router.post('/resources/:id/remove', (req, res) => {
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, '/resources'));
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('/resources/:id/reboot', (req, res) => {
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, '/resources'));
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('/resources/:id/usb-scan', (req, res) => {
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('/resources/:id/usb-scan', (req, res) => {
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 resource' });
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('/resources/:id/rename', (req, res) => {
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, '/resources'));
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('/resources/:id/config', (req, res) => {
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, '/resources'));
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('/resources/:id/groups', (req, res) => {
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, '/resources'));
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('/resources/:id/config/export', (req, res) => {
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('/resources');
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('/resources/:id/config/import', (req, res) => {
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, '/resources'));
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('/resources/:id/reboot-schedule', (req, res) => {
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, '/resources'));
755
+ res.redirect(returnTo(req, '/runners'));
747
756
  });
748
757
 
749
- router.post('/resources/:id/rotate-token', requireAdminRole, (req, res) => {
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 resource token (copy it now):\n${token}`, { sticky: true });
756
- res.redirect(returnTo(req, '/resources'));
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 resources.');
809
+ parts.push('No connected runners.');
801
810
  }
802
811
  else if (outdated.length){
803
- parts.push(`${outdated.length} of ${connected.length} connected resource(s) can update to Client v${s.latest.client}: ${names(outdated)}.`);
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 resource(s) run the latest Client (v${s.latest.client}).`);
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('/resources/update-all', updateAction(async () => {
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} resource(s).`;
848
+ return `Requested update to client v${version} on ${n} runner(s).`;
840
849
  }));
841
850
 
842
- router.post('/resources/:id/update', updateAction(async (req) => {
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 resource'), { status: 404 });
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 resource that listed it just stopped matching on it.');
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 resource.`);
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){
@@ -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}/resources/${id}/groups`, {
56
- method: 'POST', headers: { cookie }, body: new URLSearchParams([...groups.map((g) => ['groups', g]), ['returnTo', '/resources']]), redirect: 'manual'
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}/resources`, { headers: { cookie: admin } })).text();
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}/resources`, { headers: { cookie: admin } })).text(),
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}/resources`, { headers: { cookie: admin } })).text(),
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}/resources`, { headers: { cookie: admin } })).text();
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}/resources`, { headers: { cookie: admin } })).text();
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}/resources/${resourceId}/config/export`, { headers: { cookie: admin } })).json();
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, '/resources'), await page(admin, `/jobs/${job.id}`)]){
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}/resources`, { headers: { cookie, 'X-Thub-Live': '1' } });
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}/resources`, { headers: { cookie, 'X-Thub-Live': '1' }, redirect: 'manual' })).status, 302);
152
+ assert.equal((await fetch(`${base}/runners`, { headers: { cookie, 'X-Thub-Live': '1' }, redirect: 'manual' })).status, 302);
153
153
  });
@@ -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, '/resources')).status, 200);
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, '/resources');
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/);
@@ -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 resources in that group (§13.1). Which groups a Client is in is set on its resource card (Groups tab).
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 Resources
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}"? Resources listing it just stop matching on it, and its agents go back to running their jobs on any resource.`
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. Resource type.', '--type hw'],
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 resource group your jobs run in isn't an Agent
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 resource card → #[strong Connected USB devices] → #[strong Refresh], then #[strong Import to config].
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-&lt;instance&gt;.rules], so the devices appear as #[code /dev/thub/dut&lt;N&gt;-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 resource card's #[strong Capabilities]: each configured device must be present (a missing one is flagged).
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 resource.
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 resource card's #[strong Reboot] tab (cron, host-local time). It waits for running jobs.
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 Resource name. Under systemd, the instance name (#[code thub-client@dut1] → #[code dut1]) is used unless an admin renames it on the dashboard.
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 resource groups this Client belongs to. Set them on its resource card (Groups tab), which writes them here.
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 resource card (#[strong ST-Link | UART | DUT USB]
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 resource goes #[code OUT_OF_SERVICE] and its job becomes #[code LOST]; #[code sweepIntervalSec] is how often that's checked.
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/resource 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.
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="/resources") Resources] pages. Clients install an update only between jobs.
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].
@@ -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 resource card's #[strong Capabilities] show whether a Client can use Docker, and why not.
124
+ #[code docker] group. The runner card's #[strong Capabilities] show whether a Client can use Docker, and why not.
@@ -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 resource registry, and runs the scheduler and
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 Resource and job states
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 Resource
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="/resources") Resources] by itself, usually within 10 s.
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 Resources', '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 resource', '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 resource card. A name held by an `OUT_OF_SERVICE` resource is reclaimed automatically.'],
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, resource, 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, resources, 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.'],
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 resource 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`).'],
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 Resources online
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")
@@ -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, resource, command, repo…', list.urlFor({ q: '' }))
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', '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 resource, agent and group history logged before then — then compacts the database file.
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.
@@ -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 Resource
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'], ['/resources', 'resources', 'Resources', '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']] : []) : []
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 resources (last checked ${fmtDate(updates.checkedAt)})` : 'Check for updates now — the Coordinator and connected resources'
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=`/resources/${r.id}/groups`
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=`/resources/${r.id}/config`
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 resource (no group)
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 resources in this group. Manage groups on #[a(href="/groups") Groups].
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=`/resources/${r.id}/maintenance`)
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 resource back into rotation so the scheduler can assign it jobs again"
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 resource out of rotation for maintenance — the scheduler will skip it"
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=`/resources/${r.id}/rotate-token`
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 resource from the list — it's running a job"
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=`/resources/${r.id}/remove`
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 resource from the list"
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 resource as soon as its Client confirms the job stopped (at most about a minute if the Client doesn't answer).
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=`/resources/${r.id}/reboot-schedule`
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=`/resources/${r.id}/reboot-schedule`)
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=`/resources/${r.id}/cancel-activity`
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=`/resources/${r.id}/usb-scan` data-pending=r.usb_scan_request_id ? '1' : null data-requested-at=r.usb_scan_requested_at)
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=`/resources/${r.id}/reboot`
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=`/resources/${r.id}/config/export`
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=`/resources/${r.id}/config/import`
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=`/resources/${r.id}/rename`)
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, `/resources/${r.id}/update`, returnTo, true, r.activity && r.activity.state === 'update-hold')
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
@@ -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' : '/resources/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"
@@ -5,19 +5,19 @@ include ../mixins/updates
5
5
  include ../mixins/list-controls
6
6
 
7
7
  block content
8
- h1.h4.mb-3 Resources
8
+ h1.h4.mb-3 Runners
9
9
  .d-flex.flex-wrap.align-items-center.gap-2.mb-3
10
- +updatesToolbar('client', 'clients', '/resources')
10
+ +updatesToolbar('client', 'clients', '/runners')
11
11
  p.text-body-secondary.small: i.
12
- Resources register themselves — start a Client with the shared join key and it appears here. Click a resource for its details, including IP addresses.
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 resources: name, label, group, status, IP…', list.urlFor({ q: '' }))
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, 'resources')
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, `/resources/${r.id}/update`, list.pageUrl(), true, r.activity && r.activity.state === 'update-hold')
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