@andrian.yablonskyy/thub-coordinator 1.0.37 → 1.0.38

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.
Files changed (44) hide show
  1. package/package.json +2 -2
  2. package/public/css/thub.css +129 -0
  3. package/public/js/config-import.js +37 -33
  4. package/public/js/copy-to-clipboard.js +21 -18
  5. package/public/js/help.js +93 -0
  6. package/public/js/list-search.js +51 -0
  7. package/public/js/live.js +287 -0
  8. package/public/js/log-viewer.js +123 -11
  9. package/public/js/remove-resource.js +5 -3
  10. package/public/js/resource-card.js +14 -13
  11. package/public/js/tooltips.js +3 -1
  12. package/src/server.js +20 -2
  13. package/src/services/jobs.js +29 -12
  14. package/src/services/list-prefs.js +4 -0
  15. package/src/services/live.js +124 -0
  16. package/src/services/search.js +59 -0
  17. package/src/web/routes.js +130 -21
  18. package/test/list-prefs.test.js +6 -0
  19. package/test/live.test.js +153 -0
  20. package/test/search.test.js +97 -0
  21. package/test/views.test.js +30 -0
  22. package/views/admin/agents.pug +28 -10
  23. package/views/groups/list.pug +14 -1
  24. package/views/help/_agent-cli.pug +103 -0
  25. package/views/help/_agent-setup.pug +72 -0
  26. package/views/help/_ci.pug +44 -0
  27. package/views/help/_client-machines.pug +87 -0
  28. package/views/help/_client-setup.pug +122 -0
  29. package/views/help/_coordinator.pug +134 -0
  30. package/views/help/_docker.pug +124 -0
  31. package/views/help/_env.pug +127 -0
  32. package/views/help/_git.pug +129 -0
  33. package/views/help/_mixins.pug +24 -0
  34. package/views/help/_overview.pug +118 -0
  35. package/views/help/_quick-start.pug +37 -0
  36. package/views/help/_troubleshooting.pug +42 -0
  37. package/views/help/index.pug +67 -0
  38. package/views/index.pug +3 -2
  39. package/views/jobs/list.pug +6 -3
  40. package/views/layout.pug +24 -1
  41. package/views/mixins/list-controls.pug +36 -0
  42. package/views/mixins/log-viewer.pug +25 -3
  43. package/views/mixins/resource.pug +1 -1
  44. package/views/resources/list.pug +7 -4
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @file packages/coordinator/test/search.test.js
3
+ * @description Tests: dashboard list search (?q=) — query parsing, in-memory matching and the Jobs page's SQL search
4
+ *
5
+ * @author Andrian Yablonskyy
6
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
7
+ *
8
+ * This file is part of TestHub and is proprietary and confidential.
9
+ * Unauthorized copying, modification, distribution, or use of this file,
10
+ * via any medium, is strictly prohibited without prior written permission
11
+ * from AdSystem.PRO.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const test = require('node:test'),
17
+ assert = require('node:assert/strict'),
18
+ { EventEmitter } = require('node:events'),
19
+ { openDb } = require('../src/db'),
20
+ { createEventsService } = require('../src/services/events'),
21
+ { createRegistryService } = require('../src/services/registry'),
22
+ { createAgentsService } = require('../src/services/agents'),
23
+ { createJobsService } = require('../src/services/jobs'),
24
+ { parseTerms, normalizeQuery, matches, likeClause, MAX_QUERY } = require('../src/services/search');
25
+
26
+ test('parseTerms splits into lower-case, unique words, capped', () => {
27
+ assert.deepEqual(parseTerms(' Lab-HW nucleo lab-hw '), ['lab-hw', 'nucleo']);
28
+ assert.deepEqual(parseTerms(''), []);
29
+ assert.deepEqual(parseTerms(undefined), []);
30
+ assert.deepEqual(parseTerms(['a']), []); // ?q=a&q=b arrives as an array: ignored
31
+ assert.equal(parseTerms(Array.from({ length: 30 }, (_, i) => `w${i}`).join(' ')).length, 10);
32
+ assert.equal(normalizeQuery(` ${'x'.repeat(500)}`).length, MAX_QUERY - 2);
33
+ });
34
+
35
+ test('matches needs every word somewhere in the fields', () => {
36
+ const fields = ['lab-hw-01', 'IDLE', ['board:nucleo-f401re', 'uart'], null, [['10.0.0.7']]];
37
+ assert.ok(matches(fields, []));
38
+ assert.ok(matches(fields, parseTerms('NUCLEO idle')));
39
+ assert.ok(matches(fields, parseTerms('10.0.0')));
40
+ assert.ok(!matches(fields, parseTerms('nucleo busy')));
41
+ });
42
+
43
+ test('likeClause ANDs the words, ORs the columns and matches % and _ literally', () => {
44
+ const { sql, params } = likeClause(['a', 'b'], ['x', '50%_off']);
45
+ assert.equal(sql, '(COALESCE(a, \'\') LIKE ? ESCAPE \'\\\' OR COALESCE(b, \'\') LIKE ? ESCAPE \'\\\') AND ' +
46
+ '(COALESCE(a, \'\') LIKE ? ESCAPE \'\\\' OR COALESCE(b, \'\') LIKE ? ESCAPE \'\\\')');
47
+ assert.deepEqual(params, ['%x%', '%x%', '%50\\%\\_off%', '%50\\%\\_off%']);
48
+ assert.deepEqual(likeClause(['a'], []), { sql: '', params: [] });
49
+ });
50
+
51
+ test('jobs.page searches ids, users, resources, agents and spec fields — never --env values', () => {
52
+ const db = openDb(':memory:'),
53
+ bus = new EventEmitter(),
54
+ events = createEventsService(db),
55
+ registry = createRegistryService(db, { bus, events }),
56
+ agents = createAgentsService(db, { events }),
57
+ ci = agents.create({ name: 'ci-firmware', kind: 'ci' }).agent,
58
+ dev = agents.create({ name: 'alice-laptop', kind: 'cli' }).agent,
59
+ config = { scheduler: { maxQueuedPerAgent: 100 }, jobs: { defaultTimeoutSec: 60, maxTimeoutSec: 60 } },
60
+ jobs = createJobsService(db, { bus, events, registry, artifacts: {}, config });
61
+ registry.registerAuto({ clientId: 'c', name: 'lab-sw-01', type: 'sw', labels: [] });
62
+
63
+ const a = jobs.create({
64
+ agentId: ci.id,
65
+ source: 'ci',
66
+ spec: {
67
+ target: { type: 'sw' },
68
+ command: './ci/smoke.sh',
69
+ git: { url: 'git@github.com:yourorg/firmware-tests.git', ref: 'release/1.4' },
70
+ meta: { ciJobId: '9165432107' },
71
+ env: { API_TOKEN: 'hunter2-secret' }
72
+ }
73
+ }).id,
74
+ m = jobs.create({
75
+ agentId: dev.id,
76
+ source: 'cli',
77
+ spec: { target: { type: 'sw' }, command: 'make test', user: 'Alice Smith', suite: 'regression_50%' }
78
+ }).id,
79
+ ids = (q, extra = {}) => jobs.page({ q, size: 'all', ...extra }).rows.map((j) => j.id).sort();
80
+
81
+ assert.deepEqual(ids(''), [a, m].sort());
82
+ assert.deepEqual(ids(a.toLowerCase()), [a]); // job id, any case
83
+ assert.deepEqual(ids('alice'), [m]); // user and agent name
84
+ assert.deepEqual(ids('ci-firmware'), [a]); // agent name
85
+ assert.deepEqual(ids('firmware-tests release/1.4'), [a]); // git url + ref, both words
86
+ assert.deepEqual(ids('9165432107'), [a]); // meta
87
+ assert.deepEqual(ids('smoke alice'), []); // words must all match the same job
88
+ assert.deepEqual(ids('regression_50%'), [m]); // % and _ are literal…
89
+ assert.deepEqual(ids('regression_5%'), []); // …not wildcards
90
+ assert.deepEqual(ids('queued', { source: 'cli' }), [m]); // combines with filters
91
+ assert.equal(jobs.page({ q: 'make', size: 'all' }).pagination.total, 1);
92
+
93
+ // --env values are secrets (README §7.2): searching them must find nothing,
94
+ // or the search box would be an oracle for them.
95
+ assert.deepEqual(ids('hunter2'), []);
96
+ assert.deepEqual(ids('API_TOKEN'), []);
97
+ });
@@ -33,3 +33,33 @@ test('every view compiles', () => {
33
33
  assert.doesNotThrow(() => pug.compileFile(file), `${path.relative(VIEWS, file)}`);
34
34
  }
35
35
  });
