@andrian.yablonskyy/thub-coordinator 1.0.43 → 1.1.1

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.
@@ -10,40 +10,42 @@
10
10
  p.small.
11
11
  In CI you don't need a global install: #[code npx -y @andrian.yablonskyy/thub-agent run …].
12
12
 
13
- h3.h6 Get a token
13
+ h3.h6 Get your access key
14
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, the default priority and which jobs it can see:
15
+ Every person has a user with one #[strong access key]. An admin creates you on #[a(href="/admin/users") Users]
16
+ (or with #[code thub-admin user add]) and hands you the key, which is shown only once. Maintainers and admins can also
17
+ create or replace their own on their #[a(href="/profile") profile]. Your #[strong role] decides what you can do:
17
18
  table.table.table-sm.small.w-auto
18
19
  thead
19
20
  tr
20
- th Kind
21
- th For
22
- th Job ids
23
- th Default priority
24
- th Can see
21
+ th Role
22
+ th Agent
23
+ th Dashboard
25
24
  tbody
26
25
  tr
27
- td: code ci
28
- td Pipelines
29
- td: code A-00001…
30
- td 50
31
- td every job (details, log)
26
+ td: strong User
27
+ td submits jobs, sees and cancels only their own
28
+ td no
32
29
  tr
33
- td: code cli
34
- td Developers
35
- td: code M-00001…
36
- td 60 (so a busy pipeline doesn't starve them)
37
- td only the jobs it submitted
30
+ td: strong Maintainer
31
+ td submits jobs, sees and cancels every job
32
+ td yes, without Users, Settings and CI tokens
33
+ tr
34
+ td: strong Admin
35
+ td like a Maintainer
36
+ td everything
37
+ p.small.
38
+ Pipelines use a #[strong CI token] instead (#[a(href="/admin/agents") CI tokens], admins): their jobs get A- ids and they
39
+ read every job. People's jobs get M- ids.
38
40
 
39
41
  h3.h6 Configure
40
42
  +code('Save the settings once').
41
43
  thub config set url #{coordinatorUrl}
42
- thub config set token agt_…
44
+ thub config set key thk_…
43
45
  thub config set user "Your Name" # optional: job owner label
44
46
  thub config set group <groupId> # optional: default --group
45
47
  p.small.
46
- These are saved in #[code ~/.config/thub/agent.json]. Order of precedence: flags (#[code --url], #[code --token]) →
48
+ These are saved in #[code ~/.config/thub/agent.json]. Order of precedence: flags (#[code --url], #[code --key]) →
47
49
  environment → config file.
48
50
  table.table.table-sm.small
49
51
  thead
@@ -55,8 +57,8 @@
55
57
  td: code THUB_URL
56
58
  td Coordinator URL, e.g. #[code #{coordinatorUrl}]
57
59
  tr
58
- td: code THUB_TOKEN
59
- td Agent token. Keep it in your CI's secret store.
60
+ td: code THUB_KEY
61
+ td Your access key, or a CI token in a pipeline. Keep it in your CI's secret store. (#[code THUB_TOKEN], #[code --token] and a saved #[code token] still work: the old names.)
60
62
  tr
61
63
  td: code THUB_GROUP
62
64
  td Default #[code --group]
@@ -66,7 +68,10 @@
66
68
  tr
67
69
  td: code THUB_NO_SELF_UPDATE=1
68
70
  td Don't install admin-requested Agent updates on this machine
69
- +code('Check the connection').
71
+ +code('Check the connection and your key').
72
+ thub whoami # jane (user) <jane@example.com>
73
+ thub key show # your key's last characters, created, last used
74
+ thub key rotate # replace it — the old one stops at once; saved to the config file if it came from there
70
75
  thub resources # lists the Clients and their status
71
76
  thub jobs --mine # your recent jobs
72
77
  p.small.mb-0.
@@ -1,8 +1,8 @@
1
1
  +section('ci', 'CI/CD integration (GitHub Actions)', 'github')
2
2
  p.
3
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].
4
+ Create a #[strong CI token] on #[a(href="/admin/agents") CI tokens] (admins) and store it as the repository secret
5
+ #[code THUB_CI_TOKEN].
6
6
  +code('.github/workflows/firmware.yml').
7
7
  name: firmware
8
8
  on: [push, pull_request]
@@ -27,7 +27,7 @@
27
27
  runs-on: ubuntu-latest
28
28
  env:
29
29
  THUB_URL: #{coordinatorUrl}
30
- THUB_TOKEN: ${{ secrets.THUB_AGENT_TOKEN }}
30
+ THUB_KEY: ${{ secrets.THUB_CI_TOKEN }}
31
31
  steps:
32
32
  - run: |
33
33
  npx -y @andrian.yablonskyy/thub-agent run \
@@ -41,4 +41,4 @@
41
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
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
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].
44
+ li Other CI systems (GitLab CI, Jenkins, Azure Pipelines) work the same way: install Node.js, set #[code THUB_URL]/#[code THUB_KEY] and run #[code thub run … --wait].
@@ -114,10 +114,11 @@
114
114
  p.small.
