@andrian.yablonskyy/thub-coordinator 1.1.5 → 1.1.7

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 CHANGED
@@ -342,7 +342,7 @@ function main(){
342
342
  return usage(), process.exit(4);
343
343
  }
344
344
  const group = groups.create({ name, comment: flags.comment });
345
- console.log(`Group ${group.id} created ("${group.name}"). Use it with: thub run --group ${group.id}`);
345
+ console.log(`Group ${group.id} created ("${group.name}"). Give it to users or CI tokens on the dashboard (Users / CI tokens).`);
346
346
  return;
347
347
  }
348
348
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
5
5
  "main": "src/server.js",
6
6
  "engines": {
@@ -19,7 +19,7 @@
19
19
  "lint:fix": "eslint . --fix"
20
20
  },
21
21
  "dependencies": {
22
- "@andrian.yablonskyy/thub-common": "^1.1.1",
22
+ "@andrian.yablonskyy/thub-common": "^1.1.2",
23
23
  "better-sqlite3": "^12.4.1",
24
24
  "express": "^5.1.0",
25
25
  "express-session": "^1.18.2",
@@ -93,8 +93,9 @@
93
93
  keeps their height (and still takes them out of the tab order and of what
94
94
  screen readers read). The device tabs sit inside their shared
95
95
  <form>/<fieldset> (one Save for all of them), so that fieldset stacks its
96
- panes the same way. The USB tab's output scrolls in its own box (360 px),
97
- so a long lsusb doesn't make every tab that tall. */
96
+ panes the same way. The USB tab's output fills that height and scrolls
97
+ inside it (.thub-usb-pane below), so a long lsusb doesn't make every tab
98
+ that tall. */
98
99
  .thub-resource-card .tab-content,
99
100
  .thub-resource-card .tab-content > form > fieldset {
100
101
  display: grid;
@@ -111,8 +112,40 @@
111
112
  visibility: hidden;
112
113
  }
113
114
 
115
+ /* Stacked, the device tabs' <form>/<fieldset> (not hidden themselves) lie
116
+ over the other panes: only the visible pane takes the mouse, or they'd
117
+ swallow every hover and click on the Details tab's buttons. */
118
+ .thub-resource-card .tab-content > form,
119
+ .thub-resource-card .tab-content > form > fieldset,
120
+ .thub-resource-card .tab-content .tab-pane {
121
+ pointer-events: none;
122
+ }
123
+
114
124
  .thub-resource-card .tab-content .tab-pane.active {
115
125
  visibility: visible;
126
+ pointer-events: auto;
127
+ }
128
+
129
+ /* Connected USB devices: the lsusb output takes the rest of the modal's
130
+ height — what the tallest other tab sets, at least 12rem — and scrolls in
131
+ it. Its 0 basis keeps it from making the modal taller. */
132
+ .thub-resource-card .tab-content .tab-pane.thub-usb-pane {
133
+ display: flex;
134
+ flex-direction: column;
135
+ }
136
+
137
+ .thub-usb-pane > [data-usb-scan] {
138
+ display: flex;
139
+ flex: 1 1 auto;
140
+ flex-direction: column;
141
+ min-height: 0;
142
+ }
143
+
144
+ .thub-usb-pane [data-usb-output] {
145
+ flex: 1 1 0;
146
+ height: 0;
147
+ min-height: 12rem;
148
+ overflow: auto;
116
149
  }
117
150
 
118
151
  /* Help page (views/help/): sticky table of contents, code blocks with a
package/src/api/agent.js CHANGED
@@ -106,13 +106,17 @@ function createAgentRouter({ services, config }){
106
106
 
107
107
  // `thub whoami` / `thub key show` (§10.3): who this key belongs to.
108
108
  router.get('/me', auth, (req, res) => {
109
+ // The group its jobs run in (set on the dashboard, §13.1), by name.
110
+ const groupId = req.user ? services.adminUsers.getById(req.user.id)?.groupId : services.agents.get(req.agent.id)?.group_id,
111
+ group = groupId ? services.groups.get(groupId)?.name || null : null;
109
112
  if (!req.user){
110
- return res.json({ kind: req.agent.kind, name: req.agent.name });
113
+ return res.json({ kind: req.agent.kind, name: req.agent.name, group });
111
114
  }
112
115
  const { username, email, role } = req.user,
113
116
  key = services.adminUsers.keyOf(req.user.id);
114
117
  res.json({
115
118
  kind: req.agent.kind,
119
+ group,
116
120
  user: { username, email, role },
117
121
  key: { hint: key.token_hint, createdAt: key.token_created_at || key.created_at, lastUsedAt: key.last_used_at }
118
122
  });
@@ -0,0 +1,5 @@
1
+ -- The resource group an agent's jobs run in (§13.1), set on the dashboard
2
+ -- only — no longer `thub run --group`: a user's (Users, kept across key
3
+ -- rotations) or a CI token's (CI tokens). NULL: any resource.
4
+ ALTER TABLE users ADD COLUMN group_id TEXT;
5
+ ALTER TABLE agents ADD COLUMN group_id TEXT;
@@ -101,6 +101,7 @@ function toProfile(row){
101
101
  theme: row.theme,
102
102
  sessionTimeoutMin: row.session_timeout_min,
103
103
  listPrefs: parseListPrefs(row.list_prefs),
104
+ groupId: row.group_id || null,
104
105
  blocked: Boolean(row.blocked_at),
105
106
  blockedAt: row.blocked_at || null,
106
107
  mustChangePassword: Boolean(row.must_change_password),
@@ -211,17 +212,30 @@ function createAdminUsersService(db, { events } = {}){
211
212
  // callers may leave it out — the Users page flags a missing one.
212
213
  // A dashboard role without a password gets a temporary one, returned
213
214
  // once, to be changed at the first sign-in.
214
- function create({ username, email, role, password, firstName, lastName, requireEmail = false }, { by } = {}){
215
+ // The resource group a user's jobs run in (§13.1): an existing group's id,
216
+ // or null for any resource.
217
+ function checkGroup(groupId){
218
+ if (!groupId){
219
+ return null;
220
+ }
221
+ if (!db.prepare('SELECT 1 FROM groups WHERE id = ?').get(groupId)){
222
+ throw bad('Unknown group');
223
+ }
224
+ return groupId;
225
+ }
226
+
227
+ function create({ username, email, role, password, firstName, lastName, groupId, requireEmail = false }, { by } = {}){
215
228
  const id = `usr_${uuid()}`,
229
+ group = checkGroup(groupId),
216
230
  name = checkUsername(username),
217
231
  r = normalizeRole(role),
218
232
  mail = email || requireEmail ? checkEmail(email) : '',
219
233
  temp = !password && DASHBOARD_ROLES.includes(r) ? tempPassword() : null,
220
234
  pw = password || temp;
221
235
  db.prepare(
222
- `INSERT INTO users (id, username, email, password_hash, role, first_name, last_name, must_change_password, created_at)
223
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
224
- ).run(id, name, mail, pw ? hashPassword(pw) : null, r, firstName || null, lastName || null, temp ? 1 : 0, new Date().toISOString());
236
+ `INSERT INTO users (id, username, email, password_hash, role, first_name, last_name, must_change_password, group_id, created_at)
237
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
238
+ ).run(id, name, mail, pw ? hashPassword(pw) : null, r, firstName || null, lastName || null, temp ? 1 : 0, group, new Date().toISOString());
225
239
  audit(id, 'user.created', { by, username: name, role: r });
226
240
  return { id, username: name, role: r, tempPassword: temp };
227
241
  }
@@ -292,14 +306,15 @@ function createAdminUsersService(db, { events } = {}){
292
306
  email: fields.email !== undefined ? checkEmail(fields.email, id) : target.email,
293
307
  role: fields.role !== undefined ? normalizeRole(fields.role) : target.role,
294
308
  first_name: fields.firstName !== undefined ? fields.firstName || null : target.first_name,
295
- last_name: fields.lastName !== undefined ? fields.lastName || null : target.last_name
309
+ last_name: fields.lastName !== undefined ? fields.lastName || null : target.last_name,
310
+ group_id: fields.groupId !== undefined ? checkGroup(fields.groupId) : target.group_id
296
311
  };
297
312
  guardAdmins(target, { actorId, losesAdmin: next.role !== 'admin' });
298
313
  // Promoted to the dashboard without a password: a temporary one.
299
314
  const temp = DASHBOARD_ROLES.includes(next.role) && !target.password_hash ? tempPassword() : null;
300
315
  db.transaction(() => {
301
- db.prepare('UPDATE users SET username = ?, email = ?, role = ?, first_name = ?, last_name = ? WHERE id = ?')
302
- .run(next.username, next.email, next.role, next.first_name, next.last_name, id);
316
+ db.prepare('UPDATE users SET username = ?, email = ?, role = ?, first_name = ?, last_name = ?, group_id = ? WHERE id = ?')
317
+ .run(next.username, next.email, next.role, next.first_name, next.last_name, next.group_id, id);
303
318
  if (temp){
304
319
  db.prepare('UPDATE users SET password_hash = ?, must_change_password = 1 WHERE id = ?').run(hashPassword(temp), id);
305
320
  }
@@ -29,7 +29,8 @@ function createAgentsService(db, { events }){
29
29
  }
30
30
 
31
31
  function list(){
32
- return db.prepare('SELECT id, name, kind, user_id, version, update_to, created_at, last_used_at, revoked_at FROM agents ORDER BY created_at DESC').all();
32
+ return db.prepare('SELECT id, name, kind, user_id, group_id, version, update_to, created_at, last_used_at, revoked_at FROM agents ORDER BY created_at DESC')
33
+ .all();
33
34
  }
34
35
 
35
36
  function create({ name, kind }){
@@ -60,6 +61,24 @@ function createAgentsService(db, { events }){
60
61
  return get(id);
61
62
  }
62
63
 
64
+ // The resource group a CI token's jobs run in (§13.1), set on CI tokens:
65
+ // an existing group's id, or null for any resource.
66
+ function setGroup(id, groupId){
67
+ const agent = get(id);
68
+ if (!agent){
69
+ throw Object.assign(new Error('Unknown agent'), { status: 404 });
70
+ }
71
+ const group = groupId || null;
72
+ if (group && !db.prepare('SELECT 1 FROM groups WHERE id = ?').get(group)){
73
+ throw Object.assign(new Error('Unknown group'), { status: 400 });
74
+ }
75
+ if (group !== (agent.group_id || null)){
76
+ db.prepare('UPDATE agents SET group_id = ? WHERE id = ?').run(group, id);
77
+ events.record('agent', id, 'agent.group_set', { from: agent.group_id || null, to: group });
78
+ }
79
+ return get(id);
80
+ }
81
+
63
82
  function revoke(id){
64
83
  db.prepare('UPDATE agents SET revoked_at = ? WHERE id = ?').run(new Date().toISOString(), id);
65
84
  events.record('agent', id, 'agent.revoked', {});
@@ -97,7 +116,7 @@ function createAgentsService(db, { events }){
97
116
  return ids.length;
98
117
  }
99
118
 
100
- return { get, getByTokenHash, list, create, rename, revoke, touchLastUsed, setUpdateTo, requestUpdateAll };
119
+ return { get, getByTokenHash, list, create, rename, setGroup, revoke, touchLastUsed, setUpdateTo, requestUpdateAll };
101
120
  }
102
121
 
103
122
  module.exports = { createAgentsService };
@@ -15,9 +15,10 @@
15
15
 
16
16
  const { v4: uuid } = require('uuid');
17
17
 
18
- // Admin-managed resource groups (README §13.1) — a job can be constrained
19
- // to run only on resources that are members of a given group
20
- // (`thub run --group <id>`, job-spec.schema.js's `target.group`).
18
+ // Admin-managed resource groups (README §13.1). An agent — a user, or a CI
19
+ // token — may be given one on the dashboard; its jobs then run only on
20
+ // resources that are members of it (job-spec.schema.js's `target.group`,
21
+ // filled in by jobs.create — never taken from the Agent).
21
22
  // Membership itself lives on the resource (registry.js's `group_ids`,
22
23
  // declared by the Client's own config), not here — this service only
23
24
  // owns the groups' own identity (id/name/comment).
@@ -69,6 +70,9 @@ function createGroupsService(db, { events, registry }){
69
70
  registry.setGroups(resource.id, resource.group_ids.filter((g) => g !== id));
70
71
  }
71
72
  }
73
+ // Agents in it go back to running anywhere.
74
+ db.prepare('UPDATE users SET group_id = NULL WHERE group_id = ?').run(id);
75
+ db.prepare('UPDATE agents SET group_id = NULL WHERE group_id = ?').run(id);
72
76
  db.prepare('DELETE FROM groups WHERE id = ?').run(id);
73
77
  events.record('group', id, 'group.deleted', { name: group.name });
74
78
  }
@@ -141,6 +141,15 @@ function createJobsService(db, { bus, events, registry, config }){
141
141
  return { rows: rows.map(rowToJob), pagination };
142
142
  }
143
143
 
144
+ // The group an agent's jobs run in: its user's, or the CI token's own.
145
+ function agentGroup(agentId){
146
+ return db.prepare(
147
+ `SELECT g.id, g.name FROM agents a LEFT JOIN users u ON u.id = a.user_id
148
+ JOIN groups g ON g.id = CASE WHEN a.user_id IS NOT NULL THEN u.group_id ELSE a.group_id END
149
+ WHERE a.id = ?`
150
+ ).get(agentId) || null;
151
+ }
152
+
144
153
  function countQueuedForAgent(agentId){
145
154
  return db
146
155
  .prepare('SELECT COUNT(*) AS n FROM jobs WHERE agent_id = ? AND state IN (\'QUEUED\',\'ASSIGNED\')')
@@ -157,6 +166,13 @@ function createJobsService(db, { bus, events, registry, config }){
157
166
  throw Object.assign(new Error(`Invalid job spec (${VERSIONS}): ${errors.join('; ')}`), { status: 400 });
158
167
  }
159
168
  spec.source = source;
169
+ // The group is the agent's, set on the dashboard (§13.1) — whatever an
170
+ // older Agent sent as `target.group` is replaced.
171
+ const group = agentGroup(agentId);
172
+ delete spec.target.group;
173
+ if (group){
174
+ spec.target.group = group.id;
175
+ }
160
176
 
161
177
  // `target.client` (thub run --client) accepts a resource id or name;
162
178
  // store the id so the job stays pinned to the same Client across renames.
@@ -168,12 +184,12 @@ function createJobsService(db, { bus, events, registry, config }){
168
184
  spec.target.client = resource.id;
169
185
  }
170
186
 
171
- const { type, labels, group, client } = spec.target;
172
- if (!registry.everSatisfiable(type, labels, group, client)){
187
+ const { type, labels, group: groupId, client } = spec.target;
188
+ if (!registry.everSatisfiable(type, labels, groupId, client)){
173
189
  throw Object.assign(
174
190
  new Error(
175
191
  `No registered resource can ever satisfy type=${spec.target.type} labels=${spec.target.labels.join(',')}` +
176
- (spec.target.group ? ` group=${spec.target.group}` : '') +
192
+ (group ? ` group=${group.name} (this agent's group, set on the dashboard)` : '') +
177
193
  (spec.target.client ? ` client=${spec.target.client}` : '')
178
194
  ),
179
195
  { status: 422 }
@@ -32,11 +32,11 @@ const PAGE_SIZES = [10, 25, 50, 100, 'all'],
32
32
  defaults: { size: DEFAULT_PAGE_SIZE, sort: 'name', dir: 'asc' }
33
33
  },
34
34
  agents: {
35
- sorts: ['name', 'kind', 'version', 'created', 'used', 'status'],
35
+ sorts: ['name', 'kind', 'group', 'version', 'created', 'used', 'status'],
36
36
  defaults: { size: DEFAULT_PAGE_SIZE, sort: 'created', dir: 'desc' }
37
37
  },
38
38
  users: {
39
- sorts: ['username', 'role', 'status', 'used'],
39
+ sorts: ['username', 'role', 'group', 'status', 'used'],
40
40
  defaults: { size: DEFAULT_PAGE_SIZE, sort: 'username', dir: 'asc' }
41
41
  }
42
42
  };
package/src/web/routes.js CHANGED
@@ -955,6 +955,7 @@ function createWebRouter({ services, config }){
955
955
  active: 'jobs',
956
956
  job,
957
957
  pinnedClient: job.spec.target.client ? services.registry.get(job.spec.target.client) : null,
958
+ targetGroup: job.spec.target.group ? services.groups.get(job.spec.target.group) : null,
958
959
  waitingReason: job.state === JOB_STATES.QUEUED
959
960
  ? services.registry.waitingReason(job.spec.target.type, job.spec.target.labels || [], job.spec.target.group,
960
961
  job.spec.target.client)
@@ -1033,10 +1034,19 @@ function createWebRouter({ services, config }){
1033
1034
  const q = search.normalizeQuery(req.query.q),
1034
1035
  terms = search.parseTerms(q),
1035
1036
  resources = services.registry.list(),
1037
+ // Who runs their jobs in each group: users, and CI tokens still in use.
1038
+ agentsIn = [
1039
+ ...services.adminUsers.list().map((u) => ({ name: u.username, groupId: u.groupId })),
1040
+ ...services.agents.list().filter((a) => !a.user_id && !a.revoked_at).map((a) => ({ name: a.name, groupId: a.group_id }))
1041
+ ],
1036
1042
  all = services.groups.list(),
1037
1043
  groups = all
1038
- .filter((g) => search.matches([g.id, g.name, g.comment], terms))
1039
- .map((g) => ({ ...g, resourceCount: resources.filter((r) => r.group_ids.includes(g.id)).length }));
1044
+ .filter((g) => search.matches([g.name, g.comment], terms))
1045
+ .map((g) => ({
1046
+ ...g,
1047
+ resourceCount: resources.filter((r) => r.group_ids.includes(g.id)).length,
1048
+ agentNames: agentsIn.filter((a) => a.groupId === g.id).map((a) => a.name)
1049
+ }));
1040
1050
  res.render('groups/list', {
1041
1051
  title: 'Groups',
1042
1052
  active: 'groups',
@@ -1070,11 +1080,13 @@ function createWebRouter({ services, config }){
1070
1080
  terms = search.parseTerms(q),
1071
1081
  view = listView(req, res, 'agents', { q }),
1072
1082
  { sort, dir, size } = view.prefs,
1073
- value = AGENT_SORT_VALUE[sort],
1083
+ groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g])),
1084
+ groupName = (a) => groupsById[a.group_id]?.name || null,
1085
+ value = sort === 'group' ? groupName : AGENT_SORT_VALUE[sort],
1074
1086
  // CI tokens only: a person's key belongs to their user (Users, §10.3).
1075
1087
  sorted = services.agents.list()
1076
1088
  .filter((a) => !a.user_id)
1077
- .filter((a) => search.matches([a.id, a.name, a.kind, a.version, a.revoked_at ? 'revoked' : 'active'], terms))
1089
+ .filter((a) => search.matches([a.name, a.kind, a.version, groupName(a), a.revoked_at ? 'revoked' : 'active'], terms))
1078
1090
  .sort((a, b) => {
1079
1091
  const va = value(a),
1080
1092
  vb = value(b);
@@ -1090,6 +1102,8 @@ function createWebRouter({ services, config }){
1090
1102
  active: 'agents',
1091
1103
  liveTopics: 'agents',
1092
1104
  agents: sorted.slice(pagination.offset, pagination.offset + pagination.limit),
1105
+ groups: services.groups.list(),
1106
+ groupsById,
1093
1107
  q,
1094
1108
  list: { ...view, pagination, pageUrl, q },
1095
1109
  ...extra
@@ -1200,6 +1214,7 @@ function createWebRouter({ services, config }){
1200
1214
  username: (u) => userDisplayName(u).toLowerCase(),
1201
1215
  role: (u) => ROLE_ORDER[u.role],
1202
1216
  status: (u) => (u.blocked ? 1 : 0),
1217
+ group: (u) => (u.groupId ? services.groups.get(u.groupId)?.name.toLowerCase() : null) || null,
1203
1218
  used: (u) => u.key?.lastUsedAt || null
1204
1219
  },
1205
1220
  // Who manages whom on Users (§10.3): an admin everyone; a maintainer
@@ -1210,7 +1225,7 @@ function createWebRouter({ services, config }){
1210
1225
  managesUser = (actor, u) => Boolean(u) && (isAdmin(actor) || u.role === 'user' || u.id === actor.id),
1211
1226
  userFiltersFor = (actor) => (isAdmin(actor) ? USER_ROLE_FILTERS : ['user', 'blocked']),
1212
1227
  // The audit log's Action column names changed fields like this.
1213
- FIELD_LABEL = { username: 'username', email: 'email', role: 'role', first_name: 'first name', last_name: 'last name' };
1228
+ FIELD_LABEL = { username: 'username', email: 'email', role: 'role', first_name: 'first name', last_name: 'last name', group_id: 'group' };
1214
1229
 
1215
1230
  // The audit log's Action column: what was done, to whom. `nameOf` gives
1216
1231
  // the account's current username (else the one its events recorded).
@@ -1283,7 +1298,8 @@ function createWebRouter({ services, config }){
1283
1298
  visible = services.adminUsers.list().filter((u) => managesUser(me, u)),
1284
1299
  visibleIds = new Set(visible.map((u) => u.id)),
1285
1300
  searched = visible
1286
- .filter((u) => search.matches([u.username, u.email, u.firstName, u.lastName, u.role, u.blocked ? 'blocked' : 'active'], terms)),
1301
+ .filter((u) => search.matches([u.username, u.email, u.firstName, u.lastName, u.role, services.groups.get(u.groupId)?.name,
1302
+ u.blocked ? 'blocked' : 'active'], terms)),
1287
1303
  // Counts for the filter buttons: within the search, before the role filter.
1288
1304
  roleCounts = Object.fromEntries(filters.map((r) => [r, searched.filter((u) => (r === 'blocked' ? u.blocked : u.role === r)).length])),
1289
1305
  sorted = searched
@@ -1314,6 +1330,8 @@ function createWebRouter({ services, config }){
1314
1330
  me,
1315
1331
  // The roles this admin or maintainer may give.
1316
1332
  roles: isAdmin(me) ? services.adminUsers.ROLES : ['user'],
1333
+ groups: services.groups.list(),
1334
+ groupsById: Object.fromEntries(services.groups.list().map((g) => [g.id, g])),
1317
1335
  roleHelp: {
1318
1336
  user: 'Agent only: submits jobs, reads their own results; no dashboard',
1319
1337
  maintainer: 'Agent + the dashboard without Settings; manages Agent-only users and CI tokens; sees and cancels every job',
@@ -1359,12 +1377,12 @@ function createWebRouter({ services, config }){
1359
1377
  router.get('/admin/users', (req, res) => renderUsers(req, res));
1360
1378
 
1361
1379
  router.post('/admin/users', userAction((req, ctx) => {
1362
- const { username, email, role, firstName, lastName } = req.body;
1380
+ const { username, email, role, firstName, lastName, groupId } = req.body;
1363
1381
  if (!isAdmin(ctx.actor) && normalizeRole(role) !== 'user'){
1364
1382
  throw new Error('Only an admin can create a maintainer or an admin.');
1365
1383
  }
1366
1384
  const
1367
- created = services.adminUsers.create({ username, email, role, firstName, lastName, requireEmail: true }, ctx),
1385
+ created = services.adminUsers.create({ username, email, role, firstName, lastName, groupId, requireEmail: true }, ctx),
1368
1386
  key = req.body.issueKey === '1' ? services.adminUsers.issueKey(created.id, ctx) : null;
1369
1387
  if (!key && !created.tempPassword){
1370
1388
  return { message: `User ${created.username} created.` };
@@ -1373,12 +1391,12 @@ function createWebRouter({ services, config }){
1373
1391
  }));
1374
1392
 
1375
1393
  router.post('/admin/users/:id', userAction((req, ctx) => {
1376
- const { username, email, role, firstName, lastName } = req.body;
1394
+ const { username, email, role, firstName, lastName, groupId } = req.body;
1377
1395
  if (!isAdmin(ctx.actor) && role !== undefined && normalizeRole(role) !== services.adminUsers.getById(req.params.id).role){
1378
1396
  throw new Error('Only an admin can change a role.');
1379
1397
  }
1380
1398
  const
1381
- { user, tempPassword } = services.adminUsers.update(req.params.id, { username, email, role, firstName, lastName }, ctx);
1399
+ { user, tempPassword } = services.adminUsers.update(req.params.id, { username, email, role, firstName, lastName, groupId }, ctx);
1382
1400
  return tempPassword
1383
1401
  ? { secret: { title: `${user.username} can now use the dashboard`, password: tempPassword, username: user.username } }
1384
1402
  : { message: `Saved ${user.username}.` };
@@ -1430,16 +1448,28 @@ function createWebRouter({ services, config }){
1430
1448
 
1431
1449
  router.post('/admin/agents', (req, res) => {
1432
1450
  // CI tokens only; people get their key from their user (§10.3).
1433
- const { token } = services.agents.create({ name: req.body.name, kind: 'ci' });
1434
- renderAgents(req, res, { newToken: token });
1451
+ try {
1452
+ const { agent, token } = services.agents.create({ name: req.body.name, kind: 'ci' });
1453
+ services.agents.setGroup(agent.id, req.body.groupId);
1454
+ renderAgents(req, res, { newToken: token });
1455
+ }
1456
+ catch (err){
1457
+ flash(req, 'danger', err.message);
1458
+ res.redirect('/admin/agents');
1459
+ }
1435
1460
  });
1436
1461
 
1437
1462
  router.post('/admin/agents/:id/rename', (req, res) => {
1438
1463
  try {
1439
- const before = services.agents.get(req.params.id)?.name,
1464
+ const before = services.agents.get(req.params.id),
1440
1465
  agent = services.agents.rename(req.params.id, req.body.name);
1441
- if (before !== agent.name){
1442
- flash(req, 'success', `Renamed agent "${before}" to "${agent.name}".`);
1466
+ if (before.name !== agent.name){
1467
+ flash(req, 'success', `Renamed agent "${before.name}" to "${agent.name}".`);
1468
+ }
1469
+ if (req.body.groupId !== undefined && (req.body.groupId || null) !== (before.group_id || null)){
1470
+ const group = req.body.groupId ? services.groups.get(req.body.groupId) : null;
1471
+ services.agents.setGroup(req.params.id, req.body.groupId);
1472
+ flash(req, 'success', group ? `"${agent.name}" now runs its jobs in group ${group.name}.` : `"${agent.name}" now runs its jobs on any resource.`);
1443
1473
  }
1444
1474
  }
1445
1475
  catch (err){
@@ -198,3 +198,48 @@ test('Import and Export work for a Client that doesn\'t re-register (no joinKey)
198
198
  method: 'POST', headers: { authorization: `Bearer ${other.resourceToken}`, 'content-type': 'application/json' }, body: '{}'
199
199
  })).status, 403);
200
200
  });
201
+
202
+ test('resource card CSS: stacked tabs let only the visible one take the mouse (else the device tabs\' form covers Details)', () => {
203
+ const css = fs.readFileSync(path.join(__dirname, '../public/css/thub.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''),
204
+ off = css.slice(css.indexOf('.thub-resource-card .tab-content > form,'));
205
+ assert.match(off, /^[^{]*> form > fieldset,[^{]*\.tab-pane \{\s*pointer-events: none;/);
206
+ assert.match(css, /\.thub-resource-card \.tab-content \.tab-pane\.active \{[^}]*pointer-events: auto;/);
207
+ });
208
+
209
+ test('agent groups are set on the dashboard (Users, CI tokens) and shown by name — never a group id', async (t) => {
210
+ const { base, services, admin, viewer } = await start(t),
211
+ g = services.groups.create({ name: 'nightly-pool', comment: 'night runs' }),
212
+ form = (cookie, p, body) => fetch(`${base}${p}`, { method: 'POST', headers: { cookie }, body: new URLSearchParams(body), redirect: 'manual' }),
213
+ page = async (cookie, p) => (await fetch(`${base}${p}`, { headers: { cookie } })).text();
214
+ services.registry.registerAuto({ clientId: 'sw9', name: 'lab-sw-09', type: 'sw', labels: [], groups: [g.id] });
215
+
216
+ // A maintainer puts an Agent-only user in the group, an admin a CI token.
217
+ await form(viewer, '/admin/users', { username: 'dev', email: 'd@example.com', role: 'user', groupId: g.id });
218
+ const dev = services.adminUsers.getByUsername('dev');
219
+ assert.equal(services.adminUsers.getById(dev.id).groupId, g.id);
220
+ await form(admin, '/admin/agents', { name: 'ci-night', groupId: g.id });
221
+ const ci = services.agents.list().find((a) => a.name === 'ci-night');
222
+ assert.equal(ci.group_id, g.id);
223
+ await form(admin, `/admin/agents/${ci.id}/rename`, { name: 'ci-night', groupId: '' });
224
+ assert.equal(services.agents.get(ci.id).group_id, null);
225
+ await form(admin, `/admin/agents/${ci.id}/rename`, { name: 'ci-night', groupId: g.id });
226
+
227
+ // A job from the user lands in the group; its page names it.
228
+ services.adminUsers.issueKey(dev.id, { by: 'admin' });
229
+ const devAgent = services.agents.list().find((a) => a.user_id === dev.id),
230
+ job = services.jobs.create({ agentId: devAgent.id, source: 'cli', spec: { target: { type: 'sw', labels: [] }, command: './t.sh' } });
231
+ assert.match(await page(admin, `/jobs/${job.id}`), /<dt class="col-sm-2">Group<\/dt><dd class="col-sm-10">nightly-pool<\/dd>/);
232
+
233
+ const users = await page(admin, '/admin/users'),
234
+ agents = await page(admin, '/admin/agents'),
235
+ groups = await page(admin, '/groups');
236
+ assert.match(users, /<span class="badge text-bg-info">nightly-pool<\/span>/);
237
+ assert.match(users, new RegExp(`<option value="${g.id}" selected="selected">nightly-pool</option>`)); // the picker (a value, not shown)
238
+ assert.match(agents, /<span class="badge text-bg-info">nightly-pool<\/span>/);
239
+ assert.match(groups, /data-bs-title="dev, ci-night"[^>]*>2</); // Agents column: users, then CI tokens
240
+ // The id is never shown as text on a page (it's only in values and URLs).
241
+ for (const html of [users, agents, groups, await page(admin, '/resources'), await page(admin, `/jobs/${job.id}`)]){
242
+ const text = html.replace(/<[^>]*>/g, ' ');
243
+ assert.ok(!text.includes(g.id), 'a group id shows as text');
244
+ }
245
+ });
@@ -25,6 +25,7 @@ const test = require('node:test'),
25
25
  { createRegistryService } = require('../src/services/registry'),
26
26
  { createAgentsService } = require('../src/services/agents'),
27
27
  { createGroupsService } = require('../src/services/groups'),
28
+ { createAdminUsersService } = require('../src/services/admin-users'),
28
29
  { createJobsService } = require('../src/services/jobs'),
29
30
  { createLogsService } = require('../src/services/logs'),
30
31
  { createScheduler } = require('../src/services/scheduler'),
@@ -334,43 +335,52 @@ test('a legacy resource with no clientId (pre-dates the feature) is adopted by n
334
335
  assert.equal(registry.list().length, 1);
335
336
  });
336
337
 
337
- test('a job with target.group only schedules onto resources that are members of that group', () => {
338
- const { registry, agents, groups, jobs, scheduler } = buildTestServices(),
338
+ test('an agent given a group (dashboard) runs its jobs only on that group\'s resources; whatever the Agent sends is replaced', () => {
339
+ const { db, registry, agents, groups, jobs, scheduler } = buildTestServices(),
340
+ users = createAdminUsersService(db, {}),
339
341
  { agent } = agents.create({ name: 'ci', kind: 'ci' }),
340
342
  groupA = groups.create({ name: 'group-a' }),
341
343
  groupB = groups.create({ name: 'group-b' }),
342
-
343
344
  inGroupA = registerResource(registry, { name: 'lab-a', type: 'sw', groups: [groupA.id] }),
344
345
  inGroupB = registerResource(registry, { name: 'lab-b', type: 'sw', groups: [groupB.id] });
345
346
  registry.heartbeat(inGroupA.id, { state: 'idle' });
346
347
  registry.heartbeat(inGroupB.id, { state: 'idle' });
347
348
 
348
- const job = jobs.create({
349
- agentId: agent.id,
350
- source: 'cli',
351
- spec: makeSpec({ target: { type: 'sw', labels: [], group: groupA.id } })
352
- });
349
+ // A CI token in group B, whose (older) Agent still asks for group A.
350
+ agents.setGroup(agent.id, groupB.id);
351
+ const job = jobs.create({ agentId: agent.id, source: 'ci', spec: makeSpec({ target: { type: 'sw', labels: [], group: groupA.id } }) });
352
+ assert.equal(jobs.get(job.id).spec.target.group, groupB.id);
353
353
  scheduler.runPass();
354
-
355
- const assigned = jobs.get(job.id);
356
- assert.equal(assigned.state, JOB_STATES.ASSIGNED);
357
- assert.equal(assigned.resource_id, inGroupA.id); // not inGroupB, despite being IDLE and type-matching
358
- });
359
-
360
- test('a job with target.group is rejected (422) when no resource is ever a member of that group', () => {
354
+ assert.equal(jobs.get(job.id).resource_id, inGroupB.id);
355
+
356
+ // A user's group follows their key (kept across rotations); none = any resource.
357
+ const dev = users.create({ username: 'dev', email: 'd@example.com', role: 'user', groupId: groupA.id }, { by: 't' });
358
+ users.issueKey(dev.id, { by: 't' });
359
+ const devAgent = agents.list().find((a) => a.user_id === dev.id),
360
+ devJob = jobs.create({ agentId: devAgent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', labels: [] } }) });
361
+ assert.equal(jobs.get(devJob.id).spec.target.group, groupA.id);
362
+ users.issueKey(dev.id, { by: 't' }); // rotated
363
+ const rotatedJob = jobs.create({ agentId: devAgent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', labels: [] } }) });
364
+ assert.equal(jobs.get(rotatedJob.id).spec.target.group, groupA.id);
365
+ users.update(dev.id, { groupId: '' }, { by: 't' });
366
+ const anyJob = jobs.create({ agentId: devAgent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', labels: [] } }) });
367
+ assert.equal(jobs.get(anyJob.id).spec.target.group, undefined);
368
+
369
+ // Deleting a group sends its agents back to any resource.
370
+ groups.remove(groupB.id);
371
+ assert.equal(agents.get(agent.id).group_id, null);
372
+ assert.throws(() => agents.setGroup(agent.id, 'no-such-group'), /Unknown group/);
373
+ });
374
+
375
+ test('a job is rejected (422) when no resource is ever a member of its agent\'s group — named, not by id', () => {
361
376
  const { registry, agents, groups, jobs } = buildTestServices(),
362
377
  { agent } = agents.create({ name: 'ci', kind: 'ci' }),
363
378
  group = groups.create({ name: 'empty-group' });
364
379
  registerResource(registry, { name: 'lab-01', type: 'sw', groups: [] }); // exists, but not a member
365
-
380
+ agents.setGroup(agent.id, group.id);
366
381
  assert.throws(
367
- () =>
368
- jobs.create({
369
- agentId: agent.id,
370
- source: 'cli',
371
- spec: makeSpec({ target: { type: 'sw', labels: [], group: group.id } })
372
- }),
373
- /No registered resource/
382
+ () => jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', labels: [] } }) }),
383
+ /No registered resource can ever satisfy type=sw labels= group=empty-group \(this agent's group, set on the dashboard\)/
374
384
  );
375
385
  });
376
386
 
@@ -1,5 +1,6 @@
1
1
  extends ../layout
2
2
  include ../mixins/updates
3
+ include ../mixins/group-select
3
4
  include ../mixins/list-controls
4
5
 
5
6
  block content
@@ -9,7 +10,7 @@ block content
9
10
  .d-flex.flex-wrap.align-items-center.gap-2.mb-3
10
11
  +updatesToolbar('agent', 'agents', list.pageUrl())
11
12
  .d-flex.flex-wrap.align-items-center.gap-2.mb-3
12
- +searchForm(q, 'Search agents: name, kind, version, status', list.urlFor({ q: '' }))
13
+ +searchForm(q, 'Search agents: name, kind, group, version, status', list.urlFor({ q: '' }))
13
14
  .ms-auto
14
15
  +pageSizePicker(list)
15
16
 
@@ -27,6 +28,7 @@ block content
27
28
  tr
28
29
  +sortHeader(list, 'name', 'Name')
29
30
  +sortHeader(list, 'kind', 'Kind')
31
+ +sortHeader(list, 'group', 'Group')
30
32
  +sortHeader(list, 'version', 'Version', 'desc')
31
33
  +sortHeader(list, 'created', 'Created', 'desc')
32
34
  +sortHeader(list, 'used', 'Last used', 'desc')
@@ -37,6 +39,11 @@ block content
37
39
  tr(class=a.revoked_at ? 'opacity-50' : '')
38
40
  td= a.name
39
41
  td.text-uppercase= a.kind
42
+ td
43
+ if a.group_id && groupsById[a.group_id]
44
+ span.badge.text-bg-info= groupsById[a.group_id].name
45
+ else
46
+ span.text-body-secondary any
40
47
  //- A revoked token can't pick an update up: no button.
41
48
  td: +versionWithUpdate(a.version, updates.latest.agent, a.update_to, `/admin/agents/${a.id}/update`, list.pageUrl(), !a.revoked_at)
42
49
  td= fmtDate(a.created_at)
@@ -73,12 +80,14 @@ block content
73
80
  label.form-label(for=`agentName-${a.id}`) Name
74
81
  input.form-control(id=`agentName-${a.id}` type="text" name="name" value=a.name required maxlength="100")
75
82
  .form-text Only a label — the agent's token, and everything using it, keep working.
83
+ label.form-label.mt-3(for=`agentGroup-${a.id}`) Group
84
+ +groupSelect(`agentGroup-${a.id}`, a.group_id, groups)
76
85
  .modal-footer
77
86
  button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
78
87
  button.btn.btn-primary(type="submit") Save
79
88
  else
80
89
  tr
81
- td.text-body-secondary(colspan="7")
90
+ td.text-body-secondary(colspan="8")
82
91
  if q
83
92
  | No agent matches “#{q}”.
84
93
  a(href=list.urlFor({ q: '' })) Show all agents
@@ -96,6 +105,9 @@ block content
96
105
  .mb-3
97
106
  label.form-label Name
98
107
  input.form-control(type="text" name="name" placeholder="ci-firmware" required)
108
+ .mb-3
109
+ label.form-label(for="newAgentGroup") Group
110
+ +groupSelect('newAgentGroup', null, groups)
99
111
  .mb-3
100
112
  p.small.text-body-secondary.mb-0.
101
113
  For a pipeline: its jobs get A- ids and it can read every job. People use their own access key
@@ -1,5 +1,6 @@
1
1
  extends ../layout
2
2
  include ../mixins/list-controls
3
+ include ../mixins/group-select
3
4
 
4
5
  //- Users (§10.3). Roles: user — the Agent only; maintainer — the dashboard
5
6
  //- without its security features; admin — everything. A maintainer sees and
@@ -63,6 +64,7 @@ block content
63
64
  tr
64
65
  +sortHeader(list, 'username', 'User')
65
66
  +sortHeader(list, 'role', 'Role')
67
+ +sortHeader(list, 'group', 'Group')
66
68
  +sortHeader(list, 'status', 'Status')
67
69
  +sortHeader(list, 'used', 'Access key', 'desc')
68
70
  th.text-end
@@ -75,6 +77,11 @@ block content
75
77
  a.fw-medium(href=`#editUser-${u.id}` data-bs-toggle="modal" role="button")= userDisplayName(u)
76
78
  td
77
79
  span.badge(class=`text-bg-${roleTone[u.role]}` data-bs-toggle="tooltip" data-bs-title=roleHelp[u.role])= roleLabel(u.role)
80
+ td
81
+ if u.groupId && groupsById[u.groupId]
82
+ span.badge.text-bg-info= groupsById[u.groupId].name
83
+ else
84
+ span.text-body-secondary.small any
78
85
  td
79
86
  if u.blocked
80
87
  span.badge.text-bg-danger(data-bs-toggle="tooltip" data-bs-title=`Since ${fmtDate(u.blockedAt)}`) blocked
@@ -188,12 +195,15 @@ block content
188
195
  .form-text Only an admin can change a role.
189
196
  if !u.canUseDashboard && roles.length > 1
190
197
  .form-text Making them a maintainer or admin gives them a temporary password for the dashboard.
198
+ .col-12
199
+ label.form-label(for=`u-${u.id}-group`) Group
200
+ +groupSelect(`u-${u.id}-group`, u.groupId, groups)
191
201
  .modal-footer
192
202
  button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
193
203
  button.btn.btn-primary(type="submit") Save
194
204
  else
195
205
  tr
196
- td.text-body-secondary(colspan="5")= q || roleFilter ? 'No user matches.' : 'No users yet.'
206
+ td.text-body-secondary(colspan="6")= q || roleFilter ? 'No user matches.' : 'No users yet.'
197
207
 
198
208
  //- The audit log: who did what to which account (web/routes.js auditRows).
199
209
  h2.h6.mt-4 Recent activity
@@ -238,6 +248,9 @@ block content
238
248
  .col-12
239
249
  label.form-label(for="new-role") Role
240
250
  +roleSelect('new-role', 'user')
251
+ .col-12
252
+ label.form-label(for="new-group") Group
253
+ +groupSelect('new-group', null, groups)
241
254
  .col-12
242
255
  .form-check.mt-1
243
256
  input.form-check-input#new-key(type="checkbox" name="issueKey" value="1" checked)
@@ -8,12 +8,12 @@ block content
8
8
  button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addGroupModal") Add group
9
9
 
10
10
  p.text-body-secondary.small: i.
11
- A job can be constrained to run only on resources in a group with `thub run --group &lt;id&gt;` (§13.1).
12
- Which groups a Client is in is set on its resource card (Groups tab, admins), or as `groups` in the Client's own config — the tab writes it there.
11
+ Give an agent a group — a user on #[a(href="/admin/users") Users], a CI token on #[a(href="/admin/agents") CI tokens] —
12
+ and its jobs run only on resources in that group (§13.1). Which groups a Client is in is set on its resource card (Groups tab).
13
13
 
14
14
  if totalCount
15
15
  .d-flex.flex-wrap.align-items-center.gap-2.mb-2
16
- +searchForm(q, 'Search groups: name, id, comment', '/groups')
16
+ +searchForm(q, 'Search groups: name, comment', '/groups')
17
17
  if q
18
18
  span.small.text-body-secondary
19
19
  | #{groups.length} of #{totalCount} groups
@@ -24,9 +24,9 @@ block content
24
24
  thead
25
25
  tr
26
26
  th Name
27
- th ID
28
27
  th Comment
29
28
  th Resources
29
+ th Agents
30
30
  th Created
31
31
  if canManage
32
32
  th Actions
@@ -34,18 +34,9 @@ block content
34
34
  each g in groups
35
35
  tr
36
36
  td= g.name
37
- td
38
- code.small.me-1= g.id
39
- button.btn.btn-sm.btn-outline-secondary.copy-btn(
40
- type="button"
41
- aria-label="Copy group ID to clipboard"
42
- data-bs-toggle="tooltip"
43
- data-bs-title="Copy to clipboard"
44
- data-copy=g.id
45
- )
46
- i.bi.bi-clipboard
47
37
  td= g.comment || '—'
48
38
  td= g.resourceCount
39
+ td(data-bs-toggle=g.agentNames.length ? 'tooltip' : null data-bs-title=g.agentNames.join(', ') || null)= g.agentNames.length
49
40
  td= fmtDate(g.created_at)
50
41
  if canManage
51
42
  td
@@ -53,7 +44,7 @@ block content
53
44
  form.d-inline.ms-1(
54
45
  method="post"
55
46
  action=`/groups/${g.id}/delete`
56
- data-confirm=`Delete group "${g.name}"? Resources listing it just stop matching on it, and jobs targeting it (--group) can no longer be scheduled.`
47
+ data-confirm=`Delete group "${g.name}"? Resources listing it just stop matching on it, and its agents go back to running their jobs on any resource.`
57
48
  data-confirm-title="Delete group?"
58
49
  data-confirm-ok="Delete group"
59
50
  )
@@ -73,17 +64,6 @@ block content
73
64
  .mb-3
74
65
  label.form-label Comment
75
66
  textarea.form-control(name="comment" rows="2")= g.comment || ''
76
- p.text-body-secondary.small.mb-0
77
- | ID:
78
- code.me-1= g.id
79
- button.btn.btn-sm.btn-outline-secondary.copy-btn(
80
- type="button"
81
- aria-label="Copy group ID to clipboard"
82
- data-bs-toggle="tooltip"
83
- data-bs-title="Copy to clipboard"
84
- data-copy=g.id
85
- )
86
- i.bi.bi-clipboard
87
67
  .modal-footer
88
68
  button.btn.btn-primary(type="submit") Save
89
69
  else if q
@@ -108,6 +88,5 @@ block content
108
88
  .mb-3
109
89
  label.form-label Comment
110
90
  textarea.form-control(name="comment" rows="2" placeholder="Optional")
111
- p.text-body-secondary.small.mb-0 The group's id (a UUID) is generated once it's created.
112
91
  .modal-footer
113
92
  button.btn.btn-primary(type="submit") Create
@@ -21,7 +21,6 @@
21
21
  ['--type hw|sw', 'Required. Resource type.', '--type hw'],
22
22
  ['--board <name>', 'Shorthand for --label board:<name>.', '--board nucleo-f401re'],
23
23
  ['--label <l>', 'Required label (repeatable). The Client must have all of them.', '--label uart --label stlink'],
24
- ['--group <id>', 'Only Clients in this group. Default: THUB_GROUP / config.', '--group 548ae4ae-…'],
25
24
  ['--client <name|id>', 'Only this Client; waits for it even if others are idle.', '--client lab-hw-01']
26
25
  ] },
27
26
  { title: 'What to run', rows: [
@@ -43,9 +43,9 @@
43
43
  thub config set url #{coordinatorUrl}
44
44
  thub config set key thk_…
45
45
  thub config set user "Your Name" # optional: job owner label
46
- thub config set group &lt;groupId> # optional: default --group
47
46
  p.small.
48
- These are saved in #[code ~/.config/thub/agent.json]. Order of precedence: flags (#[code --url], #[code --key]) →
47
+ These are saved in #[code ~/.config/thub/agent.json]. Which resource group your jobs run in isn't an Agent
48
+ setting: an admin or maintainer sets it for your user on the dashboard (#[code thub whoami] shows it). Order of precedence: flags (#[code --url], #[code --key]) →
49
49
  environment → config file.
50
50
  table.table.table-sm.small
51
51
  thead
@@ -59,9 +59,6 @@
59
59
  tr
60
60
  td: code THUB_KEY
61
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.)
62
- tr
63
- td: code THUB_GROUP
64
- td Default #[code --group]
65
62
  tr
66
63
  td: code THUB_USER
67
64
  td Default #[code --user]
@@ -38,7 +38,6 @@
38
38
  "name": "lab-hw-01",
39
39
  "type": "hw",
40
40
  "labels": ["board:nucleo-f401re", "uart", "stlink"],
41
- "groups": ["&lt;group id>"],
42
41
  "joinKey": "&lt;the Coordinator's clientJoinKey>",
43
42
  "varDir": "/home/thub/var/lib/thub/client",
44
43
  "hw-devices": {
@@ -83,7 +82,7 @@
83
82
  tr
84
83
  td: code groups
85
84
  td No
86
- td Group ids (from #[a(href="/groups") Groups]) this Client belongs to, for #[code thub run --group]. Admins can also set them on the resource card (Groups tab), which writes them here.
85
+ td The resource groups this Client belongs to. Set them on its resource card (Groups tab), which writes them here.
87
86
  tr
88
87
  td: code varDir
89
88
  td No
@@ -121,7 +121,7 @@
121
121
  thub-admin user list | block | unblock | role | password | key | delete …
122
122
  thub-admin join-key generate # → clientJoinKey here, joinKey on every Client
123
123
  thub-admin agent add ci-firmware # CI token for a pipeline: job ids A-00001…
124
- thub-admin group add ci-nightly --comment "nightly pool" # → a group id for thub run --group
124
+ thub-admin group add ci-nightly --comment "nightly pool" # then give it to users / CI tokens on the dashboard
125
125
  thub-admin resource maintenance &lt;resourceId> --on
126
126
  thub-admin jobs reset --yes # cancel every active job
127
127
  thub-admin jobs clean --yes --before "01/09/2026 00:00:00"
@@ -37,7 +37,7 @@
37
37
  ['JOB_TYPE', '--type', 'hw or sw'],
38
38
  ['JOB_BOARD', '--board', 'the board name'],
39
39
  ['JOB_LABEL / JOB_LABEL_<n>', '--board, --label', 'all labels, comma-separated / each'],
40
- ['JOB_GROUP', '--group', 'the group id'],
40
+ ['JOB_GROUP', '(dashboard)', 'the submitting agent\'s group, if it has one'],
41
41
  ['JOB_CLIENT', '--client', 'this Client\'s name'],
42
42
  ['JOB_USER', '--user', 'as given'],
43
43
  ['JOB_COMMAND', '--command', 'the command itself'],
@@ -6,7 +6,7 @@
6
6
  ['A Client doesn\'t appear on Resources', 'Check `journalctl -u thub-client@<instance> -f`. A `503` at registration means the Coordinator has no `clientJoinKey`; a `401` means the `joinKey` differs. Also check that `coordinatorUrl` is reachable from the lab (`curl -sI <url>/login`).'],
7
7
  ['A Client flaps or two machines show as one resource', 'Both use the same `.client-id` (a cloned VM or a shared config file). Delete `<varDir>/<instance>/.client-id` on one of them and restart it, or set a distinct `clientId`.'],
8
8
  ['Name already in use (`409`)', 'Another live Client holds that name. Rename one of them in its config or from the resource card. A name held by an `OUT_OF_SERVICE` resource is reclaimed automatically.'],
9
- ['The job is rejected with `422`', 'No registered Client can ever match its type, labels, group or client. Compare `thub resources` with your `--type`, `--board`/`--label` and `--group`.'],
9
+ ['The job is rejected with `422`', 'No registered Client can ever match its type, labels, group or client. Compare `thub resources` with your `--type` and `--board`/`--label`; your group (set on the dashboard, see `thub whoami`) must have a Client of that kind.'],
10
10
  ['The job stays `QUEUED`', 'Matching Clients are all `BUSY`, locked (`local`), in `MAINTENANCE` or offline, or the job is pinned with `--client`. It times out after `--timeout`.'],
11
11
  ['`ERROR` during prepare', 'The clone, download or DUT image pull failed; the job log names the reason. For git, check the Client user\'s keys and `known_hosts` (`ssh -T git@<host>` as that user). For images, check the Client user\'s `docker login`.'],
12
12
  ['Permission denied on `/dev/ttyUSB*` or `docker.sock`', 'Docker or the device groups were added after the Client was installed. Re-run `sudo npm i -g @andrian.yablonskyy/thub-client` so the unit gets the `dialout`/`plugdev`/`docker` groups, then restart.'],
@@ -33,6 +33,10 @@ block content
33
33
  dd.col-sm-10= job.resource ? job.resource.name : job.resource_name ? `${job.resource_name} (removed)` : '—'
34
34
  dt.col-sm-2 Target
35
35
  dd.col-sm-10= `${job.spec.target.type} — ${(job.spec.target.labels || []).join(', ')}`
36
+ if job.spec.target.group
37
+ //- The submitting agent's group (set on the dashboard), by name.
38
+ dt.col-sm-2 Group
39
+ dd.col-sm-10= targetGroup ? targetGroup.name : '(a deleted group)'
36
40
  if job.spec.target.client
37
41
  dt.col-sm-2 Client
38
42
  dd.col-sm-10= pinnedClient ? pinnedClient.name : job.spec.target.client
@@ -145,16 +145,11 @@ mixin groupsPane(r, returnTo, groupsById)
145
145
  span.fw-medium= g.name
146
146
  if g.comment
147
147
  span.small.text-body-secondary= ` — ${g.comment}`
148
- br
149
- code.small.text-body-secondary= g.id
150
148
  each id in unknown
151
149
  label.list-group-item.d-flex.gap-2.align-items-start
152
150
  input.form-check-input.flex-shrink-0.mt-1(type="checkbox" name="groups" value=id checked)
153
- span
154
- span.text-body-secondary (deleted group)
155
- br
156
- code.small.text-body-secondary= id
157
- .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.
151
+ span.text-body-secondary (a deleted group)
152
+ .form-text An agent given a group (on Users or CI tokens) runs its jobs only on Clients in it; an agent without one, on any Client.
158
153
  if canEdit && r.config_support === 'file'
159
154
  .d-flex.justify-content-end.mt-3
160
155
  button.btn.btn-sm.btn-primary(type="submit" data-bs-toggle="tooltip" data-bs-title="Save the groups — in effect at once; the Client writes them to its config file") Save groups
@@ -0,0 +1,9 @@
1
+ //- The resource group an agent's jobs run in (§13.1) — a user's on Users, a
2
+ //- CI token's on CI tokens. Only here, on the dashboard: `thub run` has no
3
+ //- --group. Groups by name; none = any resource.
4
+ mixin groupSelect(id, selected, groups)
5
+ select.form-select(id=id name="groupId")
6
+ option(value="" selected=!selected) Any resource (no group)
7
+ each g in groups
8
+ option(value=g.id selected=g.id === selected)= g.name
9
+ .form-text Its jobs run only on resources in this group. Manage groups on #[a(href="/groups") Groups].
@@ -327,7 +327,7 @@ mixin resourceCard(r, groupsById, returnTo)
327
327
  .tab-pane.fade.show.active(id=`rc-${r.id}-details` role="tabpanel" aria-labelledby=`rc-${r.id}-details-tab` tabindex="0" data-live=`rc-details-${r.id}`)
328
328
  +resourceDetails(r, groupsById, returnTo, local, external)
329
329
  +configPanes(r, returnTo, groupsById)
330
- .tab-pane.fade(id=`rc-${r.id}-usb` role="tabpanel" aria-labelledby=`rc-${r.id}-usb-tab` tabindex="0")
330
+ .tab-pane.fade.thub-usb-pane(id=`rc-${r.id}-usb` role="tabpanel" aria-labelledby=`rc-${r.id}-usb-tab` tabindex="0")
331
331
  +usbScanPane(r)
332
332
 
333
333
  //- "Connected USB devices" (resource card tab): the Client's `lsusb`, run
@@ -364,7 +364,8 @@ mixin usbScanPane(r)
364
364
  else
365
365
  | Not scanned yet#{can.operate ? ' — press Refresh to run lsusb -tvv on the Client' : ''}.
366
366
  .alert.alert-danger.py-2.small(data-usb-error class=scan && scan.error ? null : 'd-none')= scan && scan.error || ''
367
- 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 || ''
367
+ //- Fills the rest of the tab and scrolls inside (thub.css .thub-usb-pane).
368
+ pre.bg-body-tertiary.p-2.rounded.small.mb-0(data-usb-output class=scan && scan.output ? null : 'd-none')= scan && scan.output || ''
368
369
 
369
370
  //- Reboot the Client's host now (card's Actions row, icon only): confirmed first — naming the
370
371
  //- job that gets canceled, if one is running — then sent with the Client's
@@ -45,7 +45,7 @@ block content
45
45
  td
46
46
  if r.group_ids.length
47
47
  each gid in r.group_ids
48
- span.badge.text-bg-info.me-1(data-bs-toggle="tooltip" data-bs-title=gid)= groupsById[gid] ? groupsById[gid].name : `(unknown: ${gid})`
48
+ span.badge.text-bg-info.me-1= groupsById[gid] ? groupsById[gid].name : '(deleted group)'
49
49
  else
50
50
  span.text-body-secondary —
51
51
  td= r.busy_source ? `${r.busy_source}${r.busy_reason ? ' — ' + r.busy_reason : ''}` : '—'