36
+
37
+ // The help page is mostly static text split over partials sharing mixins
38
+ // (views/help/_mixins.pug): compiling each file alone doesn't catch a missing
39
+ // mixin or a broken include, so render the whole page.
40
+ test('help page renders every section with this Coordinator\'s URL', () => {
41
+ const html = pug.renderFile(path.join(VIEWS, 'help', 'index.pug'), {
42
+ user: { username: 'viewer', role: 'viewer', theme: 'auto' },
43
+ updates: {},
44
+ messages: [],
45
+ currentPath: '/help',
46
+ active: 'help',
47
+ title: 'Help',
48
+ coordinatorVersion: '1.2.3',
49
+ commonVersion: '1.0.0',
50
+ fmtDate: () => '',
51
+ coordinatorUrl: 'https://thub.example.test'
52
+ });
53
+ for (const id of ['overview', 'use-cases', 'quick-start', 'coordinator', 'agent-setup', 'client-setup',
54
+ 'client-machines', 'docker', 'git', 'agent-cli', 'env', 'ci', 'troubleshooting']){
55
+ assert.match(html, new RegExp(`<section[^>]* id="${id}"`), `section #${id}`);
56
+ assert.match(html, new RegExp(`href="#${id}"`), `TOC entry for #${id}`);
57
+ }
58
+ assert.match(html, /thub config set url {3}https:\/\/thub\.example\.test/);
59
+ assert.match(html, /<a class="nav-link d-flex align-items-center active" href="\/help" aria-current="page"/);
60
+ // Troubleshooting: commands and parameters are <code>, placeholders escaped.
61
+ assert.match(html, /<code>journalctl -u thub-client@&lt;instance&gt; -f<\/code>/);
62
+ assert.match(html, /<span>The job is rejected with <code>422<\/code><\/span>/);
63
+ // Code placeholders are escaped, never parsed as tags.
64
+ assert.doesNotMatch(html, /<(url|jobId|groupId|resourceId|work|ref)>/);
65
+ });
@@ -1,12 +1,17 @@
1
1
  extends ../layout