115
115
  #[code thub-admin] works directly on the database. Run it on the Coordinator host as the service user.
116
116
  +code('Coordinator host').
117
- thub-admin create-admin alice 's3cret' --role admin # or --role viewer (read-only dashboard)
117
+ thub-admin user add alice --email alice@example.com --role admin --password 's3cret'
118
+ thub-admin user add bob --email bob@example.com --key # role user (Agent only); prints his key once
119
+ thub-admin user list | block | unblock | role | password | key | delete …
118
120
  thub-admin join-key generate # → clientJoinKey here, joinKey on every Client
119
- thub-admin agent add ci-firmware --kind ci # CI token: job ids A-00001…
120
- thub-admin agent add alice-laptop --kind cli # developer token: job ids M-00001…
121
+ thub-admin agent add ci-firmware # CI token for a pipeline: job ids A-00001…
121
122
  thub-admin group add ci-nightly --comment "nightly pool" # → a group id for thub run --group
122
123
  thub-admin resource maintenance &lt;resourceId> --on
123
124
  thub-admin jobs reset --yes # cancel every active job
@@ -68,7 +68,7 @@
68
68
  td: code.text-nowrap= v[1]
69
69
  td= v[2]
70
70
  p.small.
71
- #[code --url], #[code --token], #[code --wait], #[code --detach], #[code --json] and #[code --dry-run] only affect the Agent and
71
+ #[code --url], #[code --key], #[code --wait], #[code --detach], #[code --json] and #[code --dry-run] only affect the Agent and
72
72
  have no variable. The Client's own git commands also get #[code GIT_TERMINAL_PROMPT=0] and #[code GIT_ALLOW_PROTOCOL=http:https:ssh:git].
73
73
 
74
74
  h3.h6 Your own variables: #[code --env]
@@ -37,7 +37,7 @@
37
37
 
38
38
  h3.h6 How a job flows
39
39
  ol.small
40
- li #[code thub run …] submits a job spec (type, labels, command, inputs) with an agent token.
40
+ li #[code thub run …] submits a job spec (type, labels, command, inputs) with your access key (or a pipeline's CI token).
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]).
@@ -9,7 +9,7 @@
9
9
  thub-admin join-key generate # → put it in clientJoinKey
10
10
  nano ~/.config/thub/coordinator.json # publicUrl, clientJoinKey
11
11
  sudo systemctl restart thub-coordinator
12
- thub-admin create-admin admin 'a-strong-password'
12
+ thub-admin user add admin --email you@example.com --role admin --password 'a-strong-password'
13
13
  li
14
14
  strong Install a Client
