@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.
- package/bin/thub-admin.js +26 -5
- package/config.json +5 -6
- package/package.json +2 -2
- package/public/js/actions.js +58 -0
- package/public/js/log-viewer.js +188 -56
- package/public/js/settings.js +66 -0
- package/scripts/install-default-config.js +1 -1
- package/scripts/install-target.js +2 -2
- package/src/api/admin.js +1 -1
- package/src/api/agent.js +28 -22
- package/src/api/resource.js +11 -14
- package/src/api/sse.js +13 -10
- package/src/config.js +33 -10
- package/src/db/migrations/022_add_sessions.sql +10 -0
- package/src/db/migrations/023_drop_artifacts.sql +4 -0
- package/src/db/migrations/024_add_settings.sql +10 -0
- package/src/db/migrations/025_add_job_artifacts.sql +4 -0
- package/src/dev/virtual.js +10 -6
- package/src/login-guard.js +111 -0
- package/src/rate-limit.js +137 -0
- package/src/security.js +104 -0
- package/src/server.js +53 -23
- package/src/services/admin-users.js +32 -19
- package/src/services/cleanup.js +2 -2
- package/src/services/heartbeat.js +23 -3
- package/src/services/job-artifacts.js +91 -0
- package/src/services/jobs.js +17 -9
- package/src/services/logs.js +12 -14
- package/src/services/registry.js +34 -0
- package/src/services/retention.js +5 -14
- package/src/services/session-store.js +127 -0
- package/src/services/settings.js +345 -0
- package/src/web/routes.js +230 -21
- package/test/admin-users.test.js +16 -16
- package/test/agent-visibility.test.js +85 -0
- package/test/cleanup.test.js +1 -5
- package/test/groups-tab.test.js +97 -0
- package/test/job-artifacts.test.js +115 -0
- package/test/login-guard.test.js +139 -0
- package/test/logs.test.js +150 -0
- package/test/rate-limit.test.js +106 -0
- package/test/scheduler.test.js +10 -12
- package/test/security.test.js +107 -0
- package/test/session.test.js +132 -0
- package/test/settings.test.js +206 -0
- package/views/admin/settings.pug +133 -0
- package/views/groups/list.pug +1 -1
- package/views/help/_agent-cli.pug +2 -2
- package/views/help/_agent-setup.pug +4 -1
- package/views/help/_client-setup.pug +1 -1
- package/views/help/_coordinator.pug +18 -7
- package/views/help/_docker.pug +1 -1
- package/views/help/_env.pug +2 -1
- package/views/help/_git.pug +3 -2
- package/views/help/_overview.pug +2 -2
- package/views/help/_troubleshooting.pug +4 -0
- package/views/jobs/list.pug +5 -5
- package/views/jobs/show.pug +33 -7
- package/views/layout.pug +4 -1
- package/views/mixins/client-config.pug +65 -3
- package/views/mixins/list-controls.pug +1 -1
- package/views/mixins/log-viewer.pug +14 -0
- package/views/mixins/resource.pug +1 -8
- 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,
|
|
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
|
-
"
|
|
33
|
-
"
|
|
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
|
|
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
|
|
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
|
|
110
|
+
client_max_body_size 10m; # log batches, avatars
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
h3.h6 4. Users, agents and the join key
|
package/views/help/_docker.pug
CHANGED
|
@@ -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,
|
|
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.
|
package/views/help/_env.pug
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
-
|
|
10
10
|
const thubVars = [
|
|
11
11
|
['THUB_JOB_ID', 'The job id, e.g. M-00125'],
|
|
12
|
-
['
|
|
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'],
|
package/views/help/_git.pug
CHANGED
|
@@ -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
|
|
24
|
-
|
|
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.
|
package/views/help/_overview.pug
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/views/jobs/list.pug
CHANGED
|
@@ -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"
|
|
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"
|
|
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
|
|
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
|
|
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.
|
package/views/jobs/show.pug
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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"
|
|
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 —
|
|
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
|
-
|
|
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 <id>] 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<N>-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
|
-
|
|
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 };
|