@andrian.yablonskyy/thub-coordinator 1.1.12 → 1.1.13
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/package.json +2 -2
- package/public/css/thub.css +72 -0
- package/public/js/tag-picker.js +200 -0
- package/src/dev/virtual.js +1 -3
- package/src/services/jobs.js +14 -1
- package/test/groups-tab.test.js +4 -2
- package/test/list-prefs.test.js +7 -4
- package/test/scheduler.test.js +6 -2
- package/test/search.test.js +2 -3
- package/test/users.test.js +14 -0
- package/views/admin/agents.pug +21 -17
- package/views/admin/users.pug +43 -40
- package/views/groups/list.pug +20 -16
- package/views/help/_agent-cli.pug +11 -15
- package/views/help/_agent-setup.pug +0 -4
- package/views/help/_ci.pug +3 -2
- package/views/help/_client-machines.pug +12 -11
- package/views/help/_client-setup.pug +4 -4
- package/views/help/_docker.pug +29 -92
- package/views/help/_env.pug +11 -19
- package/views/help/_git.pug +36 -123
- package/views/help/_overview.pug +2 -2
- package/views/help/_quick-start.pug +1 -1
- package/views/help/_troubleshooting.pug +3 -1
- package/views/help/index.pug +2 -2
- package/views/index.pug +1 -0
- package/views/mixins/client-config.pug +29 -13
- package/views/mixins/resource.pug +2 -2
- package/views/resources/list.pug +1 -0
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.13",
|
|
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.3",
|
|
23
23
|
"better-sqlite3": "^12.4.1",
|
|
24
24
|
"express": "^5.1.0",
|
|
25
25
|
"express-session": "^1.18.2",
|
package/public/css/thub.css
CHANGED
|
@@ -126,6 +126,78 @@
|
|
|
126
126
|
pointer-events: auto;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/* Chip picker (runner card → Groups; public/js/tag-picker.js): chips and
|
|
130
|
+
a search box in one field, the matches in a dropdown under it. */
|
|
131
|
+
.thub-tags {
|
|
132
|
+
position: relative;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.thub-tags-box {
|
|
136
|
+
display: flex;
|
|
137
|
+
flex-wrap: wrap;
|
|
138
|
+
align-items: center;
|
|
139
|
+
gap: 0.375rem;
|
|
140
|
+
min-height: calc(1.5em + 0.75rem + 2px);
|
|
141
|
+
height: auto;
|
|
142
|
+
cursor: text;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
fieldset:disabled .thub-tags-box {
|
|
146
|
+
cursor: default;
|
|
147
|
+
background-color: var(--bs-secondary-bg);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.thub-tag {
|
|
151
|
+
display: inline-flex;
|
|
152
|
+
align-items: center;
|
|
153
|
+
gap: 0.125rem;
|
|
154
|
+
max-width: 100%;
|
|
155
|
+
padding: 0.125rem 0.25rem 0.125rem 0.5rem;
|
|
156
|
+
border-radius: var(--bs-border-radius-sm);
|
|
157
|
+
background-color: var(--bs-secondary-bg);
|
|
158
|
+
border: 1px solid var(--bs-border-color);
|
|
159
|
+
font-size: 0.875rem;
|
|
160
|
+
line-height: 1.4;
|
|
161
|
+
overflow-wrap: anywhere;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.thub-tag-remove {
|
|
165
|
+
display: inline-flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
justify-content: center;
|
|
168
|
+
width: 1.5rem;
|
|
169
|
+
height: 1.5rem;
|
|
170
|
+
padding: 0;
|
|
171
|
+
border: 0;
|
|
172
|
+
border-radius: var(--bs-border-radius-sm);
|
|
173
|
+
background: none;
|
|
174
|
+
color: var(--bs-secondary-color);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.thub-tag-remove:hover,
|
|
178
|
+
.thub-tag-remove:focus-visible {
|
|
179
|
+
background-color: var(--bs-tertiary-bg);
|
|
180
|
+
color: var(--bs-body-color);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.thub-tags-input {
|
|
184
|
+
flex: 1 1 8rem;
|
|
185
|
+
min-width: 8rem;
|
|
186
|
+
border: 0;
|
|
187
|
+
outline: 0;
|
|
188
|
+
padding: 0.125rem 0;
|
|
189
|
+
background: transparent;
|
|
190
|
+
color: inherit;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.thub-tags-menu {
|
|
194
|
+
width: 100%;
|
|
195
|
+
max-height: 15rem;
|
|
196
|
+
overflow-y: auto;
|
|
197
|
+
top: 100%;
|
|
198
|
+
left: 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
129
201
|
/* USB devices: the lsusb output takes the rest of the modal's
|
|
130
202
|
height — what the tallest other tab sets, at least 12rem — and scrolls in
|
|
131
203
|
it. Its 0 basis keeps it from making the modal taller. */
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/public/js/tag-picker.js
|
|
3
|
+
* @description Dashboard: a chip picker (the runner card's Groups tab) — chosen items as removable chips,
|
|
4
|
+
* a search box that suggests the rest; each chip carries its own hidden form input
|
|
5
|
+
*
|
|
6
|
+
* @author Andrian Yablonskyy
|
|
7
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
8
|
+
*
|
|
9
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
10
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
11
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
12
|
+
* from AdSystem.PRO.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
// Markup (views/mixins/client-config.pug, groupsPane):
|
|
18
|
+
// [data-tag-picker data-tag-name data-options='[{value, label, hint}]']
|
|
19
|
+
// [data-tag-box] > span[data-tag=value] (label, hidden input, [data-tag-remove]) … input[data-tag-input]
|
|
20
|
+
// ul[data-tag-menu]
|
|
21
|
+
// Typing filters the options not chosen yet (by name or comment); ↑/↓ move,
|
|
22
|
+
// Enter or a click adds, Backspace in an empty box removes the last chip,
|
|
23
|
+
// Esc closes the list (not the modal around it).
|
|
24
|
+
(function (){
|
|
25
|
+
const MAX_SHOWN = 50,
|
|
26
|
+
|
|
27
|
+
pickerOf = (el) => el.closest('[data-tag-picker]'),
|
|
28
|
+
optionsOf = (picker) => {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(picker.dataset.options || '[]');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
chosen = (picker) => new Set([...picker.querySelectorAll('[data-tag]')].map((t) => t.dataset.tag)),
|
|
37
|
+
menuOf = (picker) => picker.querySelector('[data-tag-menu]'),
|
|
38
|
+
inputOf = (picker) => picker.querySelector('[data-tag-input]');
|
|
39
|
+
|
|
40
|
+
function closeMenu(picker){
|
|
41
|
+
const menu = menuOf(picker);
|
|
42
|
+
menu.classList.remove('show');
|
|
43
|
+
menu.replaceChildren();
|
|
44
|
+
inputOf(picker)?.setAttribute('aria-expanded', 'false');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function openMenu(picker){
|
|
48
|
+
const input = inputOf(picker),
|
|
49
|
+
menu = menuOf(picker),
|
|
50
|
+
q = input.value.trim().toLowerCase(),
|
|
51
|
+
taken = chosen(picker),
|
|
52
|
+
matches = optionsOf(picker)
|
|
53
|
+
.filter((o) => !taken.has(o.value) && (!q || o.label.toLowerCase().includes(q) || o.hint.toLowerCase().includes(q)))
|
|
54
|
+
.slice(0, MAX_SHOWN);
|
|
55
|
+
menu.replaceChildren(...(matches.length ? matches.map((o, i) => {
|
|
56
|
+
const li = document.createElement('li'),
|
|
57
|
+
item = document.createElement('button');
|
|
58
|
+
item.type = 'button';
|
|
59
|
+
item.className = `dropdown-item${i === 0 ? ' active' : ''}`;
|
|
60
|
+
item.setAttribute('role', 'option');
|
|
61
|
+
item.dataset.tagOption = o.value;
|
|
62
|
+
item.tabIndex = -1;
|
|
63
|
+
item.append(document.createTextNode(o.label));
|
|
64
|
+
if (o.hint){
|
|
65
|
+
const hint = document.createElement('span');
|
|
66
|
+
hint.className = 'small text-body-secondary ms-2';
|
|
67
|
+
hint.textContent = o.hint;
|
|
68
|
+
item.append(hint);
|
|
69
|
+
}
|
|
70
|
+
li.append(item);
|
|
71
|
+
return li;
|
|
72
|
+
}) : [Object.assign(document.createElement('li'), {
|
|
73
|
+
className: 'dropdown-item-text small text-body-secondary',
|
|
74
|
+
textContent: optionsOf(picker).length === taken.size ? 'All groups are added' : 'No matching group'
|
|
75
|
+
})]));
|
|
76
|
+
menu.classList.add('show');
|
|
77
|
+
input.setAttribute('aria-expanded', 'true');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function addTag(picker, value){
|
|
81
|
+
const option = optionsOf(picker).find((o) => o.value === value);
|
|
82
|
+
if (!option || chosen(picker).has(value)){
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const chip = document.createElement('span'),
|
|
86
|
+
label = document.createElement('span'),
|
|
87
|
+
hidden = document.createElement('input'),
|
|
88
|
+
remove = document.createElement('button');
|
|
89
|
+
chip.className = 'thub-tag';
|
|
90
|
+
chip.dataset.tag = value;
|
|
91
|
+
label.textContent = option.label;
|
|
92
|
+
hidden.type = 'hidden';
|
|
93
|
+
hidden.name = picker.dataset.tagName;
|
|
94
|
+
hidden.value = value;
|
|
95
|
+
remove.type = 'button';
|
|
96
|
+
remove.className = 'thub-tag-remove';
|
|
97
|
+
remove.dataset.tagRemove = '';
|
|
98
|
+
remove.setAttribute('aria-label', `Remove ${option.label}`);
|
|
99
|
+
remove.innerHTML = '<i class="bi bi-x"></i>';
|
|
100
|
+
chip.append(label, hidden, remove);
|
|
101
|
+
const input = inputOf(picker);
|
|
102
|
+
input.before(chip);
|
|
103
|
+
input.value = '';
|
|
104
|
+
picker.dispatchEvent(new Event('change', { bubbles: true }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function removeTag(chip){
|
|
108
|
+
const picker = pickerOf(chip);
|
|
109
|
+
chip.remove();
|
|
110
|
+
picker.dispatchEvent(new Event('change', { bubbles: true }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const editable = (picker) => picker && inputOf(picker) && !picker.closest('fieldset')?.disabled;
|
|
114
|
+
|
|
115
|
+
document.addEventListener('click', (e) => {
|
|
116
|
+
const remove = e.target.closest('[data-tag-remove]'),
|
|
117
|
+
option = e.target.closest('[data-tag-option]'),
|
|
118
|
+
box = e.target.closest('[data-tag-box]'),
|
|
119
|
+
picker = pickerOf(e.target);
|
|
120
|
+
if (remove && editable(picker)){
|
|
121
|
+
removeTag(remove.closest('[data-tag]'));
|
|
122
|
+
// Focus back to the box, without opening the list over what's below.
|
|
123
|
+
picker.dataset.quiet = '1';
|
|
124
|
+
inputOf(picker).focus();
|
|
125
|
+
closeMenu(picker);
|
|
126
|
+
}
|
|
127
|
+
else if (option && editable(picker)){
|
|
128
|
+
addTag(picker, option.dataset.tagOption);
|
|
129
|
+
openMenu(picker);
|
|
130
|
+
inputOf(picker).focus();
|
|
131
|
+
}
|
|
132
|
+
else if (box && editable(picker)){
|
|
133
|
+
inputOf(picker).focus();
|
|
134
|
+
}
|
|
135
|
+
// A click anywhere else closes every open list.
|
|
136
|
+
document.querySelectorAll('[data-tag-picker]').forEach((p) => {
|
|
137
|
+
if (p !== picker){
|
|
138
|
+
closeMenu(p);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
document.addEventListener('focusin', (e) => {
|
|
144
|
+
const picker = e.target.matches('[data-tag-input]') ? pickerOf(e.target) : null;
|
|
145
|
+
if (picker?.dataset.quiet){
|
|
146
|
+
delete picker.dataset.quiet;
|
|
147
|
+
}
|
|
148
|
+
else if (picker && editable(picker)){
|
|
149
|
+
openMenu(picker);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
document.addEventListener('input', (e) => {
|
|
154
|
+
if (e.target.matches('[data-tag-input]')){
|
|
155
|
+
openMenu(pickerOf(e.target));
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
document.addEventListener('keydown', (e) => {
|
|
160
|
+
if (!e.target.matches('[data-tag-input]')){
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const picker = pickerOf(e.target),
|
|
164
|
+
menu = menuOf(picker),
|
|
165
|
+
items = [...menu.querySelectorAll('[data-tag-option]')],
|
|
166
|
+
at = items.findIndex((i) => i.classList.contains('active'));
|
|
167
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp'){
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
if (!menu.classList.contains('show')){
|
|
170
|
+
return openMenu(picker);
|
|
171
|
+
}
|
|
172
|
+
if (items.length){
|
|
173
|
+
const next = (at + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
|
|
174
|
+
items.forEach((i, n) => i.classList.toggle('active', n === next));
|
|
175
|
+
items[next].scrollIntoView({ block: 'nearest' });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else if (e.key === 'Enter'){
|
|
179
|
+
e.preventDefault(); // never submits the form from here
|
|
180
|
+
if (at >= 0){
|
|
181
|
+
addTag(picker, items[at].dataset.tagOption);
|
|
182
|
+
openMenu(picker);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (e.key === 'Backspace' && !e.target.value){
|
|
186
|
+
const chips = picker.querySelectorAll('[data-tag]');
|
|
187
|
+
if (chips.length){
|
|
188
|
+
removeTag(chips[chips.length - 1]);
|
|
189
|
+
openMenu(picker);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else if (e.key === 'Escape' && menu.classList.contains('show')){
|
|
193
|
+
e.stopPropagation(); // the list closes, the modal stays
|
|
194
|
+
closeMenu(picker);
|
|
195
|
+
}
|
|
196
|
+
else if (e.key === 'Tab'){
|
|
197
|
+
closeMenu(picker);
|
|
198
|
+
}
|
|
199
|
+
}, true); // capture: ahead of the modal's own Esc handling
|
|
200
|
+
})();
|
package/src/dev/virtual.js
CHANGED
|
@@ -100,8 +100,6 @@ const DEV_AGENT_NAME = 'virtual-agent',
|
|
|
100
100
|
target: { type, labels: ['virtual'] },
|
|
101
101
|
command: './ci/run.sh',
|
|
102
102
|
downloads: [{ url: 'https://artifactory.example.com/fw-local/app/1.4.0-dev/app.bin' }],
|
|
103
|
-
...(type === 'sw' ? { image: 'alpine:3.20' } : {}),
|
|
104
|
-
git: { url: 'https://github.com/yourorg/firmware-tests.git', ref: 'main', depth: 1 },
|
|
105
103
|
...(user ? { user } : {}),
|
|
106
104
|
meta: { repo: 'yourorg/firmware', branch: 'main', virtual: true },
|
|
107
105
|
...extra
|
|
@@ -313,7 +311,7 @@ function startDevMode(services, config, env = process.env){
|
|
|
313
311
|
'*** DEV MODE (DEV_MODE=1): virtual Clients virtual-sw-01 / virtual-hw-01 are running in-process' +
|
|
314
312
|
`${seeded ? `, and ${seeded} past jobs + 3 live ones were seeded` : ''}.\n` +
|
|
315
313
|
`*** Virtual agent token: ${DEV_AGENT_TOKEN}\n` +
|
|
316
|
-
`*** e.g. thub --url ${config.publicUrl} --token ${DEV_AGENT_TOKEN} run --type sw --
|
|
314
|
+
`*** e.g. thub --url ${config.publicUrl} --token ${DEV_AGENT_TOKEN} run --type sw --command 'uname -a'`
|
|
317
315
|
);
|
|
318
316
|
return virtual;
|
|
319
317
|
}
|
package/src/services/jobs.js
CHANGED
|
@@ -106,7 +106,7 @@ function createJobsService(db, { bus, events, registry, config }){
|
|
|
106
106
|
// anyone who can see the list guess a secret one character at a time.
|
|
107
107
|
SEARCH_SQL = [
|
|
108
108
|
'jobs.id', 'jobs.state', 'jobs.source', 'jobs.message', 'r.name', 'jobs.resource_name', 'a.name',
|
|
109
|
-
...['user', 'command', 'suite', '
|
|
109
|
+
...['user', 'command', 'suite', 'target.group', 'target.labels', 'downloads', 'args', 'meta']
|
|
110
110
|
.map((path) => `json_extract(jobs.spec, '$.${path}')`)
|
|
111
111
|
];
|
|
112
112
|
|
|
@@ -142,6 +142,12 @@ function createJobsService(db, { bus, events, registry, config }){
|
|
|
142
142
|
return { rows: rows.map(rowToJob), pagination };
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
// A job's user: the key's user's username, else the CI token's name.
|
|
146
|
+
function agentOwner(agentId){
|
|
147
|
+
const row = db.prepare('SELECT a.name, u.username FROM agents a LEFT JOIN users u ON u.id = a.user_id WHERE a.id = ?').get(agentId);
|
|
148
|
+
return row ? row.username || row.name : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
145
151
|
// The group an agent's jobs run in: its user's, or the CI token's own.
|
|
146
152
|
function agentGroup(agentId){
|
|
147
153
|
return db.prepare(
|
|
@@ -173,6 +179,13 @@ function createJobsService(db, { bus, events, registry, config }){
|
|
|
173
179
|
throw Object.assign(new Error(`Invalid label: ${badLabels.join(', ')} — ${LABEL_RULE}`), { status: 400 });
|
|
174
180
|
}
|
|
175
181
|
spec.source = source;
|
|
182
|
+
// Who submitted it, from the key itself (never the Agent's say): the
|
|
183
|
+
// user's username, or the CI token's name.
|
|
184
|
+
const owner = agentOwner(agentId);
|
|
185
|
+
delete spec.user;
|
|
186
|
+
if (owner){
|
|
187
|
+
spec.user = owner;
|
|
188
|
+
}
|
|
176
189
|
// The group is the agent's, set on the dashboard (§13.1) — whatever an
|
|
177
190
|
// older Agent sent as `target.group` is replaced.
|
|
178
191
|
const group = agentGroup(agentId);
|
package/test/groups-tab.test.js
CHANGED
|
@@ -66,9 +66,11 @@ test('Groups tab: in effect at once, sent to the Client as its next config revis
|
|
|
66
66
|
assert.deepEqual(pending.file.groups.sort(), [nightly.id, pr.id].sort()); // the Client writes it to its file
|
|
67
67
|
assert.match(await page(), /Groups of lab-sw-01 saved: 2 group\(s\) — in effect now/);
|
|
68
68
|
|
|
69
|
-
// The tab
|
|
69
|
+
// The tab: its groups as chips (each with its hidden input and ✕), the rest searchable; no Groups line on Details any more.
|
|
70
70
|
const html = await page();
|
|
71
|
-
assert.match(html, new RegExp(
|
|
71
|
+
assert.match(html, new RegExp(`<span class="thub-tag" data-tag="${pr.id}"><span>pr-pool</span><input type="hidden" name="groups" value="${pr.id}"`));
|
|
72
|
+
assert.match(html, /data-tag-input[^>]*placeholder="Add a group…"/);
|
|
73
|
+
assert.match(html, /data-options="[^"]*pull requests/); // searchable by name or comment
|
|
72
74
|
assert.match(html, /data-config-tab="groups"/);
|
|
73
75
|
assert.match(html, /not written to the Client yet/);
|
|
74
76
|
assert.doesNotMatch(html, /<dt class="col-sm-4">Groups<\/dt>/);
|
package/test/list-prefs.test.js
CHANGED
|
@@ -65,19 +65,22 @@ test('jobs.page sorts and pages in SQL, with empty values last', () => {
|
|
|
65
65
|
bus = new EventEmitter(),
|
|
66
66
|
events = createEventsService(db),
|
|
67
67
|
registry = createRegistryService(db, { bus, events }),
|
|
68
|
-
|
|
68
|
+
agents = createAgentsService(db, { events }),
|
|
69
69
|
config = { scheduler: { maxQueuedPerAgent: 100 }, jobs: { defaultTimeoutSec: 60, maxTimeoutSec: 60 } },
|
|
70
70
|
jobs = createJobsService(db, { bus, events, registry, artifacts: {}, config });
|
|
71
71
|
registry.registerAuto({ clientId: 'c', name: 'lab', type: 'sw', labels: [] });
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
// A job's user is its key's (here: the CI token's name); 'old' stands for
|
|
74
|
+
// a job from before that, which has none.
|
|
75
|
+
const ids = ['carol', 'alice', 'old', 'bob'].map((name) =>
|
|
74
76
|
jobs.create({
|
|
75
|
-
agentId: agent.id,
|
|
77
|
+
agentId: agents.create({ name, kind: 'ci' }).agent.id,
|
|
76
78
|
source: 'cli',
|
|
77
|
-
spec: { target: { type: 'sw' }, command: './run.sh'
|
|
79
|
+
spec: { target: { type: 'sw' }, command: './run.sh' }
|
|
78
80
|
}).id),
|
|
79
81
|
|
|
80
82
|
byUser = (dir) => jobs.page({ sort: 'user', dir, size: 'all' }).rows.map((j) => j.spec.user ?? null);
|
|
83
|
+
db.prepare('UPDATE jobs SET spec = json_remove(spec, \'$.user\') WHERE id = ?').run(ids[2]);
|
|
81
84
|
assert.deepEqual(byUser('asc'), ['alice', 'bob', 'carol', null]);
|
|
82
85
|
assert.deepEqual(byUser('desc'), ['carol', 'bob', 'alice', null]);
|
|
83
86
|
|
package/test/scheduler.test.js
CHANGED
|
@@ -532,12 +532,16 @@ test('a removed Client re-registers as a fresh resource under the same name', ()
|
|
|
532
532
|
assert.equal(again.name, 'lab-back');
|
|
533
533
|
});
|
|
534
534
|
|
|
535
|
-
test('
|
|
535
|
+
test('an SW job is just its command: git / --docker-image from an older Agent are refused, saying what to do', () => {
|
|
536
536
|
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
537
537
|
{ agent } = agents.create({ name: 'dev', kind: 'cli' }),
|
|
538
538
|
r = registerResource(registry, { name: 'sw-any', type: 'sw' });
|
|
539
539
|
registry.heartbeat(r.id, { state: 'idle' });
|
|
540
|
-
const
|
|
540
|
+
for (const extra of [{ image: 'alpine' }, { git: { url: 'https://git.lab/t.git' } }]){
|
|
541
|
+
assert.throws(() => jobs.create({ agentId: agent.id, source: 'cli', spec: { target: { type: 'sw', labels: [] }, command: './run.sh', ...extra } }),
|
|
542
|
+
/no longer supported — clone the repository or run docker in --command, passing credentials with --env/);
|
|
543
|
+
}
|
|
544
|
+
const job = jobs.create({ agentId: agent.id, source: 'cli', spec: { target: { type: 'sw', labels: [] }, command: 'docker run --rm alpine ./run.sh' } });
|
|
541
545
|
scheduler.runPass();
|
|
542
546
|
assert.equal(jobs.get(job.id).resource_id, r.id);
|
|
543
547
|
// An older Client still reporting its sw settings: they're dropped.
|
package/test/search.test.js
CHANGED
|
@@ -65,8 +65,7 @@ test('jobs.page searches ids, users, resources, agents and spec fields — never
|
|
|
65
65
|
source: 'ci',
|
|
66
66
|
spec: {
|
|
67
67
|
target: { type: 'sw' },
|
|
68
|
-
command: './ci/smoke.sh',
|
|
69
|
-
git: { url: 'git@github.com:yourorg/firmware-tests.git', ref: 'release/1.4' },
|
|
68
|
+
command: 'git clone --branch release/1.4 git@github.com:yourorg/firmware-tests.git . && ./ci/smoke.sh',
|
|
70
69
|
meta: { ciJobId: '9165432107' },
|
|
71
70
|
env: { API_TOKEN: 'hunter2-secret' }
|
|
72
71
|
}
|
|
@@ -82,7 +81,7 @@ test('jobs.page searches ids, users, resources, agents and spec fields — never
|
|
|
82
81
|
assert.deepEqual(ids(a.toLowerCase()), [a]); // job id, any case
|
|
83
82
|
assert.deepEqual(ids('alice'), [m]); // user and agent name
|
|
84
83
|
assert.deepEqual(ids('ci-firmware'), [a]); // agent name
|
|
85
|
-
assert.deepEqual(ids('firmware-tests release/1.4'), [a]); //
|
|
84
|
+
assert.deepEqual(ids('firmware-tests release/1.4'), [a]); // words of the command (its git clone), both
|
|
86
85
|
assert.deepEqual(ids('9165432107'), [a]); // meta
|
|
87
86
|
assert.deepEqual(ids('smoke alice'), []); // words must all match the same job
|
|
88
87
|
assert.deepEqual(ids('regression_50%'), [m]); // % and _ are literal…
|
package/test/users.test.js
CHANGED
|
@@ -438,3 +438,17 @@ test('audit log: Timestamp, User (who did it — always), Action — from the da
|
|
|
438
438
|
['system', 'Unblocked dev']
|
|
439
439
|
]);
|
|
440
440
|
});
|
|
441
|
+
|
|
442
|
+
test('a job\'s user comes from its key: the username, or the CI token\'s name — never what the Agent sends', async (t) => {
|
|
443
|
+
const { services } = await start(t),
|
|
444
|
+
u = services.adminUsers,
|
|
445
|
+
dev = u.create({ username: 'dev', email: 'd@example.com', role: 'user' }, { by: 't' });
|
|
446
|
+
services.registry.registerAuto({ clientId: 'c', name: 'lab-sw-01', type: 'sw', labels: [] });
|
|
447
|
+
u.issueKey(dev.id, { by: 't' });
|
|
448
|
+
const devAgent = services.agents.list().find((a) => a.user_id === dev.id),
|
|
449
|
+
ci = services.agents.create({ name: 'ci-nightly', kind: 'ci' }).agent,
|
|
450
|
+
spec = { target: { type: 'sw' }, command: './t.sh', user: 'someone else' }, // an older Agent's --user
|
|
451
|
+
userOf = (agentId) => services.jobs.get(services.jobs.create({ agentId, source: 'cli', spec: { ...spec } }).id).spec.user;
|
|
452
|
+
assert.equal(userOf(devAgent.id), 'dev');
|
|
453
|
+
assert.equal(userOf(ci.id), 'ci-nightly');
|
|
454
|
+
});
|
package/views/admin/agents.pug
CHANGED
|
@@ -87,23 +87,6 @@ block content
|
|
|
87
87
|
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")
|
|
88
88
|
i.bi.bi-trash
|
|
89
89
|
|
|
90
|
-
.modal(tabindex="-1" id=`editAgentModal-${a.id}` aria-labelledby=`editAgentTitle-${a.id}`)
|
|
91
|
-
.modal-dialog
|
|
92
|
-
.modal-content
|
|
93
|
-
form(method="post" action=`/admin/agents/${a.id}/rename`)
|
|
94
|
-
input(type="hidden" name="returnTo" value=list.pageUrl())
|
|
95
|
-
.modal-header
|
|
96
|
-
h5.modal-title(id=`editAgentTitle-${a.id}`) Edit agent
|
|
97
|
-
button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
|
|
98
|
-
.modal-body
|
|
99
|
-
label.form-label(for=`agentName-${a.id}`) Name
|
|
100
|
-
input.form-control(id=`agentName-${a.id}` type="text" name="name" value=a.name required maxlength="100")
|
|
101
|
-
.form-text Only a label — the agent's token, and everything using it, keep working.
|
|
102
|
-
label.form-label.mt-3(for=`agentGroup-${a.id}`) Group
|
|
103
|
-
+groupSelect(`agentGroup-${a.id}`, a.group_id, groups)
|
|
104
|
-
.modal-footer
|
|
105
|
-
button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
|
|
106
|
-
button.btn.btn-primary(type="submit") Save
|
|
107
90
|
else
|
|
108
91
|
tr
|
|
109
92
|
td.text-body-secondary(colspan="7")
|
|
@@ -113,6 +96,27 @@ block content
|
|
|
113
96
|
else
|
|
114
97
|
| No agents yet.
|
|
115
98
|
|
|
99
|
+
//- Edit windows: outside the table, which scrolls sideways in its own
|
|
100
|
+
//- box on phones and would trap them under what follows.
|
|
101
|
+
each a in agents
|
|
102
|
+
.modal(tabindex="-1" id=`editAgentModal-${a.id}` aria-labelledby=`editAgentTitle-${a.id}`)
|
|
103
|
+
.modal-dialog
|
|
104
|
+
.modal-content
|
|
105
|
+
form(method="post" action=`/admin/agents/${a.id}/rename`)
|
|
106
|
+
input(type="hidden" name="returnTo" value=list.pageUrl())
|
|
107
|
+
.modal-header
|
|
108
|
+
h5.modal-title(id=`editAgentTitle-${a.id}`) Edit agent
|
|
109
|
+
button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
|
|
110
|
+
.modal-body
|
|
111
|
+
label.form-label(for=`agentName-${a.id}`) Name
|
|
112
|
+
input.form-control(id=`agentName-${a.id}` type="text" name="name" value=a.name required maxlength="100")
|
|
113
|
+
.form-text Only a label — the agent's token, and everything using it, keep working.
|
|
114
|
+
label.form-label.mt-3(for=`agentGroup-${a.id}`) Group
|
|
115
|
+
+groupSelect(`agentGroup-${a.id}`, a.group_id, groups)
|
|
116
|
+
.modal-footer
|
|
117
|
+
button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
|
|
118
|
+
button.btn.btn-primary(type="submit") Save
|
|
119
|
+
|
|
116
120
|
.modal(tabindex="-1" id="addAgentModal")
|
|
117
121
|
.modal-dialog
|
|
118
122
|
.modal-content
|
package/views/admin/users.pug
CHANGED
|
@@ -161,50 +161,53 @@ block content
|
|
|
161
161
|
button.btn.btn-sm.btn-outline-danger(type="submit" aria-label="Delete" data-bs-toggle="tooltip" data-bs-title="Delete the user")
|
|
162
162
|
i.bi.bi-trash
|
|
163
163
|
|
|
164
|
-
//- Inside the actions cell (text-nowrap): wrap again in here.
|
|
165
|
-
.modal.text-start.text-wrap(tabindex="-1" id=`editUser-${u.id}` aria-labelledby=`editUserTitle-${u.id}`)
|
|
166
|
-
.modal-dialog
|
|
167
|
-
.modal-content
|
|
168
|
-
form(method="post" action=`/admin/users/${u.id}`)
|
|
169
|
-
input(type="hidden" name="returnTo" value=list.pageUrl())
|
|
170
|
-
.modal-header
|
|
171
|
-
h5.modal-title(id=`editUserTitle-${u.id}`)= `Edit ${u.username}`
|
|
172
|
-
button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
|
|
173
|
-
.modal-body
|
|
174
|
-
.row.g-2
|
|
175
|
-
.col-sm-6
|
|
176
|
-
label.form-label(for=`u-${u.id}-username`) Username
|
|
177
|
-
input.form-control(id=`u-${u.id}-username` name="username" value=u.username required maxlength="64" pattern="[A-Za-z0-9._@+\\-]{1,64}")
|
|
178
|
-
.col-sm-6
|
|
179
|
-
label.form-label(for=`u-${u.id}-email`) Email
|
|
180
|
-
input.form-control(id=`u-${u.id}-email` type="email" name="email" value=u.email required)
|
|
181
|
-
.col-sm-6
|
|
182
|
-
label.form-label(for=`u-${u.id}-first`) First name
|
|
183
|
-
input.form-control(id=`u-${u.id}-first` name="firstName" value=u.firstName || '')
|
|
184
|
-
.col-sm-6
|
|
185
|
-
label.form-label(for=`u-${u.id}-last`) Last name
|
|
186
|
-
input.form-control(id=`u-${u.id}-last` name="lastName" value=u.lastName || '')
|
|
187
|
-
.col-12
|
|
188
|
-
label.form-label(for=`u-${u.id}-role`) Role
|
|
189
|
-
//- A maintainer gives no roles but User; their own
|
|
190
|
-
//- row shows theirs without a picker (and posts none).
|
|
191
|
-
if roles.includes(u.role)
|
|
192
|
-
+roleSelect(`u-${u.id}-role`, u.role)
|
|
193
|
-
else
|
|
194
|
-
input.form-control(id=`u-${u.id}-role` value=roleLabel(u.role) disabled)
|
|
195
|
-
.form-text Only an admin can change a role.
|
|
196
|
-
if !u.canUseDashboard && roles.length > 1
|
|
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)
|
|
201
|
-
.modal-footer
|
|
202
|
-
button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
|
|
203
|
-
button.btn.btn-primary(type="submit") Save
|
|
204
164
|
else
|
|
205
165
|
tr
|
|
206
166
|
td.text-body-secondary(colspan="6")= q || roleFilter ? 'No user matches.' : 'No users yet.'
|
|
207
167
|
|
|
168
|
+
//- Edit windows: outside the table, which scrolls sideways in its own
|
|
169
|
+
//- box on phones and would trap them under what follows.
|
|
170
|
+
each u in users
|
|
171
|
+
.modal.text-start.text-wrap(tabindex="-1" id=`editUser-${u.id}` aria-labelledby=`editUserTitle-${u.id}`)
|
|
172
|
+
.modal-dialog
|
|
173
|
+
.modal-content
|
|
174
|
+
form(method="post" action=`/admin/users/${u.id}`)
|
|
175
|
+
input(type="hidden" name="returnTo" value=list.pageUrl())
|
|
176
|
+
.modal-header
|
|
177
|
+
h5.modal-title(id=`editUserTitle-${u.id}`)= `Edit ${u.username}`
|
|
178
|
+
button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
|
|
179
|
+
.modal-body
|
|
180
|
+
.row.g-2
|
|
181
|
+
.col-sm-6
|
|
182
|
+
label.form-label(for=`u-${u.id}-username`) Username
|
|
183
|
+
input.form-control(id=`u-${u.id}-username` name="username" value=u.username required maxlength="64" pattern="[A-Za-z0-9._@+\\-]{1,64}")
|
|
184
|
+
.col-sm-6
|
|
185
|
+
label.form-label(for=`u-${u.id}-email`) Email
|
|
186
|
+
input.form-control(id=`u-${u.id}-email` type="email" name="email" value=u.email required)
|
|
187
|
+
.col-sm-6
|
|
188
|
+
label.form-label(for=`u-${u.id}-first`) First name
|
|
189
|
+
input.form-control(id=`u-${u.id}-first` name="firstName" value=u.firstName || '')
|
|
190
|
+
.col-sm-6
|
|
191
|
+
label.form-label(for=`u-${u.id}-last`) Last name
|
|
192
|
+
input.form-control(id=`u-${u.id}-last` name="lastName" value=u.lastName || '')
|
|
193
|
+
.col-12
|
|
194
|
+
label.form-label(for=`u-${u.id}-role`) Role
|
|
195
|
+
//- A maintainer gives no roles but User; their own
|
|
196
|
+
//- row shows theirs without a picker (and posts none).
|
|
197
|
+
if roles.includes(u.role)
|
|
198
|
+
+roleSelect(`u-${u.id}-role`, u.role)
|
|
199
|
+
else
|
|
200
|
+
input.form-control(id=`u-${u.id}-role` value=roleLabel(u.role) disabled)
|
|
201
|
+
.form-text Only an admin can change a role.
|
|
202
|
+
if !u.canUseDashboard && roles.length > 1
|
|
203
|
+
.form-text Making them a maintainer or admin gives them a temporary password for the dashboard.
|
|
204
|
+
.col-12
|
|
205
|
+
label.form-label(for=`u-${u.id}-group`) Group
|
|
206
|
+
+groupSelect(`u-${u.id}-group`, u.groupId, groups)
|
|
207
|
+
.modal-footer
|
|
208
|
+
button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
|
|
209
|
+
button.btn.btn-primary(type="submit") Save
|
|
210
|
+
|
|
208
211
|
//- The audit log: who did what to which account (web/routes.js auditRows).
|
|
209
212
|
h2.h6.mt-4 Recent activity
|
|
210
213
|
if audit.length
|
package/views/groups/list.pug
CHANGED
|
@@ -50,22 +50,26 @@ block content
|
|
|
50
50
|
)
|
|
51
51
|
button.btn.btn-sm.btn-outline-danger(type="submit") Delete
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
53
|
+
//- Edit windows: outside the table, which scrolls sideways in its own
|
|
54
|
+
//- box on phones and would trap them under what follows.
|
|
55
|
+
if canManage
|
|
56
|
+
each g in groups
|
|
57
|
+
.modal(tabindex="-1" id=`editGroupModal-${g.id}`)
|
|
58
|
+
.modal-dialog
|
|
59
|
+
.modal-content
|
|
60
|
+
form(method="post" action=`/groups/${g.id}`)
|
|
61
|
+
.modal-header
|
|
62
|
+
h5.modal-title Edit group
|
|
63
|
+
button.btn-close(type="button" data-bs-dismiss="modal")
|
|
64
|
+
.modal-body
|
|
65
|
+
.mb-3
|
|
66
|
+
label.form-label Name
|
|
67
|
+
input.form-control(type="text" name="name" value=g.name required)
|
|
68
|
+
.mb-3
|
|
69
|
+
label.form-label Comment
|
|
70
|
+
textarea.form-control(name="comment" rows="2")= g.comment || ''
|
|
71
|
+
.modal-footer
|
|
72
|
+
button.btn.btn-primary(type="submit") Save
|
|
69
73
|
else if q
|
|
70
74
|
p.text-body-secondary
|
|
71
75
|
| No group matches “#{q}”.
|