@andrian.yablonskyy/thub-coordinator 1.0.38 → 1.0.40

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 (64) hide show
  1. package/bin/thub-admin.js +26 -5
  2. package/config.json +5 -6
  3. package/package.json +2 -2
  4. package/public/js/actions.js +58 -0
  5. package/public/js/log-viewer.js +188 -56
  6. package/public/js/settings.js +66 -0
  7. package/scripts/install-default-config.js +1 -1
  8. package/scripts/install-target.js +2 -2
  9. package/src/api/admin.js +1 -1
  10. package/src/api/agent.js +28 -22
  11. package/src/api/resource.js +11 -14
  12. package/src/api/sse.js +13 -10
  13. package/src/config.js +33 -10
  14. package/src/db/migrations/022_add_sessions.sql +10 -0
  15. package/src/db/migrations/023_drop_artifacts.sql +4 -0
  16. package/src/db/migrations/024_add_settings.sql +10 -0
  17. package/src/db/migrations/025_add_job_artifacts.sql +4 -0
  18. package/src/dev/virtual.js +10 -6
  19. package/src/login-guard.js +111 -0
  20. package/src/rate-limit.js +137 -0
  21. package/src/security.js +104 -0
  22. package/src/server.js +53 -23
  23. package/src/services/admin-users.js +32 -19
  24. package/src/services/cleanup.js +2 -2
  25. package/src/services/heartbeat.js +23 -3
  26. package/src/services/job-artifacts.js +91 -0
  27. package/src/services/jobs.js +17 -9
  28. package/src/services/logs.js +12 -14
  29. package/src/services/registry.js +34 -0
  30. package/src/services/retention.js +5 -14
  31. package/src/services/session-store.js +127 -0
  32. package/src/services/settings.js +345 -0
  33. package/src/web/routes.js +230 -21
  34. package/test/admin-users.test.js +16 -16
  35. package/test/agent-visibility.test.js +85 -0
  36. package/test/cleanup.test.js +1 -5
  37. package/test/groups-tab.test.js +97 -0
  38. package/test/job-artifacts.test.js +115 -0
  39. package/test/login-guard.test.js +139 -0
  40. package/test/logs.test.js +150 -0
  41. package/test/rate-limit.test.js +106 -0
  42. package/test/scheduler.test.js +10 -12
  43. package/test/security.test.js +107 -0
  44. package/test/session.test.js +132 -0
  45. package/test/settings.test.js +206 -0
  46. package/views/admin/settings.pug +133 -0
  47. package/views/groups/list.pug +1 -1
  48. package/views/help/_agent-cli.pug +2 -2
  49. package/views/help/_agent-setup.pug +4 -1
  50. package/views/help/_client-setup.pug +1 -1
  51. package/views/help/_coordinator.pug +18 -7
  52. package/views/help/_docker.pug +1 -1
  53. package/views/help/_env.pug +2 -1
  54. package/views/help/_git.pug +3 -2
  55. package/views/help/_overview.pug +2 -2
  56. package/views/help/_troubleshooting.pug +4 -0
  57. package/views/jobs/list.pug +5 -5
  58. package/views/jobs/show.pug +33 -7
  59. package/views/layout.pug +4 -1
  60. package/views/mixins/client-config.pug +65 -3
  61. package/views/mixins/list-controls.pug +1 -1
  62. package/views/mixins/log-viewer.pug +14 -0
  63. package/views/mixins/resource.pug +1 -8
  64. package/src/services/artifacts.js +0 -149
@@ -11,7 +11,7 @@
11
11
  p.small.
12
12
  #[code sudo npm i -g] does the whole setup for the user who ran #[code sudo]. It creates
13
13
  #[code ~/.config/thub/coordinator.json] (with a random #[code sessionSecret]) and
14
- #[code ~/var/lib/thub] (database, artifacts, avatars). It installs, enables and starts
14
+ #[code ~/var/lib/thub] (database, avatars). It installs, enables and starts
15
15
  #[code thub-coordinator.service], plus the root helper behind the navbar's #[strong Update app] button.
16
16
 
17
17
  h3.h6 2. Configure
@@ -25,12 +25,13 @@
25
25
  "trustProxy": "loopback",
26
26
  "dataDir": "/home/thub/var/lib/thub",
27
27
  "sessionSecret": "<long random string>",
28
+ "session": { "secureCookie": "auto" },
28
29
  "clientJoinKey": "<output of: thub-admin join-key generate>",
29
30
  "heartbeat": { "intervalSec": 10, "missedLimit": 3, "sweepIntervalSec": 5 },
