@andrian.yablonskyy/thub-coordinator 1.1.6 → 1.1.8
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 +1 -1
- package/package.json +2 -2
- package/src/api/agent.js +5 -1
- package/src/db/migrations/028_agent_groups.sql +5 -0
- package/src/db/migrations/029_agent_deleted.sql +3 -0
- package/src/services/admin-users.js +22 -7
- package/src/services/agents.js +54 -2
- package/src/services/groups.js +7 -3
- package/src/services/jobs.js +19 -3
- package/src/services/list-prefs.js +2 -2
- package/src/web/routes.js +68 -15
- package/test/groups-tab.test.js +70 -0
- package/test/scheduler.test.js +33 -23
- package/test/users.test.js +2 -2
- package/views/admin/agents.pug +48 -15
- package/views/admin/users.pug +16 -3
- package/views/groups/list.pug +6 -27
- package/views/help/_agent-cli.pug +0 -1
- package/views/help/_agent-setup.pug +2 -5
- package/views/help/_client-setup.pug +1 -2
- package/views/help/_coordinator.pug +1 -1
- package/views/help/_env.pug +1 -1
- package/views/help/_troubleshooting.pug +1 -1
- package/views/jobs/show.pug +4 -0
- package/views/mixins/client-config.pug +2 -7
- package/views/mixins/group-select.pug +9 -0
- package/views/resources/list.pug +1 -1
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}").
|
|
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.
|
|
3
|
+
"version": "1.1.8",
|
|
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.
|
|
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",
|
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
|
-
|
|
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
|
}
|
package/src/services/agents.js
CHANGED
|
@@ -29,7 +29,8 @@ function createAgentsService(db, { events }){
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function list(){
|
|
32
|
-
return db.prepare(
|
|
32
|
+
return db.prepare(`SELECT id, name, kind, user_id, group_id, version, update_to, created_at, last_used_at, revoked_at, token_created_at
|
|
33
|
+
FROM agents WHERE deleted_at IS NULL ORDER BY created_at DESC`).all();
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
function create({ name, kind }){
|
|
@@ -60,6 +61,57 @@ 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
|
+
|
|
82
|
+
// CI tokens page: a new token for the same agent — the old one stops
|
|
83
|
+
// working at once (a revoked token comes back with it). Returned once;
|
|
84
|
+
// the row, and so its jobs and name, stay the same.
|
|
85
|
+
function rotateToken(id){
|
|
86
|
+
const agent = get(id);
|
|
87
|
+
if (!agent || agent.deleted_at){
|
|
88
|
+
throw Object.assign(new Error('Unknown agent'), { status: 404 });
|
|
89
|
+
}
|
|
90
|
+
if (agent.user_id){
|
|
91
|
+
throw Object.assign(new Error('A user\'s key is replaced on Users, not here'), { status: 400 });
|
|
92
|
+
}
|
|
93
|
+
const token = generateToken('agt');
|
|
94
|
+
db.prepare('UPDATE agents SET token_hash = ?, token_created_at = ?, revoked_at = NULL WHERE id = ?').run(hashToken(token), new Date().toISOString(), id);
|
|
95
|
+
events.record('agent', id, 'agent.token_rotated', { name: agent.name });
|
|
96
|
+
return token;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// CI tokens page: gone from the list, its token revoked; the row stays for
|
|
100
|
+
// the jobs that reference it (they keep showing its name).
|
|
101
|
+
function remove(id){
|
|
102
|
+
const agent = get(id);
|
|
103
|
+
if (!agent || agent.deleted_at){
|
|
104
|
+
throw Object.assign(new Error('Unknown agent'), { status: 404 });
|
|
105
|
+
}
|
|
106
|
+
if (agent.user_id){
|
|
107
|
+
throw Object.assign(new Error('A user\'s key is managed on Users, not here'), { status: 400 });
|
|
108
|
+
}
|
|
109
|
+
const now = new Date().toISOString();
|
|
110
|
+
db.prepare('UPDATE agents SET deleted_at = ?, revoked_at = COALESCE(revoked_at, ?), update_to = NULL WHERE id = ?').run(now, now, id);
|
|
111
|
+
events.record('agent', id, 'agent.deleted', { name: agent.name });
|
|
112
|
+
return agent;
|
|
113
|
+
}
|
|
114
|
+
|
|
63
115
|
function revoke(id){
|
|
64
116
|
db.prepare('UPDATE agents SET revoked_at = ? WHERE id = ?').run(new Date().toISOString(), id);
|
|
65
117
|
events.record('agent', id, 'agent.revoked', {});
|
|
@@ -97,7 +149,7 @@ function createAgentsService(db, { events }){
|
|
|
97
149
|
return ids.length;
|
|
98
150
|
}
|
|
99
151
|
|
|
100
|
-
return { get, getByTokenHash, list, create, rename, revoke, touchLastUsed, setUpdateTo, requestUpdateAll };
|
|
152
|
+
return { get, getByTokenHash, list, create, rename, setGroup, rotateToken, remove, revoke, touchLastUsed, setUpdateTo, requestUpdateAll };
|
|
101
153
|
}
|
|
102
154
|
|
|
103
155
|
module.exports = { createAgentsService };
|
package/src/services/groups.js
CHANGED
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
|
|
16
16
|
const { v4: uuid } = require('uuid');
|
|
17
17
|
|
|
18
|
-
// Admin-managed resource groups (README §13.1) — a
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
}
|
package/src/services/jobs.js
CHANGED
|
@@ -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,
|
|
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
|
-
(
|
|
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.
|
|
1039
|
-
.map((g) => ({
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,17 +1448,52 @@ 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
|
-
|
|
1434
|
-
|
|
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)
|
|
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}".`);
|
|
1443
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.`);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
catch (err){
|
|
1476
|
+
flash(req, 'danger', err.message);
|
|
1477
|
+
}
|
|
1478
|
+
res.redirect(returnTo(req, '/admin/agents'));
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
router.post('/admin/agents/:id/token', (req, res) => {
|
|
1482
|
+
try {
|
|
1483
|
+
const agent = services.agents.get(req.params.id),
|
|
1484
|
+
token = services.agents.rotateToken(req.params.id);
|
|
1485
|
+
return renderAgents(req, res, { newToken: token, newTokenFor: agent.name });
|
|
1486
|
+
}
|
|
1487
|
+
catch (err){
|
|
1488
|
+
flash(req, 'danger', err.message);
|
|
1489
|
+
}
|
|
1490
|
+
res.redirect(returnTo(req, '/admin/agents'));
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
router.post('/admin/agents/:id/delete', (req, res) => {
|
|
1494
|
+
try {
|
|
1495
|
+
const agent = services.agents.remove(req.params.id);
|
|
1496
|
+
flash(req, 'success', `Deleted CI token "${agent.name}". Its jobs stay, listed under its name.`);
|
|
1444
1497
|
}
|
|
1445
1498
|
catch (err){
|
|
1446
1499
|
flash(req, 'danger', err.message);
|
package/test/groups-tab.test.js
CHANGED
|
@@ -205,3 +205,73 @@ test('resource card CSS: stacked tabs let only the visible one take the mouse (e
|
|
|
205
205
|
assert.match(off, /^[^{]*> form > fieldset,[^{]*\.tab-pane \{\s*pointer-events: none;/);
|
|
206
206
|
assert.match(css, /\.thub-resource-card \.tab-content \.tab-pane\.active \{[^}]*pointer-events: auto;/);
|
|
207
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
|
+
});
|
|
246
|
+
|
|
247
|
+
test('CI tokens: the name opens Edit; a new token replaces the old one; delete keeps its jobs', async (t) => {
|
|
248
|
+
const { base, services, admin } = await start(t),
|
|
249
|
+
form = (p, body = {}) => fetch(`${base}${p}`, { method: 'POST', headers: { cookie: admin }, body: new URLSearchParams(body), redirect: 'manual' }),
|
|
250
|
+
{ agent, token } = services.agents.create({ name: 'ci-fw', kind: 'ci' }),
|
|
251
|
+
me = (tok) => fetch(`${base}/api/v1/me`, { headers: { authorization: `Bearer ${tok}` } });
|
|
252
|
+
services.registry.registerAuto({ clientId: 'sw7', name: 'lab-sw-07', type: 'sw', labels: [] });
|
|
253
|
+
const job = services.jobs.create({ agentId: agent.id, source: 'ci', spec: { target: { type: 'sw', labels: [] }, command: './t.sh' } });
|
|
254
|
+
|
|
255
|
+
let html = await (await fetch(`${base}/admin/agents`, { headers: { cookie: admin } })).text();
|
|
256
|
+
assert.match(html, new RegExp(`<a class="fw-medium" href="#editAgentModal-${agent.id}" data-bs-toggle="modal" role="button">ci-fw</a>`));
|
|
257
|
+
assert.doesNotMatch(html, />Edit<\/button>/);
|
|
258
|
+
|
|
259
|
+
// New token: shown once, the old one stops at once.
|
|
260
|
+
const res = await form(`/admin/agents/${agent.id}/token`);
|
|
261
|
+
html = await res.text();
|
|
262
|
+
const fresh = /value="(agt_[^"]+)" readonly/.exec(html)?.[1];
|
|
263
|
+
assert.ok(fresh, 'the new token is shown');
|
|
264
|
+
assert.match(html, /New token for ci-fw \(shown once — the old one has stopped working\)/);
|
|
265
|
+
assert.equal((await me(token)).status, 401);
|
|
266
|
+
assert.equal((await me(fresh)).status, 200);
|
|
267
|
+
|
|
268
|
+
// Delete: gone from the list and from the API; its job keeps its name.
|
|
269
|
+
await form(`/admin/agents/${agent.id}/delete`);
|
|
270
|
+
html = await (await fetch(`${base}/admin/agents`, { headers: { cookie: admin } })).text();
|
|
271
|
+
assert.match(html, /Deleted CI token "ci-fw"|Deleted CI token "ci-fw"/); // the flash
|
|
272
|
+
assert.doesNotMatch(html.slice(html.indexOf('<tbody')), /ci-fw/); // the list
|
|
273
|
+
assert.equal((await me(fresh)).status, 401);
|
|
274
|
+
assert.ok((await (await fetch(`${base}/jobs?q=ci-fw`, { headers: { cookie: admin } })).text()).includes(job.id)); // still found by its name
|
|
275
|
+
assert.match(await (await form(`/admin/agents/${agent.id}/token`)).headers.get('location'), /agents/); // can't come back
|
|
276
|
+
assert.equal(services.agents.get(agent.id).revoked_at !== null, true);
|
|
277
|
+
});
|
package/test/scheduler.test.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
|
package/test/users.test.js
CHANGED
|
@@ -358,7 +358,7 @@ test('maintainers manage Agent-only users, their own account and CI tokens — n
|
|
|
358
358
|
|
|
359
359
|
// CI tokens: issue, rename, revoke.
|
|
360
360
|
page = await post('/admin/agents', { name: 'ci-nightly' });
|
|
361
|
-
assert.match(page, /Token \(shown once\):[\s\S]
|
|
361
|
+
assert.match(page, /Token \(shown once\):[\s\S]*?value="agt_/);
|
|
362
362
|
const ci = services.agents.list().find((a) => a.name === 'ci-nightly');
|
|
363
363
|
await post(`/admin/agents/${ci.id}/rename`, { name: 'ci-night' });
|
|
364
364
|
await post(`/admin/agents/${ci.id}/revoke`);
|
|
@@ -423,7 +423,7 @@ test('audit log: Timestamp, User (who did it — always), Action — from the da
|
|
|
423
423
|
log = html.slice(html.indexOf('Recent activity'), html.indexOf('id="addUserModal"')),
|
|
424
424
|
rows = [...log.matchAll(/<tr><td[^>]*>[^<]*<\/td><td[^>]*>([^<]*)<\/td><td>([^<]*)<\/td><\/tr>/g)]
|
|
425
425
|
.map((m) => [m[1], m[2].replace(/'/g, '\'').replace(/>/g, '>')]).reverse(); // oldest first
|
|
426
|
-
assert.match(log, /<th
|
|
426
|
+
assert.match(log, /<th[^>]*>Timestamp<\/th><th>User<\/th><th>Action<\/th>/);
|
|
427
427
|
assert.deepEqual(rows, [
|
|
428
428
|
['root', 'Created root (Admin)'],
|
|
429
429
|
['root', 'Created dev (User)'],
|
package/views/admin/agents.pug
CHANGED
|
@@ -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,15 +10,18 @@ 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
|
|
|
16
17
|
if newToken
|
|
17
|
-
.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
//- Shown once: nothing below keeps it.
|
|
19
|
+
.alert.alert-warning(role="alert")
|
|
20
|
+
strong= newTokenFor ? `New token for ${newTokenFor} (shown once — the old one has stopped working):` : 'Token (shown once):'
|
|
21
|
+
.input-group.input-group-sm.mt-2
|
|
22
|
+
input.form-control.font-monospace(type="text" value=newToken readonly aria-label="Token")
|
|
23
|
+
button.btn.btn-outline-secondary(type="button" data-copy=newToken aria-label="Copy token")
|
|
24
|
+
i.bi.bi-clipboard
|
|
21
25
|
|
|
22
26
|
div(data-live="list-toolbar")
|
|
23
27
|
+listToolbar(list, 'agents')
|
|
@@ -27,6 +31,7 @@ block content
|
|
|
27
31
|
tr
|
|
28
32
|
+sortHeader(list, 'name', 'Name')
|
|
29
33
|
+sortHeader(list, 'kind', 'Kind')
|
|
34
|
+
+sortHeader(list, 'group', 'Group')
|
|
30
35
|
+sortHeader(list, 'version', 'Version', 'desc')
|
|
31
36
|
+sortHeader(list, 'created', 'Created', 'desc')
|
|
32
37
|
+sortHeader(list, 'used', 'Last used', 'desc')
|
|
@@ -35,21 +40,33 @@ block content
|
|
|
35
40
|
tbody(data-live="rows")
|
|
36
41
|
each a in agents
|
|
37
42
|
tr(class=a.revoked_at ? 'opacity-50' : '')
|
|
38
|
-
|
|
43
|
+
//- The name opens Edit (name and group).
|
|
44
|
+
td
|
|
45
|
+
a.fw-medium(href=`#editAgentModal-${a.id}` data-bs-toggle="modal" role="button")= a.name
|
|
39
46
|
td.text-uppercase= a.kind
|
|
47
|
+
td
|
|
48
|
+
if a.group_id && groupsById[a.group_id]
|
|
49
|
+
span.badge.text-bg-info= groupsById[a.group_id].name
|
|
50
|
+
else
|
|
51
|
+
span.text-body-secondary any
|
|
40
52
|
//- A revoked token can't pick an update up: no button.
|
|
41
53
|
td: +versionWithUpdate(a.version, updates.latest.agent, a.update_to, `/admin/agents/${a.id}/update`, list.pageUrl(), !a.revoked_at)
|
|
42
54
|
td= fmtDate(a.created_at)
|
|
43
55
|
td= fmtDate(a.last_used_at, 'never')
|
|
44
56
|
td= a.revoked_at ? 'revoked' : 'active'
|
|
45
57
|
td
|
|
46
|
-
.d-flex.justify-content-end
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
data-
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
.d-flex.justify-content-end.text-nowrap
|
|
59
|
+
form(
|
|
60
|
+
method="post"
|
|
61
|
+
action=`/admin/agents/${a.id}/token`
|
|
62
|
+
data-confirm=a.revoked_at ? `Issue a new token for "${a.name}"? It becomes active again with it.` : `Issue a new token for "${a.name}"? Its current token stops working at once — update it wherever it's used (a CI secret).`
|
|
63
|
+
data-confirm-title="Issue a new token?"
|
|
64
|
+
data-confirm-ok="Issue token"
|
|
65
|
+
data-confirm-tone="primary"
|
|
66
|
+
)
|
|
67
|
+
input(type="hidden" name="returnTo" value=list.pageUrl())
|
|
68
|
+
button.btn.btn-sm.btn-outline-primary(type="submit" aria-label=`New token for ${a.name}` data-bs-toggle="tooltip" data-bs-title="Issue a new token (the old one stops working; shown once)")
|
|
69
|
+
i.bi.bi-key
|
|
53
70
|
if !a.revoked_at
|
|
54
71
|
form.ms-1(
|
|
55
72
|
method="post"
|
|
@@ -59,7 +76,18 @@ block content
|
|
|
59
76
|
data-confirm-ok="Revoke"
|
|
60
77
|
)
|
|
61
78
|
input(type="hidden" name="returnTo" value=list.pageUrl())
|
|
62
|
-
button.btn.btn-sm.btn-outline-
|
|
79
|
+
button.btn.btn-sm.btn-outline-warning(type="submit" aria-label=`Revoke ${a.name}` data-bs-toggle="tooltip" data-bs-title="Revoke the token (a new one can be issued later)")
|
|
80
|
+
i.bi.bi-key-fill
|
|
81
|
+
form.ms-1(
|
|
82
|
+
method="post"
|
|
83
|
+
action=`/admin/agents/${a.id}/delete`
|
|
84
|
+
data-confirm=`Delete CI token "${a.name}"? Its token stops working and it leaves this list for good; its jobs stay, listed under its name.`
|
|
85
|
+
data-confirm-title="Delete CI token?"
|
|
86
|
+
data-confirm-ok="Delete"
|
|
87
|
+
)
|
|
88
|
+
input(type="hidden" name="returnTo" value=list.pageUrl())
|
|
89
|
+
button.btn.btn-sm.btn-outline-danger(type="submit" aria-label=`Delete ${a.name}` data-bs-toggle="tooltip" data-bs-title="Delete the CI token")
|
|
90
|
+
i.bi.bi-trash
|
|
63
91
|
|
|
64
92
|
.modal(tabindex="-1" id=`editAgentModal-${a.id}` aria-labelledby=`editAgentTitle-${a.id}`)
|
|
65
93
|
.modal-dialog
|
|
@@ -73,12 +101,14 @@ block content
|
|
|
73
101
|
label.form-label(for=`agentName-${a.id}`) Name
|
|
74
102
|
input.form-control(id=`agentName-${a.id}` type="text" name="name" value=a.name required maxlength="100")
|
|
75
103
|
.form-text Only a label — the agent's token, and everything using it, keep working.
|
|
104
|
+
label.form-label.mt-3(for=`agentGroup-${a.id}`) Group
|
|
105
|
+
+groupSelect(`agentGroup-${a.id}`, a.group_id, groups)
|
|
76
106
|
.modal-footer
|
|
77
107
|
button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
|
|
78
108
|
button.btn.btn-primary(type="submit") Save
|
|
79
109
|
else
|
|
80
110
|
tr
|
|
81
|
-
td.text-body-secondary(colspan="
|
|
111
|
+
td.text-body-secondary(colspan="8")
|
|
82
112
|
if q
|
|
83
113
|
| No agent matches “#{q}”.
|
|
84
114
|
a(href=list.urlFor({ q: '' })) Show all agents
|
|
@@ -96,6 +126,9 @@ block content
|
|
|
96
126
|
.mb-3
|
|
97
127
|
label.form-label Name
|
|
98
128
|
input.form-control(type="text" name="name" placeholder="ci-firmware" required)
|
|
129
|
+
.mb-3
|
|
130
|
+
label.form-label(for="newAgentGroup") Group
|
|
131
|
+
+groupSelect('newAgentGroup', null, groups)
|
|
99
132
|
.mb-3
|
|
100
133
|
p.small.text-body-secondary.mb-0.
|
|
101
134
|
For a pipeline: its jobs get A- ids and it can read every job. People use their own access key
|
package/views/admin/users.pug
CHANGED
|
@@ -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="
|
|
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
|
|
@@ -202,13 +212,13 @@ block content
|
|
|
202
212
|
table.table.table-sm.small.align-middle
|
|
203
213
|
thead
|
|
204
214
|
tr
|
|
205
|
-
th
|
|
215
|
+
th Timestamp
|
|
206
216
|
th User
|
|
207
217
|
th Action
|
|
208
218
|
tbody
|
|
209
219
|
each e in audit
|
|
210
220
|
tr
|
|
211
|
-
td.text-
|
|
221
|
+
td.text-body-secondary= fmtDate(e.ts)
|
|
212
222
|
td.text-nowrap= e.by
|
|
213
223
|
td= e.action
|
|
214
224
|
else
|
|
@@ -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)
|
package/views/groups/list.pug
CHANGED
|
@@ -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
|
-
|
|
12
|
-
Which groups a Client is in is set on its resource card (Groups tab
|
|
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,
|
|
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
|
|
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 <groupId> # optional: default --group
|
|
47
46
|
p.small.
|
|
48
|
-
These are saved in #[code ~/.config/thub/agent.json].
|
|
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": ["<group id>"],
|
|
42
41
|
"joinKey": "<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
|
|
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" #
|
|
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 <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"
|
package/views/help/_env.pug
CHANGED
|
@@ -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', '
|
|
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
|
|
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.'],
|
package/views/jobs/show.pug
CHANGED
|
@@ -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
|
-
|
|
155
|
-
br
|
|
156
|
-
code.small.text-body-secondary= id
|
|
157
|
-
.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.
|
|
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].
|
package/views/resources/list.pug
CHANGED
|
@@ -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
|
|
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 : ''}` : '—'
|