15
15
  | on each lab machine, with the same join key (#[a(href="#client-setup") Client setup]):
@@ -20,16 +20,16 @@
20
20
  sudo systemctl enable --now thub-client@client
21
21
  | It shows up on #[a(href="/resources") Resources] by itself, usually within 10 s.
22
22
  li
23
- strong Issue an agent token
24
- | on #[a(href="/admin/agents") Agents] (admins), or on the Coordinator host with
25
- | #[code thub-admin agent add &lt;name&gt; --kind ci|cli]. The token is shown once.
23
+ strong Create users and their access keys
24
+ | on #[a(href="/admin/users") Users] (admins), or on the Coordinator host with
25
+ | #[code thub-admin user add &lt;name&gt; --email &lt;email&gt; --key]. The key is shown once.
26
26
  li
27
27
  strong Install and configure the Agent
28
28
  | (#[a(href="#agent-setup") Agent setup]):
29
29
  +code('Developer machine').
30
30
  npm i -g @andrian.yablonskyy/thub-agent
31
31
  thub config set url #{coordinatorUrl}
32
- thub config set token agt_…
32
+ thub config set key thk_…
33
33
  li
34
34
  strong Run a job
35
35
  | (#[a(href="#agent-cli") Agent CLI reference]):
@@ -20,6 +20,7 @@
20
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
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
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.'],
23
+ ['Agent says “Invalid or revoked access key” or “… is blocked”', 'The key was replaced (`thub key rotate`, an admin, your profile) or revoked, or your user is blocked. Ask an admin for a new key (Users), or create one on your profile if you can sign in. Then `thub config set key <key>`; `thub whoami` checks it.'],
23
24
  ['Locked out of the dashboard', 'Restart the Coordinator once with `THUB_BOOTSTRAP_ADMIN_PASSWORD=<new>` (resets the `admin` user), then unset it.']
24
25
  ]
25
26
  const withCode = (text) => text
package/views/index.pug CHANGED
@@ -42,7 +42,7 @@ block content
42
42
  each r in resources
43
43
  +resourceCard(r, groupsById, '/')
44
44
 
45
- if user && user.role === 'admin'
45
+ if can.operate
46
46
  +removeResourceModal()
47
47
 
48
48
  block scripts
package/views/layout.pug CHANGED
@@ -1,3 +1,11 @@
1
+ //- The signed-in user's avatar, or their initials in a circle.
2
+ mixin navAvatar(px)
3
+ if user.avatarPath
4
+ img.rounded-circle.me-2(src=user.avatarPath width=px height=px style="object-fit: cover;")
5
+ else
6
+ span.rounded-circle.bg-secondary.text-white.d-inline-flex.align-items-center.justify-content-center.me-2.flex-shrink-0(style=`width: ${px}px; height: ${px}px; font-size: ${Math.round(px / 2)}px;`)
7
+ = ((user.firstName ? user.firstName[0] : user.username[0]) + (user.lastName ? user.lastName[0] : '')).toUpperCase()
8
+
1
9
  doctype html
2
10
  html(lang="en" data-bs-theme=(user && user.theme) || "auto")
3
11
  head
@@ -21,7 +29,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
21
29
  data-bs-title=`Coordinator v${coordinatorVersion} · thub-common v${commonVersion} (validates job specs)`
22
30
  )
23
31
  | ver. #{coordinatorVersion}
24
- if user && user.role !== 'admin' && updates.coordinatorUpdate
32
+ if user && !can.admin && updates.coordinatorUpdate
25
33
  span.badge.text-bg-warning.ms-1(
26
34
  data-bs-toggle="tooltip"
27
35
  data-bs-placement="bottom"
@@ -30,10 +38,22 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
30
38
  button.navbar-toggler(type="button" data-bs-toggle="collapse" data-bs-target="#nav")
31
39
  span.navbar-toggler-icon
32
40
  #nav.collapse.navbar-collapse.thub-nav
41
+ //- Phones/tablets: who you are comes first — name, role, profile,
42
+ //- sign-out (thub.css .thub-nav-me). Desktop keeps it on the right.
43
+ if user
44
+ .thub-nav-me.d-lg-none
45
+ a.d-flex.align-items-center.text-reset.text-decoration-none.flex-grow-1(href="/profile")
46
+ +navAvatar(36)
47
+ span.d-flex.flex-column.lh-sm
48
+ span.fw-medium= user.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : user.username
49
+ span.small.text-body-secondary= `${roleLabel(user.role)} · profile`
50
+ form(action="/logout" method="post")
51
+ button.btn.btn-outline-danger(type="submit" aria-label="Log out")
52
+ i.bi.bi-box-arrow-right
33
53
  //- Below lg the menu drops down over the page (thub.css .thub-nav,
34
54
  //- closed by public/js/actions.js): pages as a two-column grid with
35
55
  //- icons, then the tools in a row, then the account row.
36
- - const pages = user ? [['/', 'overview', 'Overview', 'speedometer2'], ['/resources', 'resources', 'Resources', 'hdd-rack'], ['/groups', 'groups', 'Groups', 'collection'], ['/jobs', 'jobs', 'Jobs', 'list-task']].concat(user.role === 'admin' ? [['/admin/agents', 'agents', 'Agents', 'person-badge'], ['/admin/settings', 'settings', 'Settings', 'gear']] : []) : []
56
+ - const pages = user ? [['/', 'overview', 'Overview', 'speedometer2'], ['/resources', 'resources', 'Resources', 'hdd-rack'], ['/groups', 'groups', 'Groups', 'collection'], ['/jobs', 'jobs', 'Jobs', 'list-task']].concat(user.role === 'admin' ? [['/admin/users', 'users', 'Users', 'people'], ['/admin/agents', 'agents', 'CI tokens', 'key'], ['/admin/settings', 'settings', 'Settings', 'gear']] : []) : []
37
57
  ul.navbar-nav.me-auto.thub-nav-pages
38
58
  each pg in pages
39
59
  li.nav-item
@@ -59,7 +79,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
59
79
  )
60
80
  i.bi.bi-question-circle.me-1
61
81
  | Help
62
- if user && user.role === 'admin'
82
+ if user && can.operate
63
83
  li.nav-item.d-flex.align-items-center.me-2
64
84
  form(method="post" action="/updates/check" data-submit-busy)
65
85
  input(type="hidden" name="returnTo" value=currentPath)
@@ -71,7 +91,7 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
71
91
  data-bs-title=updates.checkedAt ? `Check for updates now — the Coordinator and connected resources (last checked ${fmtDate(updates.checkedAt)})` : 'Check for updates now — the Coordinator and connected resources'
72
92
  )