2
2
  include ../mixins/updates
3
+ include ../mixins/list-controls
3
4
 
4
5
  block content
5
6
  .d-flex.justify-content-between.align-items-center.mb-3
6
7
  h1.h4.mb-0 Agents (CI / developers)
7
8
  button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addAgentModal") Register agent
8
- .mb-3
9
- +updatesToolbar('agent', 'agents', '/admin/agents')
9
+ .d-flex.flex-wrap.align-items-center.gap-2.mb-3
10
+ +updatesToolbar('agent', 'agents', list.pageUrl())
11
+ .d-flex.flex-wrap.align-items-center.gap-2.mb-3
12
+ +searchForm(q, 'Search agents: name, kind, version, status', list.urlFor({ q: '' }))
13
+ .ms-auto
14
+ +pageSizePicker(list)
10
15
 
11
16
  if newToken
12
17
  .alert.alert-warning
@@ -14,23 +19,26 @@ block content
14
19
  |
15
20
  code= newToken
16
21
 
22
+ div(data-live="list-toolbar")
23
+ +listToolbar(list, 'agents')
24
+
17
25
  table.table.table-hover.align-middle
18
26
  thead
19
27
  tr
20
- th Name
21
- th Kind
22
- th Version
23
- th Created
24
- th Last used
25
- th Status
28
+ +sortHeader(list, 'name', 'Name')
29
+ +sortHeader(list, 'kind', 'Kind')
30
+ +sortHeader(list, 'version', 'Version', 'desc')
31
+ +sortHeader(list, 'created', 'Created', 'desc')
32
+ +sortHeader(list, 'used', 'Last used', 'desc')
33
+ +sortHeader(list, 'status', 'Status')
26
34
  th
27
- tbody
35
+ tbody(data-live="rows")
28
36
  each a in agents
29
37
  tr(class=a.revoked_at ? 'opacity-50' : '')
30
38
  td= a.name
31
39
  td.text-uppercase= a.kind
32
40
  //- A revoked token can't pick an update up: no button.
33
- td: +versionWithUpdate(a.version, updates.latest.agent, a.update_to, `/admin/agents/${a.id}/update`, '/admin/agents', !a.revoked_at)
41
+ td: +versionWithUpdate(a.version, updates.latest.agent, a.update_to, `/admin/agents/${a.id}/update`, list.pageUrl(), !a.revoked_at)
34
42
  td= fmtDate(a.created_at)
35
43
  td= fmtDate(a.last_used_at, 'never')
36
44
  td= a.revoked_at ? 'revoked' : 'active'
