@andrian.yablonskyy/thub-coordinator 1.1.9 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.1.9",
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": {
@@ -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,
@@ -687,6 +686,21 @@ function createWebRouter({ services, config }){
687
686
 
688
687
  // Groups tab (resource card): the Client's group membership, as a config
689
688
  // revision the Client writes to its file (registry.setClientGroups).
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
+
690
704
  router.post('/runners/:id/groups', (req, res) => {
691
705
  try {
692
706
  const ids = [].concat(req.body.groups || []).filter((g) => typeof g === 'string' && g),
@@ -1095,7 +1109,7 @@ function createWebRouter({ services, config }){
1095
1109
  // CI tokens only: a person's key belongs to their user (Users, §10.3).
1096
1110
  sorted = services.agents.list()
1097
1111
  .filter((a) => !a.user_id)
1098
- .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))
1099
1113
  .sort((a, b) => {
1100
1114
  const va = value(a),
1101
1115
  vb = value(b);
@@ -289,3 +289,54 @@ test('Resources renamed Runners: the page is /runners; old /resources links and
289
289
  assert.match(html, /href="\/runners"[^>]*>(<i [^>]*><\/i>)?Runners</);
290
290
  assert.doesNotMatch(html.replace(/<[^>]*>/g, ' '), /\bResources?\b/); // no visible "Resource(s)" left on the page
291
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
+ });
@@ -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
+ });
@@ -10,7 +10,7 @@ block content
10
10
  .d-flex.flex-wrap.align-items-center.gap-2.mb-3
11
11
  +updatesToolbar('agent', 'agents', list.pageUrl())
12
12
  .d-flex.flex-wrap.align-items-center.gap-2.mb-3
13
- +searchForm(q, 'Search agents: name, kind, group, version, status', list.urlFor({ q: '' }))
13
+ +searchForm(q, 'Search CI tokens: name, group, version, status', list.urlFor({ q: '' }))
14
14
  .ms-auto
15
15
  +pageSizePicker(list)
16
16
 
@@ -30,7 +30,6 @@ block content
30
30
  thead
31
31
  tr
32
32
  +sortHeader(list, 'name', 'Name')
33
- +sortHeader(list, 'kind', 'Kind')
34
33
  +sortHeader(list, 'group', 'Group')
35
34
  +sortHeader(list, 'version', 'Version', 'desc')
36
35
  +sortHeader(list, 'created', 'Created', 'desc')
@@ -43,7 +42,6 @@ block content
43
42
  //- The name opens Edit (name and group).
44
43
  td
45
44
  a.fw-medium(href=`#editAgentModal-${a.id}` data-bs-toggle="modal" role="button")= a.name
46
- td.text-uppercase= a.kind
47
45
  td
48
46
  if a.group_id && groupsById[a.group_id]
49
47
  span.badge.text-bg-info= groupsById[a.group_id].name
@@ -108,7 +106,7 @@ block content
108
106
  button.btn.btn-primary(type="submit") Save
109
107
  else
110
108
  tr
111
- td.text-body-secondary(colspan="8")
109
+ td.text-body-secondary(colspan="7")
112
110
  if q
113
111
  | No agent matches “#{q}”.
114
112
  a(href=list.urlFor({ q: '' })) Show all agents
@@ -19,8 +19,7 @@
19
19
  const groups = [
20
20
  { title: 'Where to run', rows: [
21
21
  ['--type hw|sw', 'Required. Runner type.', '--type hw'],
22
- ['--board <name>', 'Shorthand for --label board:<name>.', '--board nucleo-f401re'],
23
- ['--label <l>', 'Required label (repeatable). The Client must have all of them.', '--label uart --label stlink'],
22
+ ['--label <l>', 'Required label (repeatable). The runner must have all of them. 1–64 characters, no spaces, commas or semicolons.', '--label uart --label stlink'],
24
23
  ['--client <name|id>', 'Only this Client; waits for it even if others are idle.', '--client lab-hw-01']
25
24
  ] },
26
25
  { title: 'What to run', rows: [
@@ -73,13 +72,13 @@
73
72
 
74
73
  h3.h6 Examples
75
74
  +code('Flash and test a board, fail CI on failure').
76
- thub run --type hw --board nucleo-f401re \
75
+ thub run --type hw --label board:nucleo-f401re \
77
76
  --download-file https://artifactory.example.com/fw-local/app/1.4.0-42/app.bin \
78
77
  --git-repo https://github.com/yourorg/firmware-tests.git v1.4.0 \
79
78
  --command 'st-flash --serial "$THUB_DUT_STLINK" --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/test.sh "$@"' \
80
79
  --arg --junit --timeout 45m --wait
81
80
  +code('Reproduce a failure on the exact bench, labeled with your name').
82
- thub run --type hw --board nucleo-f401re --client lab-hw-01 --user "$USER" \
81
+ thub run --type hw --label board:nucleo-f401re --client lab-hw-01 --user "$USER" \
83
82
  --download-file "$IMAGE_URL" --git-repo "$TESTS_REPO" --command ./ci/test.sh
84
83
  # Ctrl-C detaches; the job keeps running. Re-attach:
85
84
  thub status M-00126
@@ -31,7 +31,7 @@
31
31
  steps:
32
32
  - run: |
33
33
  npx -y @andrian.yablonskyy/thub-agent run \
34
- --type hw --board nucleo-f401re \
34
+ --type hw --label board:nucleo-f401re \
35
35
  --download-file "${{ needs.build.outputs.image_url }}" \
36
36
  --git-repo "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" "$GITHUB_SHA" \
37
37
  --command 'st-flash --serial "$THUB_DUT_STLINK" --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/hw-tests.sh' \
@@ -50,7 +50,7 @@
50
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
- thub run --type hw --board nucleo-f401re \
53
+ thub run --type hw --label board:nucleo-f401re \
54
54
  --download-file https://artifactory.example.com/fw-local/app/1.4.0-42/app.bin \
55
55
  --git-repo https://github.com/yourorg/firmware-tests.git v1.4.0 \
56
56
  --command 'st-flash --serial "$THUB_DUT_STLINK" --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/test.sh' \
@@ -78,7 +78,7 @@
78
78
  tr
79
79
  td: code labels
80
80
  td No
81
- td Matched against a job's #[code --board]/#[code --label]. Use #[code board:&lt;name>] for the board.
81
+ td Matched against a job's #[code --label]s. Use #[code board:&lt;name>] for the board (#[code --label board:&lt;name>]). Also set on its runner card (Labels tab), which writes them here.
82
82
  tr
83
83
  td: code groups
84
84
  td No
@@ -35,8 +35,7 @@
35
35
  -
36
36
  const jobVars = [
37
37
  ['JOB_TYPE', '--type', 'hw or sw'],
38
- ['JOB_BOARD', '--board', 'the board name'],
39
- ['JOB_LABEL / JOB_LABEL_<n>', '--board, --label', 'all labels, comma-separated / each'],
38
+ ['JOB_LABEL / JOB_LABEL_<n>', '--label', 'all labels, comma-separated / each'],
40
39
  ['JOB_GROUP', '(dashboard)', 'the submitting agent\'s group, if it has one'],
41
40
  ['JOB_CLIENT', '--client', 'this Client\'s name'],
42
41
  ['JOB_USER', '--user', 'as given'],
@@ -118,7 +117,7 @@
118
117
  python:3.14 ./run-tests.sh
119
118
  +code('Submitting the job that feeds these').
120
119
  export API_TOKEN=…
121
- thub run --type hw --board nucleo-f401re \
120
+ thub run --type hw --label board:nucleo-f401re \
122
121
  --download-file https://artifactory.example.com/fw-local/app/1.4.0-42/app.bin \
123
122
  --git-repo git@github.com:yourorg/firmware-tests.git main \
124
123
  --env TARGET=production --env API_TOKEN \
@@ -6,7 +6,7 @@
6
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
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
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
- ['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.'],
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 `--label`s; 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`.'],
12
12
  ['Permission denied on `/dev/ttyUSB*` or `docker.sock`', 'Docker or the device groups were added after the Client was installed. Re-run `sudo npm i -g @andrian.yablonskyy/thub-client` so the unit gets the `dialout`/`plugdev`/`docker` groups, then restart.'],
@@ -96,9 +96,56 @@ mixin configTabItems(r)
96
96
  if r.type === 'hw'
97
97
  each t in [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'DUT USB']]
98
98
  +configTab(p, t[0], t[1])
99
+ +configTab(p, 'labels', 'Labels')
99
100
  +configTab(p, 'groups', 'Groups')
100
101
  +configTab(p, 'reboot', 'Reboot')
101
102
 
103
+ //- Labels tab: what a job's --label / --label board:must find on this Client,
104
+ //- one per line. Saved like the Groups tab (registry.setClientLabels): in
105
+ //- effect on the Coordinator at once, written to the config file's
106
+ //- `labels` by the Client.
107
+ mixin labelsPane(r, returnTo)
108
+ - const p = `cfg-${r.id}`
109
+ - const canEdit = can.operate && r.config_support === 'file'
110
+ - const pending = r.config_import && r.config_import.revision > r.config_applied_revision && r.config_import.fields && r.config_import.fields.labels
111
+ +configPane(p, 'labels')
112
+ if pending || r.config_error
113
+ .d-flex.justify-content-end.gap-1.mb-2
114
+ if pending
115
+ span.badge.text-bg-warning(
116
+ data-bs-toggle="tooltip"
117
+ data-bs-title="In effect on the Coordinator; the Client writes them to its config file on its next heartbeat, then restarts once idle"
118
+ ) not written to the Client yet
119
+ if r.config_error
120
+ span.badge.text-bg-danger(data-bs-toggle="tooltip" data-bs-title=r.config_error) refused by the Client
121
+ if r.config_support !== 'file'
122
+ p.small.text-body-secondary Labels can't be set here: #{noConfigReport} Or set #[code labels] in its config file.
123
+ form(
124
+ method="post"
125
+ action=`/runners/${r.id}/labels`
126
+ data-confirm=`Save the labels of ${r.name}? They apply at once; the Client also writes them to its config file and restarts once it has no job running.`
127
+ data-confirm-title="Save runner labels?"
128
+ data-confirm-ok="Save"
129
+ data-confirm-tone="primary"
130
+ )
131
+ input(type="hidden" name="returnTo" value=returnTo)
132
+ fieldset(disabled=!canEdit)
133
+ label.form-label.small(for=`${p}-labels-input`) One label per line
134
+ textarea.form-control.font-monospace(
135
+ id=`${p}-labels-input`
136
+ name="labels"
137
+ rows="6"
138
+ spellcheck="false"
139
+ autocapitalize="off"
140
+ placeholder="board:nucleo-f401re\nuart\nstlink"
141
+ )= r.labels.join('\n')
142
+ .form-text
143
+ | A job runs here only if this runner has every label it asks for (#[code thub run --label uart --label board:nucleo-f401re]).
144
+ | Each up to 64 characters, no spaces, commas or semicolons; up to 64 labels.
145
+ if canEdit
146
+ .d-flex.justify-content-end.mt-3
147
+ button.btn.btn-sm.btn-primary(type="submit" data-bs-toggle="tooltip" data-bs-title="Save the labels — in effect at once; the Client writes them to its config file") Save labels
148
+
102
149
  //- Groups tab: which resource groups this Client belongs to (thub run
103
150
  //- --group). Saved as a config revision the Client writes to its config
104
151
  //- file's `groups` (registry.setClientGroups); in effect on the Coordinator
@@ -198,6 +245,7 @@ mixin configPanes(r, returnTo, groupsById)
198
245
  +deviceTable('usbs', 'DUT USB devices', current.usbs || [], ['Device', 'udev devpath'])
199
246
  .form-text Device: a udev index 1–8 (→ /dev/thub/dut&lt;N&gt;-usb) or a /dev path. udev devpath: the USB port it's plugged into.
200
247
  +saveCapabilities(canEdit)
248
+ +labelsPane(r, returnTo)
201
249
  +groupsPane(r, returnTo, groupsById || {})
202
250
  +configPane(p, 'reboot')
203
251
  dl.row.mb-0
@@ -502,8 +502,10 @@ mixin resourceDetails(r, groupsById, returnTo, local, external)
502
502
  dt.col-sm-4 Labels
503
503
  dd.col-sm-8
504
504
  if r.labels.length
505
- each l in r.labels
506
- span.badge.text-bg-light.border.me-1= l
505
+ //- One label per line, as on the Labels tab.
506
+ .d-flex.flex-column.align-items-start.gap-1
507
+ each l in r.labels
508
+ span.badge.text-bg-light.border.font-monospace= l
507
509
  else
508
510
  span.text-body-secondary —
509
511
  dt.col-sm-4 Busy source / reason
@@ -39,9 +39,12 @@ block content
39
39
  td.text-uppercase= r.type
40
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
+ //- One label per line.
42
43
  td
43
- each l in r.labels
44
- span.badge.text-bg-light.border.me-1= l
44
+ if r.labels.length
45
+ .d-flex.flex-column.align-items-start.gap-1
46
+ each l in r.labels
47
+ span.badge.text-bg-light.border.font-monospace= l
45
48
  td
46
49
  if r.group_ids.length
47
50
  each gid in r.group_ids