73
93
  i.bi.bi-arrow-repeat
74
- if user && user.role === 'admin' && updates.coordinatorUpdate
94
+ if user && can.admin && updates.coordinatorUpdate
75
95
  li.nav-item.d-flex.align-items-center.me-3
76
96
  if updates.coordinatorPending
77
97
  //- public/js/update-watch.js polls /updates/status and
@@ -112,22 +132,16 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
112
132
  )
113
133
  i.bi.bi-circle-half
114
134
  if user
115
- li.thub-nav-break(aria-hidden="true")
116
- li.nav-item.d-flex.align-items-center.me-3.thub-nav-account
135
+ li.nav-item.d-none.d-lg-flex.align-items-center.me-3
117
136
  a.nav-link.d-flex.align-items-center.p-0(
118
137
  href="/profile"
119
138
  data-bs-toggle="tooltip"
120
139
  data-bs-placement="bottom"
121
- data-bs-title=`Profile settings (${user.role})`
140
+ data-bs-title=`Profile settings (${roleLabel(user.role)})`
122
141
  )
123
- if user.avatarPath
124
- img.rounded-circle.me-2(src=user.avatarPath width="24" height="24" style="object-fit: cover;")
125
- else
126
- span.rounded-circle.bg-secondary.text-white.d-inline-flex.align-items-center.justify-content-center.me-2(style="width: 24px; height: 24px; font-size: 12px;")
127
- = ((user.firstName ? user.firstName[0] : user.username[0]) + (user.lastName ? user.lastName[0] : '')).toUpperCase()
142
+ +navAvatar(24)
128
143
  span= user.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : user.username
129
- span.small.text-body-secondary.ms-2.d-lg-none= `${user.role} · profile`
130
- li.nav-item.d-flex.align-items-center
144
+ li.nav-item.d-none.d-lg-flex.align-items-center
131
145
  form(action="/logout" method="post")