@@ -50,12 +58,14 @@ block content
50
58
  data-confirm-title="Revoke agent token?"
51
59
  data-confirm-ok="Revoke"
52
60
  )
61
+ input(type="hidden" name="returnTo" value=list.pageUrl())
53
62
  button.btn.btn-sm.btn-outline-danger(type="submit") Revoke
54
63
 
55
64
  .modal(tabindex="-1" id=`editAgentModal-${a.id}` aria-labelledby=`editAgentTitle-${a.id}`)
56
65
  .modal-dialog
57
66
  .modal-content
58
67
  form(method="post" action=`/admin/agents/${a.id}/rename`)
68
+ input(type="hidden" name="returnTo" value=list.pageUrl())
59
69
  .modal-header
60
70
  h5.modal-title(id=`editAgentTitle-${a.id}`) Edit agent
61
71
  button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
@@ -66,6 +76,14 @@ block content
66
76
  .modal-footer
67
77
  button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
68
78
  button.btn.btn-primary(type="submit") Save
79
+ else
80
+ tr
81
+ td.text-body-secondary(colspan="7")
82
+ if q
83
+ | No agent matches “#{q}”.
84
+ a(href=list.urlFor({ q: '' })) Show all agents
85
+ else
86
+ | No agents yet.
69
87
 
70
88
  .modal(tabindex="-1" id="addAgentModal")
71
89
  .modal-dialog
@@ -1,4 +1,5 @@
1
1
  extends ../layout
2
+ include ../mixins/list-controls
2
3
 
3
4
  block content
4
5
  .d-flex.justify-content-between.align-items-center.mb-3
@@ -6,10 +7,18 @@ block content
6
7
  if canManage
7
8
  button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addGroupModal") Add group
8
9
 
9
- p.text-body-secondary.small.
10
+ p.text-body-secondary.small: i.
10
11
  A job can be constrained to run only on resources in a group with `thub run --group &lt;id&gt;` (§13.1).
11
12
  A resource joins a group by listing its id under `groups` in the Client's own config — membership isn't set here.
12
13
 
14
+ if totalCount
15
+ .d-flex.flex-wrap.align-items-center.gap-2.mb-2
16
+ +searchForm(q, 'Search groups: name, id, comment', '/groups')
17
+ if q
18
+ span.small.text-body-secondary
19
+ | #{groups.length} of #{totalCount} groups
20
+ +searchNote(q)
21
+
13
22
  if groups.length
14
23
  table.table.table-hover.align-middle
15
24
  thead
@@ -77,6 +86,10 @@ block content
77
86
  i.bi.bi-clipboard
78
87
  .modal-footer
79
88
  button.btn.btn-primary(type="submit") Save
89
+ else if q
90
+ p.text-body-secondary
91
+ | No group matches “#{q}”.
92
+ a(href="/groups") Show all groups
80
93
  else
81
94
  p.text-body-secondary No groups yet.
82
95
 
