@andrian.yablonskyy/thub-coordinator 1.1.9 → 1.1.11

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.11",
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": {
@@ -56,7 +56,7 @@
56
56
  }
57
57
 
58
58
  .resource-capabilities {
59
- font-size: 0.75rem;
59
+ font-size: 0.875rem;
60
60
  }
61
61
 
62
62
  /* An overlay inside a modal (e.g. "Import to config"'s review note on the
@@ -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,72 @@ 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
+ });
343
+
344
+ test('runner card: capabilities have their own tab, next to Details', async (t) => {
345
+ const { base, services, admin } = await start(t),
346
+ { resourceId } = services.registry.registerAuto({
347
+ clientId: 'hw8', name: 'lab-hw-08', type: 'hw', labels: [],
348
+ capabilities: { hw: { stlinks: [{ index: 1, path: '/dev/thub/dut1-stlink', present: false }], uarts: [], usbs: [] } }
349
+ }),
350
+ html = await (await fetch(`${base}/runners`, { headers: { cookie: admin } })).text(),
351
+ card = html.slice(html.indexOf(`id="resource-card-${resourceId}"`)),
352
+ details = card.slice(card.indexOf(`id="rc-${resourceId}-details"`), card.indexOf(`id="rc-${resourceId}-caps"`)),
353
+ caps = card.slice(card.indexOf(`id="rc-${resourceId}-caps"`)),
354
+ // The tab right after Details.
355
+ tabs = [...card.slice(0, card.indexOf('</ul>')).matchAll(/<button class="nav-link[^"]*" id="rc-[^"]*-(\w+)-tab"/g)].map((m) => m[1]);
356
+ assert.deepEqual(tabs.slice(0, 2), ['details', 'caps']);
357
+ assert.doesNotMatch(details, />Capabilities</); // no longer a Details row
358
+ assert.match(caps, new RegExp(`^id="rc-${resourceId}-caps"[^>]*data-live="rc-caps-${resourceId}"`)); // updates live
359
+ assert.match(caps, /\/dev\/thub\/dut1-stlink<\/code><span class="badge text-bg-danger[^"]*"[^>]*>missing/);
360
+ });
@@ -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' \
@@ -47,10 +47,10 @@
47
47
  +code('HW machine').
48
48
  sudo systemctl restart thub-client@client
49
49
  ls -l /dev/thub/
50
- li Check the runner card's #[strong Capabilities]: each configured device must be present (a missing one is flagged).
50
+ li Check the runner card's #[strong Capabilities] tab: 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
@@ -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 runner card's #[strong Capabilities] show whether a Client can use Docker, and why not.
124
+ #[code docker] group. The runner card's #[strong Capabilities] tab shows whether a Client can use Docker, and why not.
@@ -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
@@ -298,8 +298,9 @@ mixin resourceCard(r, groupsById, returnTo)
298
298
  //- data-config-root: where public/js/client-config.js tracks unsaved
299
299
  //- changes (⚠ on the config tabs).
300
300
  .modal-body.pt-2(data-config-root)
301
- //- Details: what the Client is and reports. Then its config tabs
302
- //- (client-config.pug; read-only for viewers), then its USB devices.
301
+ //- Details: what the Client is and reports; Capabilities: what it can
302
+ //- drive. Then its config tabs (client-config.pug; read-only for
303
+ //- viewers), then its USB devices.
303
304
  ul.nav.nav-tabs.mb-3(role="tablist")
304
305
  li.nav-item(role="presentation")
305
306
  button.nav-link.active(
@@ -311,6 +312,16 @@ mixin resourceCard(r, groupsById, returnTo)
311
312
  aria-controls=`rc-${r.id}-details`
312
313
  aria-selected="true"
313
314
  ) Details
315
+ li.nav-item(role="presentation")
316
+ button.nav-link(
317
+ id=`rc-${r.id}-caps-tab`
318
+ type="button"
319
+ role="tab"
320
+ data-bs-toggle="tab"
321
+ data-bs-target=`#rc-${r.id}-caps`
322
+ aria-controls=`rc-${r.id}-caps`
323
+ aria-selected="false"
324
+ ) Capabilities
314
325
  +configTabItems(r)
315
326
  li.nav-item(role="presentation")
316
327
  button.nav-link(
@@ -326,6 +337,11 @@ mixin resourceCard(r, groupsById, returnTo)
326
337
  .tab-content
327
338
  .tab-pane.fade.show.active(id=`rc-${r.id}-details` role="tabpanel" aria-labelledby=`rc-${r.id}-details-tab` tabindex="0" data-live=`rc-details-${r.id}`)
328
339
  +resourceDetails(r, groupsById, returnTo, local, external)
340
+ //- Live too: a Client that restarts reports them again.
341
+ .tab-pane.fade(id=`rc-${r.id}-caps` role="tabpanel" aria-labelledby=`rc-${r.id}-caps-tab` tabindex="0" data-live=`rc-caps-${r.id}`)
342
+ p.small.text-body-secondary.mb-2 What the Client reported it can drive at its last start, checked against its host.
343
+ .resource-capabilities
344
+ +capabilitiesList(r.capabilities)
329
345
  +configPanes(r, returnTo, groupsById)
330
346
  .tab-pane.fade.thub-usb-pane(id=`rc-${r.id}-usb` role="tabpanel" aria-labelledby=`rc-${r.id}-usb-tab` tabindex="0")
331
347
  +usbScanPane(r)
@@ -496,14 +512,13 @@ mixin resourceDetails(r, groupsById, returnTo, local, external)
496
512
  data-bs-toggle="tooltip"
497
513
  data-bs-title="The address the Coordinator last saw this Client connect from"
498
514
  ) external
499
- dt.col-sm-4 Capabilities
500
- dd.col-sm-8.resource-capabilities
501
- +capabilitiesList(r.capabilities)
502
515
  dt.col-sm-4 Labels
503
516
  dd.col-sm-8
504
517
  if r.labels.length
505
- each l in r.labels
506
- span.badge.text-bg-light.border.me-1= l
518
+ //- One label per line, as on the Labels tab.
519
+ .d-flex.flex-column.align-items-start.gap-1
520
+ each l in r.labels
521
+ span.badge.text-bg-light.border.font-monospace= l
507
522
  else
508
523
  span.text-body-secondary —
509
524
  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