132
146
  button.btn.btn-sm.btn-outline-danger(
133
147
  type="submit"
@@ -194,6 +208,6 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
194
208
  script(src="/js/actions.js")
195
209
  if user && liveTopics
196
210
  script(src="/js/live.js")
197
- if user && user.role === 'admin' && updates.coordinatorPending
211
+ if user && can.admin && updates.coordinatorPending
198
212
  script(src="/js/update-watch.js")
199
213
  block scripts
@@ -5,14 +5,13 @@
5
5
  //- what the Client reported at registration.
6
6
 
7
7
  //- One device row: "Device" is a udev index (1-8) or a /dev path; fields the
8
- //- form doesn't show (vendorId/productId/subsystem) ride along in data-extra.
8
+ //- form doesn't show (vendorId/productId/subsystem, an ST-Link serial set in
9
+ //- the config file) ride along in data-extra.
9
10
  mixin deviceRow(kind, d)
10
- - const known = ['index', 'path', 'serial', 'baudRate', 'devpath']
11
+ - const known = ['index', 'path', 'baudRate', 'devpath']
11
12
  - const extra = Object.fromEntries(Object.entries(d || {}).filter(([k]) => !known.includes(k)))
12
13
  tr(data-row=kind data-extra=JSON.stringify(extra))
13
14
  td: input.form-control.form-control-sm.font-monospace(data-f="device" value=d ? (d.path || d.index || '') : '' placeholder="1 or /dev/…" required)
14
- if kind === 'stlinks'
15
- td: input.form-control.form-control-sm.font-monospace(data-f="serial" value=d && d.serial || '' placeholder="(from udev)")
16
15
  if kind === 'uarts'
17
16
  td: input.form-control.form-control-sm(data-f="baudRate" type="number" min="50" value=d && d.baudRate || '' placeholder="115200")
18
17
  td: input.form-control.form-control-sm.font-monospace(data-f="devpath" value=d && d.devpath || '' placeholder="3.3.4.1")
@@ -106,7 +105,7 @@ mixin configTabItems(r)
106
105
  //- at once.
107
106
  mixin groupsPane(r, returnTo, groupsById)
108
107
  - const p = `cfg-${r.id}`
109
- - const canEdit = user.role === 'admin'
108
+ - const canEdit = can.operate
110
109
  - const pending = r.config_import && r.config_import.revision > r.config_applied_revision && r.config_import.fields && r.config_import.fields.groups
111
110
  - const all = Object.values(groupsById).sort((a, b) => a.name.localeCompare(b.name))
112
111
  - const unknown = r.group_ids.filter((id) => !groupsById[id])
@@ -162,8 +161,14 @@ mixin groupsPane(r, returnTo, groupsById)
162
161
 
163
162
  mixin configPanes(r, returnTo, groupsById)
164
163
  - const p = `cfg-${r.id}`
165
- - const current = r.config_desired || r.client_config
166
- - const canEdit = user.role === 'admin'
164
+ //- What the Client runs — its config as it reported it at its last start
165
+ //- — unless a saved revision is still on its way to it: then that one, so
166
+ //- it can be checked or changed before it's applied. (Always preferring the
167
+ //- saved revision showed an old one forever, after the file changed on the
168
+ //- host or an Import.)
169
+ - const pendingRevision = r.config_desired && r.config_applied_revision < r.config_revision
170
+ - const current = pendingRevision ? r.config_desired : (r.client_config || r.config_desired)
171
+ - const canEdit = can.operate
167
172
  if r.type !== 'hw'
168
173
  //- no device tabs
169
174
  else if !current
@@ -185,8 +190,8 @@ mixin configPanes(r, returnTo, groupsById)
185
190
  fieldset(disabled=!canEdit)
186
191
  +configPane(p, 'stlinks')
187
192
  +capabilitiesStatus(r)
188
- +deviceTable('stlinks', 'ST-Link probes', current.stlinks || [], ['Device', 'Serial', 'udev devpath'])
189
- .form-text Device: a udev index 1–8 (→ /dev/thub/dut&lt;N&gt;-stlink) or a /dev path. The serial is looked up from the udev symlink when empty.
193
+ +deviceTable('stlinks', 'ST-Link probes', current.stlinks || [], ['Device', 'udev devpath'])
194
+ .form-text Device: a udev index 1–8 (→ /dev/thub/dut&lt;N&gt;-stlink) or a /dev path. Which probe a job uses is up to its #[code --command] (#[code $THUB_DUT_STLINK_&lt;n&gt;]).
190
195
  +saveCapabilities(canEdit)
191
196
  +configPane(p, 'uarts')
192
197
  +capabilitiesStatus(r)
@@ -25,22 +25,24 @@ mixin resourceActions(r, returnTo)
25
25
  data-bs-title="Take this resource out of rotation for maintenance — the scheduler will skip it"
26
26
  )