@@ -0,0 +1,103 @@
1
+ +section('agent-cli', 'Agent CLI reference', 'code-square')
2
+ h3.h6 Commands
3
+ +code('thub --help').
4
+ thub run [options] Submit a job and follow its log
5
+ thub status &lt;jobId> [--json] Status; follow the log if running, artifact links if done
6
+ thub cancel &lt;jobId> Cancel a job
7
+ thub resources [--json] Clients and their status
8
+ thub jobs [--mine] [--state &lt;s>] [--json] Recent jobs
9
+ thub config set &lt;url|token|group|user> &lt;value>
10
+ thub check-update Compare with the latest published Agent
11
+ thub self-update [--to &lt;x.y.z>] Update with npm i -g
12
+ thub --version
13
+ Global: --url &lt;url> --token &lt;token> (override THUB_URL / THUB_TOKEN / config file)
14
+
15
+ h3.h6 #[code thub run] options
16
+ -
17
+ const groups = [
18
+ { title: 'Where to run', rows: [
19
+ ['--type hw|sw', 'Required. Resource type.', '--type hw'],
20
+ ['--board <name>', 'Shorthand for --label board:<name>.', '--board nucleo-f401re'],
21
+ ['--label <l>', 'Required label (repeatable). The Client must have all of them.', '--label uart --label stlink'],
22
+ ['--group <id>', 'Only Clients in this group. Default: THUB_GROUP / config.', '--group 548ae4ae-…'],
23
+ ['--client <name|id>', 'Only this Client; waits for it even if others are idle.', '--client lab-hw-01']
24
+ ] },
25
+ { title: 'What to run', rows: [
26
+ ['--command <string>', 'Required. Run with sh -c in the work directory. Exit code 0 = PASSED.', "--command './ci/test.sh'"],
27
+ ['--arg <value>', 'Argument for the command, as "$@" (repeatable).', '--arg --junit --arg -v'],
28
+ ['--suite <name>', 'Passed as THUB_SUITE (default: default).', '--suite smoke'],
29
+ ['--download-file <url>', 'File downloaded before the command (repeatable, http(s), no credentials). THUB_DOWNLOAD_1…', '--download-file https://…/app.bin'],
30
+ ['--git-repo <url> [ref]', 'Repository cloned first; the command runs in it.', '--git-repo git@github.com:org/tests.git v1.4.0'],
31
+ ['--depth <n>', 'Commits to fetch with --git-repo (default 1, 0 = full).', '--depth 0'],
32
+ ['--git-options <string>', 'Extra git options between git and its subcommand. Stored with the job.', '--git-options \'-c core.sshCommand="ssh -i ~/.ssh/k"\''],
33
+ ['--docker-image <ref>', 'SW only: DUT container image, pulled with the Client host’s Docker login.', '--docker-image registry.lab:5000/emu:1'],
34
+ ['--env <vars>', 'NAME=value[,NAME=value] for git and the command (repeatable). --env NAME takes the value from your shell. Masked, dropped at job end.', '--env TARGET=staging --env API_TOKEN']
35
+ ] },
36
+ { title: 'Job settings', rows: [
37
+ ['--timeout <dur>', 'e.g. 30m, 1h (default 30m, capped by the Coordinator).', '--timeout 1h'],
38
+ ['--priority <n>', '0–100. Defaults: ci 50, cli 60.', '--priority 80'],
39
+ ['--user <name>', 'Free-text owner label. Default: THUB_USER / config.', '--user alice'],
40
+ ['--meta <key=value>', 'Metadata stored on the job (repeatable). The command sees THUB_META_<KEY>.', '--meta ciJobId=$GITHUB_RUN_ID'],
41
+ ['--dry-run', 'Schedule for real, but only log what the Client would run.', '--dry-run']
42
+ ] },
43
+ { title: 'Agent behavior', rows: [
44
+ ['--wait', 'Follow to the end and exit with the verdict code (CI). Ctrl-C/SIGINT cancels the job.', '--wait'],
45
+ ['--detach', 'Print the job id and exit.', '--detach --json'],
46
+ ['--json', 'Machine-readable output.', '--json']
47
+ ] }
48
+ ]
49
+ each g in groups
50
+ h4.h6.text-body-secondary.mt-3= g.title
51
+ .table-responsive
52
+ table.table.table-sm.small.align-middle
53
+ thead
54
+ tr
55
+ th(style="width: 22%") Option
56
+ th Description
57
+ th(style="width: 32%") Example
58
+ tbody
59
+ each r in g.rows
60
+ tr
61
+ td: code.text-nowrap= r[0]
62
+ td= r[1]
63
+ td: code= r[2]
64
+
65
+ h3.h6 Exit codes
66
+ table.table.table-sm.small.w-auto
67
+ tbody
68
+ each e in [['0', 'PASSED'], ['1', 'FAILED'], ['2', 'ERROR, TIMEOUT or LOST'], ['3', 'CANCELED'], ['4', 'Usage, auth or connection error'], ['5', 'thub status --json: still queued or running'], ['130', 'Detached with Ctrl-C (job keeps running)']]
69
+ tr
70
+ td: code= e[0]
71
+ td= e[1]
72
+
73
+ h3.h6 Examples
74
+ +code('Flash and test a board, fail CI on failure').
75
+ thub run --type hw --board nucleo-f401re \
76
+ --download-file https://artifactory.example.com/fw-local/app/1.4.0-42/app.bin \
77
+ --git-repo https://github.com/yourorg/firmware-tests.git v1.4.0 \
78
+ --command 'st-flash --serial "$THUB_DUT_STLINK" --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/test.sh "$@"' \
79
+ --arg --junit --timeout 45m --wait
80
+ +code('Reproduce a failure on the exact bench, labeled with your name').
81
+ thub run --type hw --board nucleo-f401re --client lab-hw-01 --user "$USER" \
82
+ --download-file "$IMAGE_URL" --git-repo "$TESTS_REPO" --command ./ci/test.sh
83
+ # Ctrl-C detaches; the job keeps running. Re-attach:
84
+ thub status M-00126
85
+ +code('Verify a download before using it').
86
+ thub run --type hw --download-file https://artifactory.example.com/fw-local/app/1.4.0-42/app.bin \
87
+ --command 'echo "8f15bf27… $THUB_DOWNLOAD_1" | sha256sum -c && ./flash.sh "$THUB_DOWNLOAD_1"' --wait
88
+ +code('Secrets and parameters').
89
+ export API_TOKEN=…
90
+ thub run --type sw --git-repo "$TESTS_REPO" \
91
+ --env TARGET=staging,LOG_LEVEL=debug --env API_TOKEN \
92
+ --command './run-tests.sh --target "$TARGET"' --suite regression --wait
93
+ +code('Preview exactly what would run (nothing is executed)').
94
+ thub run --type sw --docker-image alpine --git-repo https://example.invalid/tests.git main \
95
+ --command ./ci/test.sh --dry-run --wait
96
+ +code('Scripting: submit, poll, read the result').
97
+ JOB=$(thub run --type sw --git-repo "$TESTS_REPO" --command ./ci/test.sh --detach --json | jq -r .jobId)
98
+ while thub status "$JOB" --json > job.json; [ $? -eq 5 ]; do sleep 10; done
99
+ jq -r '"\(.state) exit=\(.exit_code) tests=\(.summary.total // 0) failed=\(.summary.failed // 0)"' job.json
100
+ +code('Lists').
101
+ thub resources
102
+ thub jobs --mine --state FAILED
103
+ thub cancel M-00126
@@ -0,0 +1,72 @@
1
+ +section('agent-setup', 'Agent setup', 'terminal')
2
+ p.
3
+ The Agent (#[code thub]) is a stateless Node.js CLI. Install it wherever jobs are submitted from:
4
+ a developer machine (macOS, Linux or Windows with Node.js 20+) or a CI runner.
5
+
6
+ h3.h6 Install
7
+ +code('Developer machine').
8
+ npm i -g @andrian.yablonskyy/thub-agent
9
+ thub --version
10
+ p.small.
11
+ In CI you don't need a global install: #[code npx -y @andrian.yablonskyy/thub-agent run …].
12
+
13
+ h3.h6 Get a token
14
+ p.small.
15
+ An admin registers an agent on #[a(href="/admin/agents") Agents] (or with #[code thub-admin agent add]) and passes you
16
+ its token, which is shown only once. The agent's #[strong kind] decides the job id prefix and the default priority:
17
+ table.table.table-sm.small.w-auto
18
+ thead
19
+ tr
20
+ th Kind
21
+ th For
22
+ th Job ids
23
+ th Default priority
24
+ tbody
25
+ tr
26
+ td: code ci
27
+ td Pipelines
28
+ td: code A-00001…
29
+ td 50
30
+ tr
31
+ td: code cli
32
+ td Developers
33
+ td: code M-00001…
34
+ td 60 (so a busy pipeline doesn't starve them)
35
+
36
+ h3.h6 Configure
37
+ +code('Save the settings once').
38
+ thub config set url #{coordinatorUrl}
39
+ thub config set token agt_…
40
+ thub config set user "Your Name" # optional: job owner label
41
+ thub config set group &lt;groupId> # optional: default --group
42
+ p.small.
43
+ These are saved in #[code ~/.config/thub/agent.json]. Order of precedence: flags (#[code --url], #[code --token]) →
44
+ environment → config file.
45
+ table.table.table-sm.small
46
+ thead
47
+ tr
48
+ th Variable
49
+ th Meaning
50
+ tbody
51
+ tr
52
+ td: code THUB_URL
53
+ td Coordinator URL, e.g. #[code #{coordinatorUrl}]
54
+ tr
55
+ td: code THUB_TOKEN
56
+ td Agent token. Keep it in your CI's secret store.
57
+ tr
58
+ td: code THUB_GROUP
59
+ td Default #[code --group]
60
+ tr
61
+ td: code THUB_USER
62
+ td Default #[code --user]
63
+ tr
64
+ td: code THUB_NO_SELF_UPDATE=1
65
+ td Don't install admin-requested Agent updates on this machine
66
+ +code('Check the connection').
67
+ thub resources # lists the Clients and their status
68
+ thub jobs --mine # your recent jobs
69
+ p.small.mb-0.
70
+ When an admin requests an Agent update, the next #[code thub] command installs it and then runs on
71
+ the new version. If it can't install (e.g. no permission in CI), it prints the manual command and
72
+ carries on with the current version.
@@ -0,0 +1,44 @@
1
+ +section('ci', 'CI/CD integration (GitHub Actions)', 'github')
2
+ p.
3
+ The test job only needs the Agent and HTTPS access to the Coordinator, so it runs on an ordinary hosted runner.
4
+ Create a #[strong ci]-kind agent on #[a(href="/admin/agents") Agents] and store its token as the repository secret
5
+ #[code THUB_AGENT_TOKEN].
6
+ +code('.github/workflows/firmware.yml').
7
+ name: firmware
8
+ on: [push, pull_request]
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: [self-hosted, fw-build]
13
+ outputs:
14
+ image_url: ${{ steps.upload.outputs.image_url }}
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - run: cmake -B build -G Ninja && cmake --build build
18
+ - id: upload
19
+ env: { ART_TOKEN: "${{ secrets.ARTIFACTORY_TOKEN }}" }
20
+ run: |
21
+ URL="https://artifactory.example.com/fw-local/app/${GITHUB_SHA::7}-${GITHUB_RUN_NUMBER}/app.bin"
22
+ curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T build/app.bin "$URL"
23
+ echo "image_url=$URL" >> "$GITHUB_OUTPUT"
24
+
25
+ test-hw:
26
+ needs: build
27
+ runs-on: ubuntu-latest
28
+ env:
29
+ THUB_URL: #{coordinatorUrl}
30
+ THUB_TOKEN: ${{ secrets.THUB_AGENT_TOKEN }}
31
+ steps:
32
+ - run: |
33
+ npx -y @andrian.yablonskyy/thub-agent run \
34
+ --type hw --board nucleo-f401re \
35
+ --download-file "${{ needs.build.outputs.image_url }}" \
36
+ --git-repo "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" "$GITHUB_SHA" \
37
+ --command 'st-flash --serial "$THUB_DUT_STLINK" --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/hw-tests.sh' \
38
+ --suite smoke --timeout 30m --wait \
39
+ --meta runUrl="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
40
+ ul.small.mb-0
41
+ li The step's exit code is the verdict (#[a(href="#agent-cli") exit codes]), so the workflow fails exactly when the tests do.
42
+ li If the workflow is canceled, the Agent (in #[code --wait] mode) cancels the TestHub job, so abandoned runs don't hold hardware.
43
+ li Downloads carry no credentials. Publish the image to a URL the Client can read, or fetch it in #[code --command] with a token passed as #[code --env].
44
+ li Other CI systems (GitLab CI, Jenkins, Azure Pipelines) work the same way: install Node.js, set #[code THUB_URL]/#[code THUB_TOKEN] and run #[code thub run … --wait].
@@ -0,0 +1,87 @@
1
+ +section('client-machines', 'Client machines: HW and SW', 'pc')
2
+ p.
3
+ Which machine you need depends on what the jobs run against. Clients are supported on
4
+ #[strong Ubuntu 26.04]; other systemd-based Linux distributions work the same way in practice.
5
+ Every Client machine needs outbound HTTPS to the Coordinator (and to wherever jobs download from:
6
+ Artifactory, git servers, Docker registries). Nothing inbound.
7
+
8
+ .row.g-3.mb-3
9
+ .col-md-6
10
+ .card.h-100.border-primary-subtle
11
+ .card-header
12
+ i.bi.bi-cpu.me-1
13
+ strong HW Client: real machine with a physical DUT
14
+ .card-body.small
15
+ ul.mb-0
16
+ li A physical machine (a mini-PC, NUC or Raspberry-class ARM64 board) next to the bench.
17
+ li USB for the DUT's debugger (ST-Link), USB-serial adapters (UART) and DUT USB. A #[strong powered USB hub] is recommended.
18
+ li #[code stlink-tools] and/or #[code openocd], #[code usbutils] (for the dashboard's USB scan).
19
+ li Docker only if jobs run containers in their #[code --command].
20
+ li One Client instance per board. One machine can drive several boards (up to 8 ST-Links, UARTs and USB devices per instance).
21
+ .col-md-6
22
+ .card.h-100.border-success-subtle
23
+ .card-header
24
+ i.bi.bi-pc-display.me-1
25
+ strong SW Client: real or virtual machine, software-only jobs
26
+ .card-body.small
27
+ ul.mb-0
28
+ li Any Linux host: bare metal, a VM (Proxmox, VMware, Hyper-V, KVM) or a cloud instance.
29
+ li Docker Engine (#[code docker.io]), with the Client's service user in the #[code docker] group (the installer arranges this).
30
+ li Each job's DUT container is limited to 2 CPUs and 2 GB RAM. Size the VM for one job plus your test tools: 4 vCPU, 8 GB RAM and 40 GB+ disk for images and workspaces is a good start.
31
+ li For KVM-accelerated emulators (QEMU) inside a VM, turn on #[strong nested virtualization].
32
+ li No USB or udev setup.
33
+
34
+ h3.h6 Preparing an HW machine
35
+ ol.small
36
+ li Install the OS and the Client (#[a(href="#client-setup") Client setup]), then plug in the board's ST-Link, UART adapter and DUT USB.
37
+ li
38
+ | Find each device's USB #[strong port path] (#[code devpath]). It's stable across replugs as long as the device stays in the same port:
39
+ +code('HW machine').
40
+ lsusb -t # USB tree
41
+ udevadm info -a -n /dev/ttyUSB0 | grep -m1 'ATTRS{devpath}'
42
+ st-info --probe # ST-Link serials
43
+ | Or open the resource card → #[strong Connected USB devices] → #[strong Refresh], then #[strong Import to config].
44
+ li
45
+ | Put the devpaths into #[code hw-devices] and restart. On every start the Client writes
46
+ | #[code /etc/udev/rules.d/99-thub-&lt;instance&gt;.rules], so the devices appear as #[code /dev/thub/dut&lt;N&gt;-stlink|uart|usb]:
47
+ +code('HW machine').
48
+ sudo systemctl restart thub-client@client
49
+ ls -l /dev/thub/
50
+ li Check the resource card's #[strong Capabilities]: each configured device must be present (a missing one is flagged).
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
+ +code('A typical HW job').
53
+ thub run --type hw --board nucleo-f401re \
54
+ --download-file https://artifactory.example.com/fw-local/app/1.4.0-42/app.bin \
55
+ --git-repo https://github.com/yourorg/firmware-tests.git v1.4.0 \
56
+ --command 'st-flash --serial "$THUB_DUT_STLINK" --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/test.sh' \
57
+ --wait
58
+
59
+ +note('warning').
60
+ #[strong HW in a VM?] USB passthrough works, but the #[code devpath] then reflects the #[em virtual] USB topology and
61
+ can change when the hypervisor reassigns devices. Serial timing-sensitive tests may also suffer. Use a physical machine for HW Clients.
62
+
63
+ h3.h6 Preparing an SW machine (real or virtual)
64
+ +code('SW machine / VM').
65
+ sudo apt install -y nodejs npm docker.io
66
+ sudo npm i -g @andrian.yablonskyy/thub-client
67
+ nano ~/.config/thub/client.json # "type": "sw", coordinatorUrl, joinKey
68
+ sudo systemctl enable --now thub-client@client
69
+ # a private registry for DUT images? log in once, as the Client's user:
70
+ docker login registry.lab.local:5000
71
+ p.small.
72
+ To scale out, clone the VM template. In the clone, #[strong before its first start], run
73
+ #[code rm -f ~/var/lib/thub/client/*/.client-id ~/var/lib/thub/client/*.token] and give it its own #[code name].
74
+ Otherwise both VMs register as the same resource.
75
+ +code('A typical SW job').
76
+ thub run --type sw \
77
+ --docker-image registry.lab.local:5000/dut-emulator:2026.08 \
78
+ --git-repo git@github.com:yourorg/firmware-tests.git main \
79
+ --command 'make test DUT="$THUB_DUT_HOST"' --wait
80
+
81
+ h3.h6 Checklist for any Client machine
82
+ ul.small.mb-0
83
+ li Correct time (NTP). Timestamps are anchored to the Coordinator's clock, but TLS needs a sane clock.
84
+ li Outbound HTTPS to the Coordinator; test it with #[code curl -sI #{coordinatorUrl}/login].
85
+ li The Client's service user has SSH keys and git credentials for private repos (#[a(href="#git") Git repositories]) and Docker logins for private registries (#[a(href="#docker") Docker registry]).
86
+ li Under systemd, the service can only write to its state directory. Jobs write to #[code $THUB_WORK_DIR], not to #[code ~].
87
+ li Optional: a nightly reboot from the resource card's #[strong Reboot] tab (cron, host-local time). It waits for running jobs.