30
31
  "scheduler": { "assignAckTimeoutSec": 15, "requeueOnLost": true, "maxQueuedPerAgent": 20, "tickIntervalSec": 10 },
31
32
  "jobs": { "defaultTimeoutSec": 1800, "maxTimeoutSec": 14400 },
32
- "retention": { "logRetentionDays": 14, "artifactRetentionDays": 30, "jobRetention": "forever" },
33
- "artifacts": { "maxUploadMb": 512, "linkTtlHours": 168 },
33
+ "rateLimit": { "requestsPerMinute": 500, "loginPerMinute": 10 },
34
+ "retention": { "jobRetention": "forever" },
34
35
  "updates": { "checkIntervalMin": 15, "registry": null }
35
36
  }
36
37
  .table-responsive
@@ -51,25 +52,35 @@
51
52
  td Which proxies' #[code X-Forwarded-For] to trust for a Client's external address: #[code loopback] (default), a CIDR such as #[code "10.0.0.0/8"], a hop count, or #[code false].
52
53
  tr
53
54
  td: code sessionSecret
54
- td Signs dashboard sessions and artifact download links. Keep it secret and never use the #[code change-me-…] placeholder.
55
+ td Signs dashboard session cookies. Keep it secret and never use the #[code change-me-…] placeholder.
56
+ tr
57
+ td: code session.secureCookie
58
+ td #[code "auto"] (default): the session cookie is Secure (HTTPS-only) whenever #[code publicUrl] is https://. #[code true]/#[code false] force it. Sessions are stored in the database and survive restarts.
55
59
  tr
56
60
  td: code clientJoinKey
57
61
  td The shared secret Clients self-register with. Unset or #[code null] turns registration off (#[code 503]).
58
62
  tr
59
63
  td: code heartbeat.*
60
- td Clients heartbeat every #[code intervalSec]. After #[code missedLimit] misses a resource goes #[code OUT_OF_SERVICE] and its job becomes #[code LOST].
64
+ td Clients heartbeat every #[code intervalSec] — the Coordinator tells them, so it's set here only (and on Settings). After #[code missedLimit] misses a resource goes #[code OUT_OF_SERVICE] and its job becomes #[code LOST]; #[code sweepIntervalSec] is how often that's checked.
61
65
  tr
62
66
  td: code scheduler.*
63
67
  td #[code assignAckTimeoutSec]: how long an assignment waits for the Client to accept. #[code requeueOnLost]: retry a #[code LOST] job once. #[code maxQueuedPerAgent]: queue limit per agent.
68
+ tr
69
+ td: code rateLimit.*
70
+ td Requests per minute per caller (agent/resource token, dashboard user, else IP; default 500), and sign-in attempts per IP (default 10). Past it: #[code 429] with #[code Retry-After]. #[code 0] turns a limit off.
64
71
  tr
65
72
  td: code jobs.*
66
73
  td Default and maximum job timeout, in seconds.
67
74
  tr
68
75
  td: code retention.*
69
- td #[code jobRetention]: #[code 1w], #[code 2w], #[code 1m], #[code 3m], #[code 6m] or #[code forever]. Older finished jobs, their logs and artifacts are deleted hourly.
76
+ td #[code jobRetention]: #[code 1w], #[code 2w], #[code 1m], #[code 3m], #[code 6m] or #[code forever]. Older finished jobs and their logs are deleted hourly.
70
77
  tr
71
78
  td: code updates.*