27
27
  i.bi.bi-tools
28
- form.d-inline.ms-1(
29
- method="post"
30
- action=`/resources/${r.id}/rotate-token`
31
- data-confirm=`Issue a new token for ${r.name}? Its current token stops working at once, and the Client won't reconnect until it has the new one.`
32
- data-confirm-title="Rotate resource token?"
33
- data-confirm-ok="Rotate token"
34
- data-confirm-tone="warning"
35
- )
36
- input(type="hidden" name="returnTo" value=returnTo)
37
- button.btn.btn-sm.btn-outline-warning(
38
- type="submit"
39
- aria-label="Rotate token"
40
- data-bs-toggle="tooltip"
41
- data-bs-title="Invalidate the current resource token and issue a new one — the Client won't reconnect until it has it"
28
+ //- Credentials: admins only (§10.3).
29
+ if can.admin
30
+ form.d-inline.ms-1(
31
+ method="post"
32
+ action=`/resources/${r.id}/rotate-token`
33
+ data-confirm=`Issue a new token for ${r.name}? Its current token stops working at once, and the Client won't reconnect until it has the new one.`
34
+ data-confirm-title="Rotate resource token?"
35
+ data-confirm-ok="Rotate token"
36
+ data-confirm-tone="warning"
42
37
  )
43
- i.bi.bi-arrow-repeat
38
+ input(type="hidden" name="returnTo" value=returnTo)
39
+ button.btn.btn-sm.btn-outline-warning(
40
+ type="submit"
41
+ aria-label="Rotate token"
42
+ data-bs-toggle="tooltip"
43
+ data-bs-title="Invalidate the current resource token and issue a new one — the Client won't reconnect until it has it"
44
+ )
45
+ i.bi.bi-arrow-repeat
44
46
  +removeResourceButton(r, returnTo)
45
47
 
46
48
  //- Remove: a plain confirm when the resource is idle; with a running job,
@@ -188,7 +190,7 @@ mixin uptimeRows(r, returnTo)
188
190
  span.text-body-secondary.small.ms-1 (at last heartbeat)
189
191
  else
190
192
  span.text-body-secondary —
191
- if user.role === 'admin'
193
+ if can.operate
192
194
  +cancelActivityButton(r, returnTo)
193
195
 
194
196
  //- Scheduled host reboot (cron, in the Client host's local time): what's
@@ -226,7 +228,7 @@ mixin rebootScheduleRow(r, returnTo)
226
228
  data-bs-toggle="tooltip"
227
229
  data-bs-title="This host has no reboot helper (thub-client-reboot.path), so the schedule can't reboot it — reinstall the Client as root: sudo npm i -g @andrian.yablonskyy/thub-client"
228
230
  ) can't reboot
