@andrian.yablonskyy/thub-coordinator 1.1.8 → 1.1.10

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.10",
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.
@@ -14,6 +14,7 @@
14
14
  'use strict';
15
15
 
16
16
  const { validateJobSpec, maskEnv, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
17
+ { LABEL_RE, LABEL_RULE } = require('./registry'),
17
18
  { paginate } = require('./list-prefs'),
18
19
  { parseTerms, likeClause } = require('./search'),
19
20
  { sanitizeArtifacts } = require('./job-artifacts'),
@@ -165,6 +166,12 @@ function createJobsService(db, { bus, events, registry, config }){
165
166
  if (!valid){
166
167
  throw Object.assign(new Error(`Invalid job spec (${VERSIONS}): ${errors.join('; ')}`), { status: 400 });
167
168
  }
169
+ // A label no runner could have (the Labels tab's rule): say so, rather
170
+ // than "no runner can ever satisfy" it.
171
+ const badLabels = (spec.target.labels || []).filter((l) => !LABEL_RE.test(l));
172
+ if (badLabels.length){
173
+ throw Object.assign(new Error(`Invalid label: ${badLabels.join(', ')} — ${LABEL_RULE}`), { status: 400 });
174
+ }
168
175
  spec.source = source;
169
176
  // The group is the agent's, set on the dashboard (§13.1) — whatever an
170
177
  // older Agent sent as `target.group` is replaced.
@@ -32,7 +32,7 @@ const PAGE_SIZES = [10, 25, 50, 100, 'all'],
32
32
  defaults: { size: DEFAULT_PAGE_SIZE, sort: 'name', dir: 'asc' }
33
33
  },
34
34
  agents: {
35
- sorts: ['name', 'kind', 'group', 'version', 'created', 'used', 'status'],
35
+ sorts: ['name', 'group', 'version', 'created', 'used', 'status'],
36
36
  defaults: { size: DEFAULT_PAGE_SIZE, sort: 'created', dir: 'desc' }
37
37
  },
38
38
  users: {
@@ -18,8 +18,14 @@ const { v4: uuid } = require('uuid'),
18
18
  shareableClientConfigFile, HW_DEVICES_SECTION } = require('@andrian.yablonskyy/thub-common'),
19
19
  { generateToken, hashToken } = require('./tokens');
20
20
 
21
- // Never kept from or put in a Client's config file: its credentials.
22
- const CONFIG_FILE_SECRETS = ['joinKey'],
21
+ // A label (a runner's, on the Labels tab; a job's, --label): 1–64
22
+ // characters, no whitespace, commas or semicolons — JOB_LABEL joins them
23
+ // with commas, and a label is one line on the dashboard.
24
+ const LABEL_RE = /^[^\s,;]{1,64}$/,
25
+ LABEL_RULE = '1–64 characters, no spaces, commas or semicolons',
26
+
27
+ // Never kept from or put in a Client's config file: its credentials.
28
+ CONFIG_FILE_SECRETS = ['joinKey'],
23
29
  // The first Client that applies a whole config file (an Import's other
24
30
  // fields, the Groups tab), not only its hw-devices section.
25
31
  CONFIG_FILE_CLIENT = '1.0.30';
@@ -796,6 +802,38 @@ function createRegistryService(db, { bus, events }){
796
802
  return get(resourceId);
797
803
  }
798
804
 
805
+ // Labels tab (runner card): the labels a job's --label / --label board:must
806
+ // find on this Client. Like the Groups tab: they belong to its config
807
+ // file (`labels`, which every registration reports), so they go out as
808
+ // its next config revision, and the Coordinator applies them at once.
809
+ // Each label: see LABEL_RE (top); at most 64 of them.
810
+ function setClientLabels(resourceId, labels, { by } = {}){
811
+ const r = get(resourceId);
812
+ if (!r){
813
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
814
+ }
815
+ const list = [...new Set(labels.map((l) => String(l).trim()).filter(Boolean))],
816
+ bad = list.filter((l) => !LABEL_RE.test(l));
817
+ if (bad.length){
818
+ throw Object.assign(new Error(`Invalid label: ${bad.join(', ')} — ${LABEL_RULE}`), { status: 400 });
819
+ }
820
+ if (list.length > 64){
821
+ throw Object.assign(new Error('At most 64 labels'), { status: 400 });
822
+ }
823
+ const desired = currentSection(r);
824
+ if (!desired || r.config_support !== 'file'){
825
+ throw Object.assign(new Error(`${r.name} is too old to take its labels from the dashboard — update its Client first`), { status: 409 });
826
+ }
827
+ const revision = r.config_revision + 1,
828
+ fields = { ...(pendingImport(r) || {}), labels: list };
829
+ db.transaction(() => {
830
+ db.prepare(`UPDATE resources SET config_desired = ?, config_revision = ?, config_error = NULL, config_import = ?, labels = ?
831
+ WHERE id = ?`).run(JSON.stringify(desired), revision, JSON.stringify({ revision, fields }), JSON.stringify(list), resourceId);
832
+ })();
833
+ events.record('resource', resourceId, 'resource.labels_set', { by, revision, labels: list });
834
+ return get(resourceId);
835
+ }
836
+
799
837
  // Export (resource card): the Client's config file as it last reported it
800
838
  // (secrets excluded), with what's saved for it but not applied yet — so
801
839
  // exporting right after an edit or import gives what the Client will run.
@@ -878,6 +916,7 @@ function createRegistryService(db, { bus, events }){
878
916
  return {
879
917
  get,
880
918
  reportClientConfig,
919
+ setClientLabels,
881
920
  getByName,
882
921
  getByClientId,
883
922
  getByTokenHash,
@@ -912,4 +951,4 @@ function createRegistryService(db, { bus, events }){
912
951
  };
913
952
  }
914
953
 
915
- module.exports = { createRegistryService };
954
+ module.exports = { LABEL_RE, LABEL_RULE, createRegistryService };
package/src/web/routes.js CHANGED
@@ -52,7 +52,6 @@ const STARTED_AT = new Date().toISOString(),
52
52
  // Agents page (list-prefs `agents`): never-used / never-reported last.
53
53
  AGENT_SORT_VALUE = {
54
54
  name: (a) => a.name,
55
- kind: (a) => a.kind,
56
55
  version: (a) => a.version,
57
56
  created: (a) => a.created_at,
58
57
  used: (a) => a.last_used_at,
@@ -292,6 +291,15 @@ function createWebRouter({ services, config }){
292
291
  req.session.destroy(() => res.redirect('/login'));
293
292
  });
294
293
 
294
+ // Resources were renamed Runners on the dashboard: old links and
295
+ // bookmarks (and a form from a page loaded before) still land.
296
+ router.use((req, res, next) => {
297
+ if (!/^\/resources(\/|$)/.test(req.path)){
298
+ return next();
299
+ }
300
+ res.redirect(req.method === 'GET' || req.method === 'HEAD' ? 301 : 308, req.originalUrl.replace(/^\/resources/, '/runners'));
301
+ });
302
+
295
303
  router.use(requireAdminSession);
296
304
  router.use((req, res, next) => {
297
305
  const u = req.session.user;
@@ -496,7 +504,7 @@ function createWebRouter({ services, config }){
496
504
  res.render('index', { title: 'Overview', active: 'overview', liveTopics: 'resources jobs', resources, queueLength, jobsLast24h, onlineCount, groupsById });
497
505
  });
498
506
 
499
- router.get('/resources', (req, res) => {
507
+ router.get('/runners', (req, res) => {
500
508
  const groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g])),
501
509
  q = search.normalizeQuery(req.query.q),
502
510
  terms = search.parseTerms(q),
@@ -518,7 +526,7 @@ function createWebRouter({ services, config }){
518
526
  pagination = listPrefs.paginate(sorted.length, size, req.query.page),
519
527
  pageUrl = (changes) => view.urlFor({ page: pagination.page, ...changes });
520
528
  res.render('resources/list', {
521
- title: 'Resources',
529
+ title: 'Runners',
522
530
  active: 'resources',
523
531
  liveTopics: 'resources',
524
532
  resources: withActiveJobs(sorted.slice(pagination.offset, pagination.offset + pagination.limit)),
@@ -527,21 +535,21 @@ function createWebRouter({ services, config }){
527
535
  });
528
536
  });
529
537
 
530
- router.post('/resources/:id/maintenance', (req, res) => {
538
+ router.post('/runners/:id/maintenance', (req, res) => {
531
539
  services.registry.setMaintenance(req.params.id, req.body.enabled === '1');
532
- res.redirect(returnTo(req, '/resources'));
540
+ res.redirect(returnTo(req, '/runners'));
533
541
  });
534
542
 
535
543
  // Cancel whatever the Client is doing (resource card): its job, a manual
536
544
  // local lock, or a self-update hold (README §10). The Client applies the
537
545
  // lock/update ones via heartbeat commands; a job is canceled right here.
538
- router.post('/resources/:id/cancel-activity', (req, res) => {
546
+ router.post('/runners/:id/cancel-activity', (req, res) => {
539
547
  const r = services.registry.get(req.params.id),
540
548
  job = r && services.jobs.activeForResource(r.id),
541
549
  activity = r?.activity?.state;
542
550
  try {
543
551
  if (!r){
544
- throw Object.assign(new Error('Unknown resource'), { status: 404 });
552
+ throw Object.assign(new Error('Unknown runner'), { status: 404 });
545
553
  }
546
554
  if (job){
547
555
  services.jobs.cancel(job.id, { isAdmin: true });
@@ -572,10 +580,10 @@ function createWebRouter({ services, config }){
572
580
  catch (err){
573
581
  flash(req, 'danger', err.message);
574
582
  }
575
- res.redirect(returnTo(req, '/resources'));
583
+ res.redirect(returnTo(req, '/runners'));
576
584
  });
577
585
 
578
- router.post('/resources/:id/remove', (req, res) => {
586
+ router.post('/runners/:id/remove', (req, res) => {
579
587
  try {
580
588
  const { resource: r, pending, stoppedJob, canceledJobs } = services.jobs.removeResource(req.params.id, {
581
589
  by: req.session.user.username,
@@ -592,12 +600,12 @@ function createWebRouter({ services, config }){
592
600
  catch (err){
593
601
  flash(req, 'danger', err.message);
594
602
  }
595
- res.redirect(returnTo(req, '/resources'));
603
+ res.redirect(returnTo(req, '/runners'));
596
604
  });
597
605
 
598
606
  // Reboot now (resource card footer): cancels a running job, then the
599
607
  // Client reboots its host on its next heartbeat (jobs.requestReboot).
600
- router.post('/resources/:id/reboot', (req, res) => {
608
+ router.post('/runners/:id/reboot', (req, res) => {
601
609
  try {
602
610
  const { resource: r, canceledJob } = services.jobs.requestReboot(req.params.id, { by: req.session.user.username });
603
611
  flash(req, 'warning', `Rebooting ${r.name}'s host on its next heartbeat.` + (canceledJob ? `\nCanceled its running job ${canceledJob}.` : ''));
@@ -605,7 +613,7 @@ function createWebRouter({ services, config }){
605
613
  catch (err){
606
614
  flash(req, 'danger', err.message);
607
615
  }
608
- res.redirect(returnTo(req, '/resources'));
616
+ res.redirect(returnTo(req, '/runners'));
609
617
  });
610
618
 
611
619
  // "Connected USB devices" tab (public/js/usb-scan.js): Refresh queues a
@@ -621,7 +629,7 @@ function createWebRouter({ services, config }){
621
629
  };
622
630
  }
623
631
 
624
- router.post('/resources/:id/usb-scan', (req, res) => {
632
+ router.post('/runners/:id/usb-scan', (req, res) => {
625
633
  try {
626
634
  services.registry.requestUsbScan(req.params.id, { by: req.session.user.username });
627
635
  res.json(usbScanView(req, services.registry.get(req.params.id)));
@@ -631,16 +639,16 @@ function createWebRouter({ services, config }){
631
639
  }
632
640
  });
633
641
 
634
- router.get('/resources/:id/usb-scan', (req, res) => {
642
+ router.get('/runners/:id/usb-scan', (req, res) => {
635
643
  const r = services.registry.get(req.params.id);
636
644
  if (!r){
637
- return res.status(404).json({ error: 'Unknown resource' });
645
+ return res.status(404).json({ error: 'Unknown runner' });
638
646
  }
639
647
  res.set('Cache-Control', 'no-store').json(usbScanView(req, r));
640
648
  });
641
649
 
642
650
  // Rename (resource card, Name row); empty = back to the Client's own name.
643
- router.post('/resources/:id/rename', (req, res) => {
651
+ router.post('/runners/:id/rename', (req, res) => {
644
652
  try {
645
653
  const before = services.registry.get(req.params.id)?.name,
646
654
  r = services.registry.rename(req.params.id, req.body.name, { by: req.session.user.username });
@@ -651,13 +659,13 @@ function createWebRouter({ services, config }){
651
659
  catch (err){
652
660
  flash(req, 'danger', err.message);
653
661
  }
654
- res.redirect(returnTo(req, '/resources'));
662
+ res.redirect(returnTo(req, '/runners'));
655
663
  });
656
664
 
657
665
  // Capabilities (resource card, Config tab): public/js/client-config.js
658
666
  // sends the edited hw/sw section as JSON in `config`. Applied by the Client
659
667
  // on its next heartbeat, then it restarts once idle.
660
- router.post('/resources/:id/config', (req, res) => {
668
+ router.post('/runners/:id/config', (req, res) => {
661
669
  try {
662
670
  let config;
663
671
  try {
@@ -673,12 +681,27 @@ function createWebRouter({ services, config }){
673
681
  catch (err){
674
682
  flash(req, 'danger', err.message);
675
683
  }
676
- res.redirect(returnTo(req, '/resources'));
684
+ res.redirect(returnTo(req, '/runners'));
677
685
  });
678
686
 
679
687
  // Groups tab (resource card): the Client's group membership, as a config
680
688
  // revision the Client writes to its file (registry.setClientGroups).
681
- router.post('/resources/:id/groups', (req, res) => {
689
+ // Labels tab (runner card): one label per line.
690
+ router.post('/runners/:id/labels', (req, res) => {
691
+ try {
692
+ // One per line: a comma or semicolon is in a label (and refused), not a separator.
693
+ const labels = String(req.body.labels || '').split(/\r?\n/),
694
+ r = services.registry.setClientLabels(req.params.id, labels, { by: req.session.user.username });
695
+ flash(req, 'success', `Labels of ${r.name} saved: ${r.labels.length ? r.labels.join(', ') : 'none'} — in effect now.\n` +
696
+ 'The Client writes them to its config file on its next heartbeat, then restarts once it has no job running.');
697
+ }
698
+ catch (err){
699
+ flash(req, 'danger', err.message);
700
+ }
701
+ res.redirect(returnTo(req, '/runners'));
702
+ });
703
+
704
+ router.post('/runners/:id/groups', (req, res) => {
682
705
  try {
683
706
  const ids = [].concat(req.body.groups || []).filter((g) => typeof g === 'string' && g),
684
707
  r = services.registry.setClientGroups(req.params.id, ids, {
@@ -691,12 +714,12 @@ function createWebRouter({ services, config }){
691
714
  catch (err){
692
715
  flash(req, 'danger', err.message);
693
716
  }
694
- res.redirect(returnTo(req, '/resources'));
717
+ res.redirect(returnTo(req, '/runners'));
695
718
  });
696
719
 
697
720
  // Export (resource card, admins — the file holds the join key): the
698
721
  // Client's config file as JSON, secrets excluded.
699
- router.get('/resources/:id/config/export', (req, res) => {
722
+ router.get('/runners/:id/config/export', (req, res) => {
700
723
  try {
701
724
  const r = services.registry.get(req.params.id),
702
725
  file = services.registry.exportClientConfig(req.params.id);
@@ -704,13 +727,13 @@ function createWebRouter({ services, config }){
704
727
  }
705
728
  catch (err){
706
729
  flash(req, 'danger', err.message);
707
- res.redirect('/resources');
730
+ res.redirect('/runners');
708
731
  }
709
732
  });
710
733
 
711
734
  // Import (resource card, public/js/config-import.js): a config file, applied
712
735
  // by the Client on its next heartbeat like a Config tab Save.
713
- router.post('/resources/:id/config/import', (req, res) => {
736
+ router.post('/runners/:id/config/import', (req, res) => {
714
737
  try {
715
738
  let file;
716
739
  try {
@@ -727,12 +750,12 @@ function createWebRouter({ services, config }){
727
750
  catch (err){
728
751
  flash(req, 'danger', err.message);
729
752
  }
730
- res.redirect(returnTo(req, '/resources'));
753
+ res.redirect(returnTo(req, '/runners'));
731
754
  });
732
755
 
733
756
  // Scheduled host reboot (resource card): save a cron expression, or clear
734
757
  // it; the Client applies it on its next heartbeat.
735
- router.post('/resources/:id/reboot-schedule', (req, res) => {
758
+ router.post('/runners/:id/reboot-schedule', (req, res) => {
736
759
  try {
737
760
  const cron = req.body.clear === '1' ? '' : req.body.cron,
738
761
  r = services.registry.setRebootSchedule(req.params.id, cron, { by: req.session.user.username });
@@ -743,17 +766,17 @@ function createWebRouter({ services, config }){
743
766
  catch (err){
744
767
  flash(req, 'danger', err.message);
745
768
  }
746
- res.redirect(returnTo(req, '/resources'));
769
+ res.redirect(returnTo(req, '/runners'));
747
770
  });
748
771
 
749
- router.post('/resources/:id/rotate-token', requireAdminRole, (req, res) => {
772
+ router.post('/runners/:id/rotate-token', requireAdminRole, (req, res) => {
750
773
  const { generateToken, hashToken } = require('../services/tokens');
751
774
  const token = generateToken('res');
752
775
  services.db.prepare('UPDATE resources SET token_hash = ? WHERE id = ?').run(hashToken(token), req.params.id);
753
776
  services.events.record('resource', req.params.id, 'resource.token_rotated', {});
754
777
  // 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'));
778
+ flash(req, 'warning', `New runner token (copy it now):\n${token}`, { sticky: true });
779
+ res.redirect(returnTo(req, '/runners'));
757
780
  });
758
781
 
759
782
  // Self-update (README §10.2). Clients get a `self-update` heartbeat
@@ -797,13 +820,13 @@ function createWebRouter({ services, config }){
797
820
  parts.push('Latest Client version unknown.');
798
821
  }
799
822
  else if (!connected.length){
800
- parts.push('No connected resources.');
823
+ parts.push('No connected runners.');
801
824
  }
802
825
  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)}.`);
826
+ parts.push(`${outdated.length} of ${connected.length} connected runner(s) can update to Client v${s.latest.client}: ${names(outdated)}.`);
804
827
  }
805
828
  else {
806
- parts.push(`All ${connected.length} connected resource(s) run the latest Client (v${s.latest.client}).`);
829
+ parts.push(`All ${connected.length} connected runner(s) run the latest Client (v${s.latest.client}).`);
807
830
  }
808
831
  if (unknown.length){
809
832
  parts.push(`Version unknown (Client too old to report it): ${names(unknown)}.`);
@@ -833,20 +856,20 @@ function createWebRouter({ services, config }){
833
856
  });
834
857
  });
835
858
 
836
- router.post('/resources/update-all', updateAction(async () => {
859
+ router.post('/runners/update-all', updateAction(async () => {
837
860
  const version = await services.updates.targetVersion('client'),
838
861
  n = services.registry.requestUpdateAll(version);
839
- return `Requested update to client v${version} on ${n} resource(s).`;
862
+ return `Requested update to client v${version} on ${n} runner(s).`;
840
863
  }));
841
864
 
842
- router.post('/resources/:id/update', updateAction(async (req) => {
865
+ router.post('/runners/:id/update', updateAction(async (req) => {
843
866
  // Cancel (next to the version): withdraw the request and, if the host is
844
867
  // already holding for the update, abort that too (the Client's next
845
868
  // heartbeat) — the one control for canceling a Client update.
846
869
  if (req.body.cancel === '1'){
847
870
  const r = services.registry.get(req.params.id);
848
871
  if (!r){
849
- throw Object.assign(new Error('Unknown resource'), { status: 404 });
872
+ throw Object.assign(new Error('Unknown runner'), { status: 404 });
850
873
  }
851
874
  services.registry.setUpdateTo(r.id, null);
852
875
  if (r.activity?.state === 'update-hold'){
@@ -1069,7 +1092,7 @@ function createWebRouter({ services, config }){
1069
1092
 
1070
1093
  router.post('/groups/:id/delete', (req, res) => {
1071
1094
  services.groups.remove(req.params.id);
1072
- flash(req, 'warning', 'Group deleted; any resource that listed it just stopped matching on it.');
1095
+ flash(req, 'warning', 'Group deleted; any runner that listed it just stopped matching on it.');
1073
1096
  res.redirect('/groups');
1074
1097
  });
1075
1098
 
@@ -1086,7 +1109,7 @@ function createWebRouter({ services, config }){
1086
1109
  // CI tokens only: a person's key belongs to their user (Users, §10.3).
1087
1110
  sorted = services.agents.list()
1088
1111
  .filter((a) => !a.user_id)
1089
- .filter((a) => search.matches([a.name, a.kind, a.version, groupName(a), a.revoked_at ? 'revoked' : 'active'], terms))
1112
+ .filter((a) => search.matches([a.name, a.version, groupName(a), a.revoked_at ? 'revoked' : 'active'], terms))
1090
1113
  .sort((a, b) => {
1091
1114
  const va = value(a),
1092
1115
  vb = value(b);
@@ -1469,7 +1492,7 @@ function createWebRouter({ services, config }){
1469
1492
  if (req.body.groupId !== undefined && (req.body.groupId || null) !== (before.group_id || null)){
1470
1493
  const group = req.body.groupId ? services.groups.get(req.body.groupId) : null;
1471
1494
  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.`);
1495
+ flash(req, 'success', group ? `"${agent.name}" now runs its jobs in group ${group.name}.` : `"${agent.name}" now runs its jobs on any runner.`);
1473
1496
  }
1474
1497
  }
1475
1498
  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,68 @@ 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
+ });
292
+
293
+ test('Labels tab: in effect at once, sent to the Client as its next config revision; validated', async (t) => {
294
+ const { base, services, admin } = await start(t),
295
+ id = register(services, 'lab-sw-30'),
296
+ save = (labels) => fetch(`${base}/runners/${id}/labels`, {
297
+ method: 'POST', headers: { cookie: admin }, body: new URLSearchParams({ labels, returnTo: '/runners' }), redirect: 'manual'
298
+ }),
299
+ card = async () => {
300
+ const html = await (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text();
301
+ return html.slice(html.indexOf(`id="resource-card-${id}"`));
302
+ };
303
+ assert.match(await card(), new RegExp(`id="cfg-${id}-labels-tab"`)); // the tab
304
+
305
+ await save('board:nucleo-f401re\nuart\n\nuart\n');
306
+ const r = services.registry.get(id);
307
+ assert.deepEqual(r.labels, ['board:nucleo-f401re', 'uart']); // trimmed, deduplicated, at once
308
+ assert.deepEqual(services.registry.pendingConfig(id).file, { labels: ['board:nucleo-f401re', 'uart'] });
309
+ assert.match(await card(), /not written to the Client yet/);
310
+ assert.match(await card(), />board:nucleo-f401re\nuart<\/textarea>/);
311
+
312
+ // Scheduling follows at once: a job asking for them can run here.
313
+ services.registry.heartbeat(id, { state: 'IDLE' });
314
+ const { agent } = services.agents.create({ name: 'ci', kind: 'ci' }),
315
+ spec = { target: { type: 'sw', labels: ['uart', 'board:nucleo-f401re'] }, command: './t' };
316
+ assert.ok(services.jobs.create({ agentId: agent.id, source: 'ci', spec }));
317
+
318
+ // A groups save meanwhile keeps the labels in the same revision.
319
+ services.registry.setClientGroups(id, [], { by: 'admin' });
320
+ assert.deepEqual(services.registry.pendingConfig(id).file, { labels: ['board:nucleo-f401re', 'uart'], groups: [] });
321
+
322
+ await save('has space');
323
+ assert.deepEqual(services.registry.get(id).labels, ['board:nucleo-f401re', 'uart']); // refused
324
+ for (const bad of ['a,b', 'a;b', 'a\tb', 'x'.repeat(65)]){
325
+ assert.throws(() => services.registry.setClientLabels(id, [bad], { by: 'x' }), /1–64 characters, no spaces, commas or semicolons/, bad);
326
+ }
327
+ assert.deepEqual(services.registry.setClientLabels(id, ['x'.repeat(64), 'board:b'], { by: 'x' }).labels, ['x'.repeat(64), 'board:b']);
328
+ await save('uart, stlink'); // a comma is part of the label (refused), not a separator
329
+ assert.deepEqual(services.registry.get(id).labels, ['x'.repeat(64), 'board:b']);
330
+ // One per line in the card's Labels row and the Runners table.
331
+ const html = await card(),
332
+ labelsRow = html.slice(html.indexOf('<dt class="col-sm-4">Labels</dt>'));
333
+ assert.match(labelsRow, /^<dt[^>]*>Labels<\/dt><dd[^>]*><div class="d-flex flex-column[^"]*">/);
334
+ assert.match(labelsRow, /^[^]*?<span class="badge[^"]*">x{64}<\/span><span class="badge[^"]*">board:b<\/span><\/div>/);
335
+
336
+ // A job asking for a label no runner could have: refused, saying why.
337
+ assert.throws(() => services.jobs.create({ agentId: agent.id, source: 'ci', spec: { target: { type: 'sw', labels: ['has space'] }, command: './t' } }),
338
+ /Invalid label: has space — 1–64 characters/);
339
+
340
+ const old = register(services, 'lab-old-30', { configFile: null });
341
+ assert.throws(() => services.registry.setClientLabels(old, ['x'], { by: 'x' }), /too old/);
342
+ });
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
  });
@@ -845,3 +845,15 @@ test('migration 027 drops joinKey copies from stored config files', () => {
845
845
  assert.deepEqual(JSON.parse(db.prepare('SELECT client_config_file FROM resources WHERE id = ?').get('a').client_config_file), { name: 'a', labels: [] });
846
846
  assert.equal(db.prepare('SELECT client_config_file FROM resources WHERE id = ?').get('b').client_config_file, null);
847
847
  });
848
+
849
+ test('a job runs only on a runner that has every label it asks for', () => {
850
+ const { registry, agents, jobs, scheduler } = buildTestServices(),
851
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
852
+ some = registerResource(registry, { name: 'lab-some', type: 'hw', labels: ['board:nucleo', 'uart'] }),
853
+ all = registerResource(registry, { name: 'lab-all', type: 'hw', labels: ['board:nucleo', 'uart', 'stlink', 'extra'] });
854
+ registry.heartbeat(some.id, { state: 'idle' });
855
+ registry.heartbeat(all.id, { state: 'idle' });
856
+ const job = jobs.create({ agentId: agent.id, source: 'ci', spec: makeSpec({ target: { type: 'hw', labels: ['board:nucleo', 'uart', 'stlink'] } }) });
857
+ scheduler.runPass();
858
+ assert.equal(jobs.get(job.id).resource_id, all.id); // lab-some lacks stlink, though it's idle too
859
+ });