72
79
  td How often to check npm for new versions (#[code 0] turns the periodic check off), and an optional private registry.
80
+ +note('info').
81
+ Most of these can also be changed by admins on the dashboard's #[a(href="/admin/settings") Settings] page — stored in the
82
+ database on top of this file; some apply at once, the rest after a restart (#[strong Restart now] under systemd). An environment
83
+ variable still wins. Locked out by a setting? #[code thub-admin settings reset] on the Coordinator host, then restart.
73
84
  p.small.
74
85
  Resolution order: #[code THUB_COORDINATOR_CONFIG] (a path) → #[code ~/.config/thub/coordinator.json] → the bundled
75
86
  default. These environment variables override the file: #[code THUB_LISTEN], #[code THUB_PUBLIC_URL],
@@ -96,7 +107,7 @@
96
107
  proxy_http_version 1.1;
97
108
  proxy_buffering off; # SSE log streams
98
109
  proxy_read_timeout 1h;
99
- client_max_body_size 512m; # artifacts.maxUploadMb
110
+ client_max_body_size 10m; # log batches, avatars
100
111
  }
101
112
 
102
113
  h3.h6 4. Users, agents and the join key
@@ -106,7 +106,7 @@
106
106
  td Removes the container when the tests end.
107
107
  tr
108
108
  td: code -v "$THUB_WORK_DIR:/work" -w /work
109
- td The checkout and downloads, and the #[code results/] and #[code artifacts/] folders, which the Client uploads.
109
+ td The checkout and downloads, and the #[code results/] and #[code artifacts/] folders, where the Client reads JUnit XML for the test counts.
110
110
  tr
111
111
  td: code -e NAME
112
112
  td Passes a job variable (#[code --env], #[code THUB_*], #[code JOB_*]) into the container.
@@ -9,7 +9,8 @@
9
9
  -
10
10
  const thubVars = [
11
11
  ['THUB_JOB_ID', 'The job id, e.g. M-00125'],
12
- ['THUB_WORK_DIR', 'The directory the command runs in: the --git-repo checkout, else empty. results/ and artifacts/ in it are uploaded'],
12
+ ['THUB_ARTIFACTS_FILE', 'Where the command may list the artifacts it published elsewhere: a JSON array of {"name", "size", "link", "timestamp"}, shown on the job page'],
13
+ ['THUB_WORK_DIR', 'The directory the command runs in: the --git-repo checkout, else empty. JUnit XML in results/ or artifacts/ gives the test counts; deleted when the job ends'],
13
14
  ['THUB_SUITE', '--suite (default: default)'],
14
15
  ['THUB_GIT_COMMIT', 'The exact commit checked out for --git-repo'],
15
16
  ['THUB_DOWNLOADS_DIR', 'Where the --download-file files are'],
@@ -20,8 +20,9 @@
20
20
  git remote add origin git@github.com:yourorg/firmware-tests.git
21
21
  git push -u origin main
22
22
  p.small.
23
- The exit code of #[code --command] is the verdict. JUnit files under #[code results/] or #[code artifacts/] are uploaded
24
- as artifacts and summed into the job's test counts. Any other file there is uploaded too.
23
+ The exit code of #[code --command] is the verdict. JUnit files under #[code results/] or #[code artifacts/] are summed into
24
+ the job's test counts. Nothing is uploaded: the workspace is deleted when the job ends, so publish anything you need to keep
25
+ from the command itself (e.g. #[code curl -T report.html "$ARTIFACTORY/…"] with a token passed as #[code --env]).
25
26
 
26
27
  h3.h6 2. Give Clients read access: SSH deploy key (recommended)
27
28
  p.small.
@@ -13,7 +13,7 @@
13
13
  | Coordinator
14
14
  p.card-text.small.mb-0.
15
15
  This service. It holds the job queue and the resource registry, and runs the scheduler and
16
- the heartbeat monitor. It stores logs and artifacts in SQLite plus a local directory,
16
+ the heartbeat monitor. It stores job state and logs in SQLite,
17
17
  and serves this dashboard. It's the only thing that needs a public HTTPS port.
18
18
  .col-md-4
19
19
  .card.h-100
@@ -41,7 +41,7 @@
41
41
  li The Coordinator queues it (#[code QUEUED]) and the scheduler assigns it to an idle Client whose type, labels and group match.
42
42
  li The Client accepts it, clones #[code --git-repo], downloads #[code --download-file] files and prepares the DUT (#[code PREPARING]).
43
43
  li It runs #[code --command] with #[code sh -c], and output streams live to the Agent and the dashboard (#[code RUNNING]).
44
- li The exit code is the verdict: #[code 0] = #[code PASSED], anything else = #[code FAILED]. JUnit files and artifacts are uploaded.
44
+ li The exit code is the verdict: #[code 0] = #[code PASSED], anything else = #[code FAILED]. The Client reports the JUnit test counts; the job's files stay on the Client and are deleted with its workspace.
45
45
  li The Agent exits with the verdict code, so a CI step passes or fails with it.
46
46
 
47
47
  h3.h6 Key design rules
@@ -16,6 +16,10 @@
16
16
  ['Live logs don\'t stream through the proxy', 'Turn off response buffering for the Coordinator (nginx: `proxy_buffering off;`, Caddy: `flush_interval -1`).'],
17
17
  ['Finding a job, resource, group or agent', 'Use the search box on its list page (press `/` to jump to it). Every word must match, e.g. `lab-hw-01 FAILED` or a git ref. Job search covers ids, users, resources, agents, commands, repos and `--meta`, but not `--env` values (secrets). On a job page, search the log above the log viewer: `Enter`/`Shift+Enter` step through matches.'],
18
18
  ['Lists don\'t update by themselves (navbar shows Offline or Paused)', '`Paused`: you\'re editing something on the page (a field, the inline rename, a dialog); it resumes when you\'re done. `Offline`: the browser can\'t hold the `/live` stream open, often a proxy buffering it (nginx: `proxy_buffering off;`); reloading the page always shows current data. `Session ended`: sign in again. Background updates never extend the session\'s idle timeout.'],
19
+ ['Sign-in says it needs HTTPS', 'The session cookie is HTTPS-only because `publicUrl` is `https://`, but the request arrived as plain HTTP. Open the dashboard through its https:// address; behind a reverse proxy, make it send `X-Forwarded-Proto: https` (nginx: `proxy_set_header X-Forwarded-Proto $scheme;`) and check `trustProxy`. To allow plain HTTP, set `session.secureCookie` to `false`.'],
20
+ ['Where are a job\'s test reports and output files?', 'The Coordinator keeps only the log and the result (verdict, exit code, JUnit test counts). Files the job creates stay in its workspace on the Client and are deleted when the job ends. To keep them, publish them from `--command`, e.g. `curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T results/report.xml "https://artifactory.example.com/qa/$THUB_JOB_ID/report.xml"` with `--env ART_TOKEN`. To show them on the job page and in `thub status`, list them in `$THUB_ARTIFACTS_FILE` as `[{"name", "size", "link", "timestamp"}]` — only that metadata is kept. The full log is always available from the job page\'s Raw button.'],
21
+ ['429 Too Many Requests', 'A caller went over `rateLimit.requestsPerMinute` (per agent or resource token, per dashboard user, else per IP; default 500). The Agent and Client wait for `Retry-After` and retry by themselves; a script calling the API should do the same. Raise the limit in the config file or on Settings if your normal traffic needs more (a Client streaming logs makes up to ~240 requests a minute). Too many sign-in attempts from one address: wait a minute (`rateLimit.loginPerMinute`).'],
22
+ ['Sign-in says “Too many failed sign-ins”', 'After repeated wrong passwords the Coordinator makes each further attempt wait, doubling each time: per address from the 5th failure (up to 15 min), per username from the 10th from any address (up to 5 min). Wait the time shown, or sign in from another address; a successful sign-in clears it, and an hour without failures forgets it. The Coordinator never tells whether a username exists.'],
19
23
  ['Locked out of the dashboard', 'Restart the Coordinator once with `THUB_BOOTSTRAP_ADMIN_PASSWORD=<new>` (resets the `admin` user), then unset it.']
20
24
  ]
21
25
  const withCode = (text) => text
@@ -15,12 +15,12 @@ block content
15
15
  .col-12.col-md-5.col-lg-4
16
16
  +searchBox(filters.q, 'Search jobs: id, user, resource, command, repo…', list.urlFor({ q: '' }))
17
17
  .col-auto
18
- select.form-select.form-select-sm(name="state" onchange="this.form.submit()")
18
+ select.form-select.form-select-sm(name="state" data-autosubmit)
19
19
  option(value="") any state
20
20
  each s in ['QUEUED','ASSIGNED','PREPARING','RUNNING','PASSED','FAILED','ERROR','TIMEOUT','CANCELED','LOST']
21
21
  option(value=s selected=filters.state === s)= s
22
22
  .col-auto
23
- select.form-select.form-select-sm(name="source" onchange="this.form.submit()")
23
+ select.form-select.form-select-sm(name="source" data-autosubmit)
24
24
  option(value="") any source
25
25
  option(value="ci" selected=filters.source === 'ci') ci
26
26
  option(value="cli" selected=filters.source === 'cli') cli
@@ -87,7 +87,7 @@ block content
87
87
  |
88
88
  code CANCELED
89
89
  | .
90
- p.small.text-body-secondary.mb-0 Finished jobs, logs and artifacts are not touched.
90
+ p.small.text-body-secondary.mb-0 Finished jobs and their logs are not touched.
91
91
  else
92
92
  p.mb-0 There are no active jobs — nothing to cancel.
93
93
 
@@ -128,11 +128,11 @@ block content
128
128
  each preset in cleanupInfo.presets
129
129
  button.btn.btn-sm.btn-outline-secondary(
130
130
  type="button"
131
+ data-fill="#cleanup-before"
131
132
  data-value=preset.value
132
- onclick="document.getElementById('cleanup-before').value = this.dataset.value"
133
133
  )= preset.label
134
134
  p
135
- | Deletes finished jobs that ended before then, with their logs and artifacts (files too), and the resource, agent and group history logged before then — then compacts the database file.
135
+ | Deletes finished jobs that ended before then, with their logs, and the resource, agent and group history logged before then — then compacts the database file.
136
136
  |
137
137
  strong This cannot be undone.
138
138
  | Active jobs are never touched.
@@ -49,15 +49,41 @@ block content
49
49
  dt.col-sm-2 Message
50
50
  dd.col-sm-10= job.message
51
51
 
52
+ //- What the job published elsewhere and listed in $THUB_ARTIFACTS_FILE
53
+ //- (README §7.3): metadata only; the links go to wherever the files are.
52
54
  h2.h6.mt-4#artifacts Artifacts
53
- if artifacts.length
54
- ul.list-group
55
- each a in artifacts
56
- li.list-group-item.d-flex.justify-content-between
57
- span= a.name
58
- a(href=a.url) download
55
+ if job.artifacts.length
56
+ .table-responsive
57
+ table.table.table-sm.align-middle.mb-0
58
+ thead
59
+ tr
60
+ th Name
61
+ th.text-end Size
62
+ th Time
63
+ th
64
+ tbody
65
+ each a in job.artifacts
66
+ tr
67
+ td
68
+ a(href=a.link target="_blank" rel="noopener noreferrer")= a.name
69
+ td.text-end.text-nowrap(title=a.size != null ? `${a.size} bytes` : null)= a.size != null ? fmtBytes(a.size) : '—'
70
+ td.text-nowrap= a.timestamp ? fmtDate(a.timestamp) : '—'
71
+ td.text-end
72
+ a.btn.btn-sm.btn-outline-secondary(
73
+ href=a.link
74
+ target="_blank"
75
+ rel="noopener noreferrer"
76
+ aria-label=`Download ${a.name}`
77
+ data-bs-toggle="tooltip"
78
+ data-bs-title=a.link
79
+ )
80
+ i.bi.bi-download
81
+ else if jobActive
82
+ p.small.text-body-secondary.mb-0 Listed here when the job finishes, if it reports any.
59
83
  else
60
- p.text-body-secondary No artifacts yet.
84
+ p.small.text-body-secondary.mb-0.
85
+ None reported. A job lists the files it published (to Artifactory, S3, a file server…) in
86
+ #[code $THUB_ARTIFACTS_FILE] as #[code [{"name", "size", "link", "timestamp"}]].
61
87
 
62
88
  h2.h6.mt-4 Log
63
89
  +logViewer(job.id, jobActive)
package/views/layout.pug CHANGED
@@ -43,6 +43,8 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
43
43
  if user.role === 'admin'
44
44
  li.nav-item
45
45
  a.nav-link(href="/admin/agents" class=active === 'agents' ? 'active' : '') Agents
46
+ li.nav-item
47
+ a.nav-link(href="/admin/settings" class=active === 'settings' ? 'active' : '') Settings
46
48
  ul.navbar-nav
47
49
  if user && liveTopics
48
50
  //- Live-update status, driven by public/js/live.js.
@@ -64,7 +66,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
64
66
  | Help
65
67
  if user && user.role === 'admin'
66
68
  li.nav-item.d-flex.align-items-center.me-2
67
- form(method="post" action="/updates/check" onsubmit="this.querySelector('button').disabled = true; this.querySelector('i').className = 'spinner-border spinner-border-sm';")
69
+ form(method="post" action="/updates/check" data-submit-busy)
68
70
  input(type="hidden" name="returnTo" value=currentPath)
69
71
  button.btn.btn-sm.btn-outline-secondary(
70
72
  type="submit"
@@ -192,6 +194,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
192
194
  script(src="/js/copy-to-clipboard.js")
193
195
  script(src="/js/confirm.js")
194
196
  script(src="/js/list-search.js")
197
+ script(src="/js/actions.js")
195
198
  if user && liveTopics
196
199
  script(src="/js/live.js")
197
200
  if user && user.role === 'admin' && updates.coordinatorPending
@@ -88,8 +88,8 @@ mixin capabilitiesStatus(r)
88
88
  span.badge.text-bg-danger(data-bs-toggle="tooltip" data-bs-title=r.config_error) refused by the Client
89
89
 
90
90
  //- The Client's config, as tabs of the resource card next to Details:
91
- //- HW — ST-Link, UART, DUT USB (its hw-devices section); both — Reboot (the
92
- //- schedule, its own Save). An SW Client has no settings of its own. The
91
+ //- HW — ST-Link, UART, DUT USB (its hw-devices section); both — Groups
92
+ //- (membership, its own Save) and Reboot (the schedule, its own Save). An SW Client has no settings of its own. The
93
93
  //- device tabs share one form and one "Save capabilities"; a tab with
94
94
  //- unsaved changes shows ⚠ (public/js/client-config.js).
95
95
  mixin configTabItems(r)
@@ -97,9 +97,70 @@ mixin configTabItems(r)
97
97
  if r.type === 'hw'
98
98
  each t in [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'DUT USB']]
99
99
  +configTab(p, t[0], t[1])
100
+ +configTab(p, 'groups', 'Groups')
100
101
  +configTab(p, 'reboot', 'Reboot')
101
102
 
102
- mixin configPanes(r, returnTo)
103
+ //- Groups tab: which resource groups this Client belongs to (thub run
104
+ //- --group). Saved as a config revision the Client writes to its config
105
+ //- file's `groups` (registry.setClientGroups); in effect on the Coordinator
106
+ //- at once.
107
+ mixin groupsPane(r, returnTo, groupsById)
108
+ - const p = `cfg-${r.id}`
109
+ - const canEdit = user.role === 'admin'
110
+ - const pending = r.config_import && r.config_import.revision > r.config_applied_revision && r.config_import.fields && r.config_import.fields.groups
111
+ - const all = Object.values(groupsById).sort((a, b) => a.name.localeCompare(b.name))
112
+ - const unknown = r.group_ids.filter((id) => !groupsById[id])
113
+ +configPane(p, 'groups')
114
+ if pending || r.config_error
115
+ .d-flex.justify-content-end.gap-1.mb-2
116
+ if pending
117
+ span.badge.text-bg-warning(
118
+ data-bs-toggle="tooltip"
119
+ data-bs-title="In effect on the Coordinator; the Client writes it to its config file on its next heartbeat, then restarts once idle"
120
+ ) not written to the Client yet
121
+ if r.config_error
122
+ span.badge.text-bg-danger(data-bs-toggle="tooltip" data-bs-title=r.config_error) refused by the Client
123
+ if !r.client_config_file
124
+ p.small.text-body-secondary This Client is too old to take its groups from the dashboard — update it, or set #[code groups] in its config file.
125
+ if !all.length && !unknown.length
126
+ p.small.text-body-secondary.mb-0
127
+ | No groups exist yet.
128
+ if canEdit
129
+ | Create one on #[a(href="/groups") Groups].
130
+ else
131
+ form(
132
+ method="post"
133
+ action=`/resources/${r.id}/groups`
134
+ data-confirm=`Save the groups of ${r.name}? They apply at once; the Client also writes them to its config file and restarts once it has no job running.`
135
+ data-confirm-title="Save Client groups?"
136
+ data-confirm-ok="Save"
137
+ data-confirm-tone="primary"
138
+ )
139
+ input(type="hidden" name="returnTo" value=returnTo)
140
+ fieldset(disabled=!canEdit || !r.client_config_file)
141
+ .list-group.mb-2
142
+ each g in all
143
+ label.list-group-item.d-flex.gap-2.align-items-start
144
+ input.form-check-input.flex-shrink-0.mt-1(type="checkbox" name="groups" value=g.id checked=r.group_ids.includes(g.id))
145
+ span
146
+ span.fw-medium= g.name
147
+ if g.comment
148
+ span.small.text-body-secondary= ` — ${g.comment}`
149
+ br
150
+ code.small.text-body-secondary= g.id
151
+ each id in unknown
152
+ label.list-group-item.d-flex.gap-2.align-items-start
153
+ input.form-check-input.flex-shrink-0.mt-1(type="checkbox" name="groups" value=id checked)
154
+ span
155
+ span.text-body-secondary (deleted group)
156
+ br
157
+ code.small.text-body-secondary= id
158
+ .form-text A job run with #[code thub run --group &lt;id&gt;] is scheduled only on Clients in that group; one that asks for no group can run on any Client.
159
+ if canEdit && r.client_config_file
160
+ .d-flex.justify-content-end.mt-3
161
+ button.btn.btn-sm.btn-primary(type="submit") Save groups
162
+
163
+ mixin configPanes(r, returnTo, groupsById)
103
164
  - const p = `cfg-${r.id}`
104
165
  - const current = r.config_desired || r.client_config
105
166
  - const canEdit = user.role === 'admin'
@@ -137,6 +198,7 @@ mixin configPanes(r, returnTo)
137
198
  +deviceTable('usbs', 'DUT USB devices', current.usbs || [], ['Device', 'udev devpath'])
138
199
  .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.
139
200
  +saveCapabilities(canEdit)
201
+ +groupsPane(r, returnTo, groupsById || {})
140
202
  +configPane(p, 'reboot')
141
203
  dl.row.mb-0
142
204
  +rebootScheduleRow(r, returnTo)
@@ -11,7 +11,7 @@ mixin pageSizePicker(list)
11
11
  label.small.text-body-secondary.mb-0.text-nowrap(for="list-page-size") Show
12
12
  select.form-select.form-select-sm.w-auto#list-page-size(
13
13
  aria-label="Records per page"
14
- onchange="location.href = this.value"
14
+ data-navigate
15
15
  )
16
16
  each s in list.pageSizes
17
17
  option(value=list.urlFor({ size: s }) selected=list.prefs.size === s)= s === 'all' ? 'All' : s
@@ -1,6 +1,7 @@
1
1
  mixin logViewer(jobId, jobActive)
2
2
  //- Stream filter + search (public/js/log-viewer.js): matches are
3
3
  //- highlighted; Enter / Shift+Enter or the arrows step through them.
4
+ //- The log loads from its end; earlier lines load as you scroll up.
4
5
  .d-flex.flex-wrap.align-items-center.gap-2.mb-2
5
6
  select#stream-filter.form-select.form-select-sm.w-auto(aria-label="Log stream")
6
7
  option(value="") all streams
@@ -28,6 +29,19 @@ mixin logViewer(jobId, jobActive)
28
29
  .form-check.form-check-inline.small.mb-0
29
30
  input#log-search-only.form-check-input(type="checkbox")
30
31
  label.form-check-label(for="log-search-only") Display only matching lines
32
+ a.btn.btn-sm.btn-outline-secondary.ms-auto(
33
+ href=`/jobs/${jobId}/log.txt`
34
+ target="_blank"
35
+ rel="noopener"
36
+ data-bs-toggle="tooltip"
37
+ data-bs-title="Open the full log as plain text in a new tab"
38
+ )
39
+ i.bi.bi-file-earmark-text.me-1
40
+ | Raw
41
+ //- "Showing the last 1,000 of 5,321 lines · Load all" (log-viewer.js).
42
+ .d-flex.flex-wrap.align-items-center.gap-2.small.text-body-secondary.mb-1(aria-live="polite")
43
+ span#log-info Loading log…
44
+ button#log-load-all.btn.btn-link.btn-sm.p-0.align-baseline(type="button" hidden) Load all
31
45
  pre#log-viewer.bg-body-tertiary.p-3.rounded.position-relative(
32
46
  style="height: 420px; overflow-y: auto;"
33
47
  data-job-id=jobId
@@ -324,7 +324,7 @@ mixin resourceCard(r, groupsById, returnTo)
324
324
  .tab-content
325
325
  .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}`)
326
326
  +resourceDetails(r, groupsById, returnTo, local, external)
327
- +configPanes(r, returnTo)
327
+ +configPanes(r, returnTo, groupsById)
328
328
  .tab-pane.fade(id=`rc-${r.id}-usb` role="tabpanel" aria-labelledby=`rc-${r.id}-usb-tab` tabindex="0")
329
329
  +usbScanPane(r)
330
330
 
@@ -509,13 +509,6 @@ mixin resourceDetails(r, groupsById, returnTo, local, external)
509
509
  span.badge.text-bg-light.border.me-1= l
510
510
  else
511
511
  span.text-body-secondary —
512
- dt.col-sm-4 Groups
513
- dd.col-sm-8
514
- if r.group_ids.length
515
- each gid in r.group_ids
516
- span.badge.text-bg-info.me-1(data-bs-toggle="tooltip" data-bs-title=gid)= groupsById[gid] ? groupsById[gid].name : `(unknown: ${gid})`
517
- else
518
- span.text-body-secondary —
519
512
  dt.col-sm-4 Busy source / reason
520
513
  dd.col-sm-8= r.busy_source ? `${r.busy_source}${r.busy_reason ? ' — ' + r.busy_reason : ''}` : '—'
521
514
  dt.col-sm-4 Last heartbeat
@@ -1,149 +0,0 @@
1
- /**
2
- * @file packages/coordinator/src/services/artifacts.js
3
- * @description Job artifact storage: saves uploaded files and generates download links
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 fs = require('node:fs'),
17
- path = require('node:path'),
18
- crypto = require('node:crypto');
19
-
20
- // §3.1 / §12: stores uploaded result files under artifacts/<jobId>/ and
21
- // generates HMAC-signed, time-limited download links (no server-side
22
- // session needed for the Agent/CI to fetch them).
23
- function createArtifactsService(db, { config }){
24
- const secret = config.sessionSecret;
25
-
26
- function jobDir(jobId){
27
- const dir = path.join(config.artifactsDir, jobId);
28
- fs.mkdirSync(dir, { recursive: true });
29
- return dir;
30
- }
31
-
32
- function sha256File(filePath){
33
- const hash = crypto.createHash('sha256');
34
- hash.update(fs.readFileSync(filePath));
35
- return hash.digest('hex');
36
- }
37
-
38
- // `files` are multer file objects already written to a temp path.
39
- function storeUploaded(jobId, files){
40
- const dir = jobDir(jobId),
41
- stored = [];
42
- for (const file of files){
43
- const dest = path.join(dir, path.basename(file.originalname));
44
- fs.renameSync(file.path, dest);
45
- const sha256 = sha256File(dest),
46
- size = fs.statSync(dest).size,
47
- info = db
48
- .prepare(
49
- `INSERT INTO artifacts (job_id, name, path, size, sha256, content_type, created_at)
50
- VALUES (?, ?, ?, ?, ?, ?, ?)`
51
- )
52
- .run(jobId, file.originalname, dest, size, sha256, file.mimetype, new Date().toISOString());
53
- stored.push(get(info.lastInsertRowid));
54
- }
55
- return stored;
56
- }
57
-
58
- function storeGenerated(jobId, name, buffer, contentType){
59
- const dir = jobDir(jobId),
60
- dest = path.join(dir, name);
61
- fs.writeFileSync(dest, buffer);
62
- const info = db
63
- .prepare(
64
- `INSERT INTO artifacts (job_id, name, path, size, sha256, content_type, created_at)
65
- VALUES (?, ?, ?, ?, ?, ?, ?)`
66
- )
67
- .run(jobId, name, dest, buffer.length, sha256File(dest), contentType, new Date().toISOString());
68
- return get(info.lastInsertRowid);
69
- }
70
-
71
- function get(id){
72
- return db.prepare('SELECT * FROM artifacts WHERE id = ?').get(id);
73
- }
74
-
75
- function listForJob(jobId){
76
- return db.prepare('SELECT * FROM artifacts WHERE job_id = ? ORDER BY id ASC').all(jobId);
77
- }
78
-
79
- function sign(artifactId, ttlHours = config.artifacts.linkTtlHours){
80
- const expiresAt = Date.now() + ttlHours * 3600 * 1000,
81
- payload = `${artifactId}.${expiresAt}`,
82
- mac = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
83
- return `${payload}.${mac}`;
84
- }
85
-
86
- function verify(token){
87
- const [artifactId, expiresAt, mac] = String(token).split('.');
88
- if (!artifactId || !expiresAt || !mac){
89
- throw invalidLink();
90
- }
91
- const payload = `${artifactId}.${expiresAt}`,
92
- expectedMac = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
93
- if (!crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expectedMac))){
94
- throw invalidLink();
95
- }
96
- if (Date.now() > Number(expiresAt)){
97
- throw invalidLink('Link expired');
98
- }
99
- const artifact = get(Number(artifactId));
100
- if (!artifact){
101
- throw invalidLink();
102
- }
103
- return artifact;
104
- }
105
-
106
- function invalidLink(message = 'Invalid or expired link'){
107
- return Object.assign(new Error(message), { status: 403 });
108
- }
109
-
110
- function signedUrl(artifact){
111
- return `${config.publicUrl}/artifacts/download/${sign(artifact.id)}`;
112
- }
113
-
114
- // Removes a job's artifact files from disk (not the DB rows — the
115
- // caller, jobs.js's cleanHistory, deletes those as part of the same
116
- // transaction that also deletes the job itself).
117
- function deleteJobArtifacts(jobId){
118
- fs.rmSync(path.join(config.artifactsDir, jobId), { recursive: true, force: true });
119
- }
120
-
121
- function purgeOlderThan(days){
122
- const cutoff = new Date(Date.now() - days * 86400 * 1000).toISOString(),
123
- stale = db
124
- .prepare(
125
- `SELECT a.* FROM artifacts a JOIN jobs j ON j.id = a.job_id
126
- WHERE j.finished_at IS NOT NULL AND j.finished_at <= ?`
127
- )
128
- .all(cutoff);
129
- for (const artifact of stale){
130
- fs.rmSync(artifact.path, { force: true });
131
- db.prepare('DELETE FROM artifacts WHERE id = ?').run(artifact.id);
132
- }
133
- }
134
-
135
- return {
136
- storeUploaded,
137
- storeGenerated,
138
- get,
139
- listForJob,
140
- sign,
141
- verify,
142
- signedUrl,
143
- purgeOlderThan,
144
- deleteJobArtifacts,
145
- jobDir
146
- };
147
- }
148
-
149
- module.exports = { createArtifactsService };