229
- if user.role === 'admin'
231
+ if can.operate
230
232
  form.d-flex.gap-1.mt-1(
231
233
  method="post"
232
234
  action=`/resources/${r.id}/reboot-schedule`
@@ -338,7 +340,7 @@ mixin usbScanPane(r)
338
340
  - const importWhy = r.type !== 'hw' ? 'Only HW Clients have USB device lists to import into' : !(r.config_desired || r.client_config) ? 'This Client is too old to report its config — update it first' : null
339
341
  div(data-usb-scan=`/resources/${r.id}/usb-scan` data-pending=r.usb_scan_request_id ? '1' : null data-requested-at=r.usb_scan_requested_at)
340
342
  .d-flex.align-items-center.gap-2.mb-2
341
- if user.role === 'admin'
343
+ if can.operate
342
344
  if offline
343
345
  span.d-inline-block(tabindex="0" data-bs-toggle="tooltip" data-bs-title="Offline — a Client can only be scanned while it's connected")
344
346
  button.btn.btn-sm.btn-outline-primary(type="button" disabled style="pointer-events: none;")
@@ -366,7 +368,7 @@ mixin usbScanPane(r)
366
368
  if scan
367
369
  = `lsusb -tvv at ${fmtDate(scan.at)}`
368
370
  else
369
- | Not scanned yet#{user.role === 'admin' ? ' — press Refresh to run lsusb -tvv on the Client' : ''}.
371
+ | Not scanned yet#{can.operate ? ' — press Refresh to run lsusb -tvv on the Client' : ''}.
370
372
  .alert.alert-danger.py-2.small(data-usb-error class=scan && scan.error ? null : 'd-none')= scan && scan.error || ''
371
373
  pre.bg-body-tertiary.p-2.rounded.small.mb-0(data-usb-output style="max-height: 360px; overflow: auto;" class=scan && scan.output ? null : 'd-none')= scan && scan.output || ''
372
374
 
@@ -453,7 +455,7 @@ mixin resourceDetails(r, groupsById, returnTo, local, external)
453
455
  data-bs-toggle="tooltip"
454
456
  data-bs-title="Renamed on the dashboard; this is the name the Client itself reports"
455
457
  )= `(Client: ${r.reported_name})`
456
- if user.role === 'admin'
458
+ if can.operate
457
459
  button.btn.btn-sm.btn-link.p-0.ms-2.align-baseline(
458
460
  type="button"
459
461
  data-bs-toggle="collapse"
@@ -513,7 +515,7 @@ mixin resourceDetails(r, groupsById, returnTo, local, external)
513
515
  dd.col-sm-8= r.busy_source ? `${r.busy_source}${r.busy_reason ? ' — ' + r.busy_reason : ''}` : '—'
514
516
  dt.col-sm-4 Last heartbeat
515
517
  dd.col-sm-8= fmtDate(r.last_heartbeat_at, 'never')
516
- if user.role === 'admin'
518
+ if can.operate
517
519
  dt.col-sm-4 Actions
518
520
  dd.col-sm-8.mb-0
519
521
  +resourceActions(r, returnTo)
@@ -25,7 +25,7 @@ mixin versionCell(current, latest, updateTo)
25
25
  //- an update). Agents table, Resources table, resource card.
26
26
  mixin versionWithUpdate(current, latest, updateTo, action, returnTo, canUpdate = true, holding = false)
27
27
  +versionCell(current, latest, updateTo)
28
- if user.role === 'admin' && canUpdate && (updateTo || holding || (current && isNewer(latest, current)))
28
+ if can.operate && canUpdate && (updateTo || holding || (current && isNewer(latest, current)))
29
29
  +updateButton(action, updateTo || (holding ? true : null), returnTo)
30
30
 
31
31
  //- Latest published version of `app` (agent | client), "check now" and,
@@ -40,7 +40,7 @@ mixin updatesToolbar(app, label, returnTo)
40
40
  = ` · checked ${fmtDate(updates.checkedAt)}`
41
41
  if updates.error
42
42
  i.bi.bi-exclamation-triangle.text-warning.ms-1(data-bs-toggle="tooltip" data-bs-title=updates.error)
43
- if user.role === 'admin'
43
+ if can.operate
44
44
  form.d-inline(method="post" action="/updates/check")
45
45
  input(type="hidden" name="returnTo" value=returnTo)
46
46
  button.btn.btn-sm.btn-outline-secondary(type="submit")
package/views/profile.pug CHANGED
@@ -3,6 +3,24 @@ extends layout
3
3
  block content
4
4
  h1.h4.mb-3 Profile settings
5
5
 
6
+ if user.mustChangePassword
7
+ .alert.alert-warning
8
+ i.bi.bi-shield-exclamation.me-1
9
+ | You signed in with a temporary password — choose your own below before using the dashboard.
10
+
11
+ if secret
12
+ //- Shown once (§10.3): nothing keeps it.
13
+ .alert.alert-warning(role="alert")
14
+ h2.h6.alert-heading
15
+ i.bi.bi-shield-lock.me-1
16
+ = secret.title
17
+ .input-group.input-group-sm.mb-2
18
+ span.input-group-text Access key
19
+ input.form-control.font-monospace(type="text" value=secret.key readonly aria-label="Access key")
20
+ button.btn.btn-outline-secondary(type="button" data-copy=secret.key aria-label="Copy access key")
21
+ i.bi.bi-clipboard
22
+ p.small.mb-0 Shown only now. Save it with #[code thub config set key …] (or use #[code THUB_KEY] / #[code --key]).
23
+
6
24
  .row
7
25
  .col-md-6
8
26
  form(method="post" action="/profile" enctype="multipart/form-data")
@@ -21,6 +39,12 @@ block content
21
39
  label.form-label(for="username") Username
22
40
  input#username.form-control(type="text" name="username" value=user.username required)
23
41
 
42
+ .mb-3
43
+ label.form-label(for="email") Email
44
+ input#email.form-control(type="email" name="email" value=user.email required)
45
+ if !user.email
46
+ .form-text.text-warning Every user needs an email — please add yours.
47
+
24
48
  .row
25
49
  .col-6.mb-3
26
50
  label.form-label(for="firstName") First name
@@ -58,6 +82,38 @@ block content
58
82
  data-set-theme=t
59
83
  )= t.charAt(0).toUpperCase() + t.slice(1)
