@andrian.yablonskyy/thub-coordinator 1.1.11 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.1.11",
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.2",
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",
@@ -80,7 +80,7 @@
80
80
  }
81
81
 
82
82
  /* The client details modal has up to 7 tabs (Details, the config tabs,
83
- Connected USB devices) — tighter padding keeps them on one row, ⚠ icons
83
+ USB devices) — tighter padding keeps them on one row, ⚠ icons
84
84
  included, at the modal-lg width. */
85
85
  .thub-resource-card .nav-tabs .nav-link {
86
86
  padding: 0.45rem 0.65rem;
@@ -126,7 +126,79 @@
126
126
  pointer-events: auto;
127
127
  }
128
128
 
129
- /* Connected USB devices: the lsusb output takes the rest of the modal's
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
+
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. */
132
204
  .thub-resource-card .tab-content .tab-pane.thub-usb-pane {
@@ -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
+ })();
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file packages/coordinator/public/js/usb-import.js
3
- * @description Dashboard: "Import to config" on the resource card's Connected USB devices tab — updates the Config
3
+ * @description Dashboard: "Import to config" on the resource card's USB devices tab — updates the Config
4
4
  * sub-tabs' device lists from the last `lsusb -tvv`, opens the first changed one, and asks to verify
5
5
  *
6
6
  * @author Andrian Yablonskyy
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file packages/coordinator/public/js/usb-scan.js
3
- * @description Dashboard: the resource card's "Connected USB devices" tab — Refresh asks the Client for `lsusb`,
3
+ * @description Dashboard: the resource card's "USB devices" tab — Refresh asks the Client for `lsusb`,
4
4
  * then polls until its answer arrives and shows it in place
5
5
  *
6
6
  * @author Andrian Yablonskyy
@@ -1,4 +1,4 @@
1
- -- "Connected USB devices" (resource card tab, README §10): the Client runs
1
+ -- "USB devices" (resource card tab, README §10): the Client runs
2
2
  -- `lsusb` only when an admin presses Refresh — a scan-usb command with its
3
3
  -- next heartbeat — and posts the output back. `usb_scan` is the last result
4
4
  -- (JSON {output, error, at}); `usb_scan_requested_at`/`_request_id` the
@@ -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 --docker-image alpine --command 'uname -a'`
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
  }
@@ -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', 'image', 'git.url', 'git.ref', 'target.group', 'target.labels', 'downloads', 'args', 'meta']
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);
@@ -625,7 +625,7 @@ function createRegistryService(db, { bus, events }){
625
625
  return db.prepare('SELECT * FROM resources WHERE remove_requested_at IS NOT NULL').all().map(rowToResource);
626
626
  }
627
627
 
628
- // "Connected USB devices" tab: Refresh asks the Client to run `lsusb` on
628
+ // "USB devices" tab: Refresh asks the Client to run `lsusb` on
629
629
  // its next heartbeat (a scan-usb command, delivered by the caller's bus
630
630
  // via commands). Only while it's connected — it would otherwise run
631
631
  // whenever it next reconnects.
package/src/web/routes.js CHANGED
@@ -616,7 +616,7 @@ function createWebRouter({ services, config }){
616
616
  res.redirect(returnTo(req, '/runners'));
617
617
  });
618
618
 
619
- // "Connected USB devices" tab (public/js/usb-scan.js): Refresh queues a
619
+ // "USB devices" tab (public/js/usb-scan.js): Refresh queues a
620
620
  // scan-usb for the Client's next heartbeat; the tab then polls the GET
621
621
  // until the answer is in. JSON both ways — the modal stays open.
622
622
  function usbScanView(req, r){
@@ -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, with every group as a checkbox; no Groups line on Details any more.
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(`name="groups" value="${pr.id}" checked`));
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>/);
@@ -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
- { agent } = createAgentsService(db, { events }).create({ name: 'ci', kind: 'ci' }),
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
- const ids = ['carol', 'alice', null, 'bob'].map((user) =>
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', ...(user ? { user } : {}) }
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
 
@@ -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('a job-supplied Docker image runs on any matching SW Client (no opt-in any more)', () => {
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 job = jobs.create({ agentId: agent.id, source: 'cli', spec: { target: { type: 'sw', labels: [] }, image: 'alpine', command: './run.sh' } });
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.
@@ -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]); // git url + ref, both words
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…
@@ -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
+ });
@@ -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
@@ -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