60
84
 
85
+ //- Your access key for the Agent (§10.3); also: thub key show|rotate.
86
+ .card.mb-3
87
+ .card-body
88
+ h6.card-subtitle.mb-3.text-body-secondary My access key
89
+ if accessKey
90
+ p.small.mb-2
91
+ | #[code= `…${accessKey.token_hint || '????'}`] · created #{fmtDate(accessKey.token_created_at || accessKey.created_at)} · last used #{fmtDate(accessKey.last_used_at, 'never')}
92
+ else
93
+ p.small.text-body-secondary.mb-2 None yet — create one to use the Agent (thub).
94
+ .d-flex.gap-2
95
+ form(
96
+ method="post"
97
+ action="/profile/key"
98
+ data-confirm=accessKey ? 'Replace your access key? The current one stops working at once — everywhere you use it.' : 'Create your access key?'
99
+ data-confirm-title=accessKey ? 'Replace access key?' : 'Create access key?'
100
+ data-confirm-ok=accessKey ? 'Replace key' : 'Create key'
101
+ data-confirm-tone="primary"
102
+ )
103
+ button.btn.btn-sm.btn-outline-primary(type="submit")
104
+ i.bi.bi-key.me-1
105
+ = accessKey ? 'Replace key' : 'Create key'
106
+ if accessKey
107
+ form(
108
+ method="post"
109
+ action="/profile/key/revoke"
110
+ data-confirm="Revoke your access key? The Agent stops working for you until you create a new one."
111
+ data-confirm-title="Revoke access key?"
112
+ data-confirm-ok="Revoke key"
113
+ )
114
+ button.btn.btn-sm.btn-outline-warning(type="submit") Revoke
115
+ .form-text.mt-2 From the command line: #[code thub key show] and #[code thub key rotate].
116
+
61
117
  .card
62
118
  .card-body
63
119
  h6.card-subtitle.mb-3.text-body-secondary Change password
@@ -30,7 +30,7 @@ block content
30
30
  th Groups
31
31
  th Busy source / reason
32
32
  +sortHeader(list, 'heartbeat', 'Last heartbeat', 'desc')
33
- if user.role === 'admin'
33
+ if can.operate
34
34
  th Actions
35
35
  tbody(data-live="rows")
36
36
  each r in resources
@@ -50,14 +50,14 @@ block content
50
50
  span.text-body-secondary —
51
51
  td= r.busy_source ? `${r.busy_source}${r.busy_reason ? ' — ' + r.busy_reason : ''}` : '—'
52
52
  td= fmtDate(r.last_heartbeat_at, 'never')
53
- if user.role === 'admin'
53
+ if can.operate
54
54
  td
55
55
  +resourceActions(r, list.pageUrl())
56
56
 
57
57
  each r in resources
58
58
  +resourceCard(r, groupsById, list.pageUrl())
59
59
 
60
- if user && user.role === 'admin'
60
+ if can.operate
61
61
  +removeResourceModal()
62
62
 
63
63
  block scripts