@andrian.yablonskyy/thub-coordinator 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
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.0",
22
+ "@andrian.yablonskyy/thub-common": "^1.1.1",
23
23
  "better-sqlite3": "^12.4.1",
24
24
  "express": "^5.1.0",
25
25
  "express-session": "^1.18.2",
@@ -86,17 +86,33 @@
86
86
  padding: 0.45rem 0.65rem;
87
87
  }
88
88
 
89
- /* Bootstrap hides inactive tab panes only as direct children of
90
- .tab-content (`.tab-content > .tab-pane`). The client details modal's
91
- capability tabs sit inside their shared <form>/<fieldset> (one Save for
92
- all of them), so without this the inactive ones merely fade out and still
93
- take up space, pushing the active tab's fields down. */
94
- .tab-content .tab-pane[data-config-pane] {
95
- display: none;
89
+ /* The client details modal keeps one size whichever tab is open — the
90
+ tallest tab's — instead of growing and shrinking (and, centered, jumping)
91
+ on every switch. Every pane sits in the same grid cell, so the tab area is
92
+ as tall as the tallest; inactive ones are hidden with `visibility`, which
93
+ keeps their height (and still takes them out of the tab order and of what
94
+ screen readers read). The device tabs sit inside their shared
95
+ <form>/<fieldset> (one Save for all of them), so that fieldset stacks its
96
+ panes the same way. The USB tab's output scrolls in its own box (360 px),
97
+ so a long lsusb doesn't make every tab that tall. */
98
+ .thub-resource-card .tab-content,
99
+ .thub-resource-card .tab-content > form > fieldset {
100
+ display: grid;
101
+ }
102
+
103
+ .thub-resource-card .tab-content > *,
104
+ .thub-resource-card .tab-content > form > fieldset > * {
105
+ grid-area: 1 / 1;
106
+ min-width: 0;
96
107
  }
97
108
 
98
- .tab-content .tab-pane[data-config-pane].active {
109
+ .thub-resource-card .tab-content .tab-pane {
99
110
  display: block;
111
+ visibility: hidden;
112
+ }
113
+
114
+ .thub-resource-card .tab-content .tab-pane.active {
115
+ visibility: visible;
100
116
  }
101
117
 
102
118
  /* Help page (views/help/): sticky table of contents, code blocks with a
@@ -233,9 +249,6 @@
233
249
  itself on short screens, and is laid out for thumbs: pages as a two-column
234
250
  grid, the tools in one row, the account in the next. public/js/actions.js
235
251
  closes it on a tap outside, Esc, or choosing a page. */
236
- .thub-nav-break {
237
- display: none;
238
- }
239
252
 
240
253
  @media (max-width: 991.98px) {
241
254
  .navbar .thub-nav {
@@ -295,15 +308,19 @@
295
308
  min-height: 2.5rem;
296
309
  }
297
310
 
298
- /* The account row: the profile link takes the line, sign-out at its end. */
299
- .thub-nav-break {
300
- display: block;
301
- flex-basis: 100%;
302
- height: 0;
311
+ /* The account row, first in the menu: you, your role, your profile, and
312
+ sign-out at its end. */
313
+ .thub-nav-me {
314
+ display: flex;
315
+ align-items: center;
316
+ gap: 0.75rem;
317
+ padding-bottom: 0.75rem;
318
+ margin-bottom: 0.75rem;
319
+ border-bottom: 1px solid var(--bs-border-color);
303
320
  }
304
321
 
305
- .thub-nav-account {
306
- flex: 1 1 auto;
322
+ .thub-nav-me .btn {
323
+ min-width: 2.5rem;
307
324
  min-height: 2.5rem;
308
325
  }
309
326
  }
@@ -34,7 +34,6 @@
34
34
  return compact({
35
35
  ...extra,
36
36
  ...device(text(tr, 'device')),
37
- serial: text(tr, 'serial'),
38
37
  baudRate: num(tr, 'baudRate'),
39
38
  devpath: text(tr, 'devpath')
40
39
  });
@@ -655,6 +655,17 @@ function createRegistryService(db, { bus, events }){
655
655
  return get(resourceId);
656
656
  }
657
657
 
658
+ // The Client's hw/sw section as it stands: what it reported at its last
659
+ // start, unless a saved revision hasn't reached it yet — then that one.
660
+ // The base for a revision that only changes something else (groups, an
661
+ // Import without a section) and for Export: the saved one alone could be
662
+ // older than the Client's file, and sending it would undo what changed
663
+ // there since.
664
+ function currentSection(r){
665
+ const pending = r.config_desired && r.config_applied_revision < r.config_revision;
666
+ return pending ? r.config_desired : (r.client_config || r.config_desired);
667
+ }
668
+
658
669
  // { revision, type, config[, file] } while the Client hasn't applied the
659
670
  // saved one; `file`: an import's other fields, until it's applied.
660
671
  function pendingConfig(resourceId){
@@ -684,7 +695,7 @@ function createRegistryService(db, { bus, events }){
684
695
  if (!valid){
685
696
  throw Object.assign(new Error(`Can't import this config into ${r.name}: ${errors.join('; ')}`), { status: 400 });
686
697
  }
687
- const desired = section || r.config_desired || r.client_config;
698
+ const desired = section || currentSection(r);
688
699
  if (!desired){
689
700
  throw Object.assign(new Error(`Can't import this config into ${r.name}: it has no "${HW_DEVICES_SECTION}" section, and the Client hasn't reported one`),
690
701
  { status: 400 });
@@ -714,7 +725,7 @@ function createRegistryService(db, { bus, events }){
714
725
  }
715
726
  // Writing top-level fields needs a Client that reports its config file
716
727
  // (the same that Import needs); an older one would drop them.
717
- const desired = r.config_desired || r.client_config;
728
+ const desired = currentSection(r);
718
729
  if (!desired || !r.client_config_file){
719
730
  throw Object.assign(new Error(`${r.name} is too old to take its groups from the dashboard — update its Client first`), { status: 409 });
720
731
  }
@@ -740,7 +751,7 @@ function createRegistryService(db, { bus, events }){
740
751
  }
741
752
  // Re-shared: a file stored from an older Client may still say hw / sw.
742
753
  const base = shareableClientConfigFile(r.client_config_file || { name: r.reported_name || r.name, type: r.type, labels: r.labels }),
743
- section = r.type === 'hw' ? r.config_desired || r.client_config : null;
754
+ section = r.type === 'hw' ? currentSection(r) : null;
744
755
  return { ...base, ...(pendingImport(r) || {}), type: r.type, ...(section ? { [HW_DEVICES_SECTION]: section } : {}) };
745
756
  }
746
757
 
@@ -95,3 +95,48 @@ test('Groups tab: keeps other pending changes; refuses unknown groups and Client
95
95
  const old = register(services, 'lab-old', { configFile: null });
96
96
  assert.throws(() => services.registry.setClientGroups(old, [g.id], { knownGroupIds: [g.id] }), /too old to take its groups/);
97
97
  });
98
+
99
+ test('device tabs show what the Client runs — not an old saved revision; groups/import/export build on it', async (t) => {
100
+ const { base, services, admin } = await start(t),
101
+ reg = services.registry,
102
+ register = (devpath) => reg.registerAuto({
103
+ clientId: 'hw1', name: 'lab-hw-01', type: 'hw', labels: [],
104
+ config: { stlinks: [{ index: 1, devpath }] }, configFile: { name: 'lab-hw-01', type: 'hw' }
105
+ }).resourceId,
106
+ id = register('1.1'),
107
+ devpaths = async () => {
108
+ const html = await (await fetch(`${base}/resources`, { headers: { cookie: admin } })).text(),
109
+ table = html.slice(html.indexOf(`id="cfg-${id}-stlinks"`), html.indexOf(`id="cfg-${id}-uarts"`));
110
+ return [...table.matchAll(/data-f="devpath" value="([^"]*)"/g)].map((m) => m[1]).filter(Boolean); // not the blank add-row template
111
+ };
112
+ assert.deepEqual(await devpaths(), ['1.1']); // as the Client reported it
113
+
114
+ // Saved on the dashboard, not applied yet: the tab shows the saved one.
115
+ reg.setClientConfig(id, { stlinks: [{ index: 1, devpath: '2.2' }] }, { by: 'admin' });
116
+ assert.deepEqual(await devpaths(), ['2.2']);
117
+
118
+ // Applied — and later the file was edited on the host; the Client restarts
119
+ // and reports 3.3. That's what the tab shows now, not the old 2.2.
120
+ services.db.prepare('UPDATE resources SET config_applied_revision = config_revision WHERE id = ?').run(id);
121
+ register('3.3');
122
+ assert.deepEqual(await devpaths(), ['3.3']);
123
+
124
+ // …and what a groups save, an export and an Import without a section send.
125
+ const g = services.groups.create({ name: 'g' });
126
+ reg.setClientGroups(id, [g.id], { by: 'admin', knownGroupIds: [g.id] });
127
+ assert.deepEqual(reg.pendingConfig(id).config, { stlinks: [{ index: 1, devpath: '3.3' }] }); // not 2.2 again
128
+ assert.deepEqual(reg.exportClientConfig(id)['hw-devices'], { stlinks: [{ index: 1, devpath: '3.3' }] });
129
+ });
130
+
131
+ test('ST-Link tab has no Serial field; a serial set in the config file is kept on Save', async (t) => {
132
+ const { base, services, admin } = await start(t),
133
+ id = services.registry.registerAuto({
134
+ clientId: 'hw2', name: 'lab-hw-02', type: 'hw', labels: [],
135
+ config: { stlinks: [{ index: 1, serial: '066DFF48', devpath: '1.2' }] }, configFile: { name: 'lab-hw-02', type: 'hw' }
136
+ }).resourceId,
137
+ html = await (await fetch(`${base}/resources`, { headers: { cookie: admin } })).text(),
138
+ table = html.slice(html.indexOf(`id="cfg-${id}-stlinks"`), html.indexOf(`id="cfg-${id}-uarts"`));
139
+ assert.doesNotMatch(table, /data-f="serial"|>Serial</);
140
+ // Rides along in data-extra, which public/js/client-config.js spreads back into the row.
141
+ assert.match(table, /data-extra="\{&quot;serial&quot;:&quot;066DFF48&quot;\}"/);
142
+ });
package/views/layout.pug CHANGED
@@ -1,3 +1,11 @@
1
+ //- The signed-in user's avatar, or their initials in a circle.
2
+ mixin navAvatar(px)
3
+ if user.avatarPath
4
+ img.rounded-circle.me-2(src=user.avatarPath width=px height=px style="object-fit: cover;")
5
+ else
6
+ span.rounded-circle.bg-secondary.text-white.d-inline-flex.align-items-center.justify-content-center.me-2.flex-shrink-0(style=`width: ${px}px; height: ${px}px; font-size: ${Math.round(px / 2)}px;`)
7
+ = ((user.firstName ? user.firstName[0] : user.username[0]) + (user.lastName ? user.lastName[0] : '')).toUpperCase()
8
+
1
9
  doctype html
2
10
  html(lang="en" data-bs-theme=(user && user.theme) || "auto")
3
11
  head
@@ -30,6 +38,18 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
30
38
  button.navbar-toggler(type="button" data-bs-toggle="collapse" data-bs-target="#nav")
31
39
  span.navbar-toggler-icon
32
40
  #nav.collapse.navbar-collapse.thub-nav
41
+ //- Phones/tablets: who you are comes first — name, role, profile,
42
+ //- sign-out (thub.css .thub-nav-me). Desktop keeps it on the right.
43
+ if user
44
+ .thub-nav-me.d-lg-none
45
+ a.d-flex.align-items-center.text-reset.text-decoration-none.flex-grow-1(href="/profile")
46
+ +navAvatar(36)
47
+ span.d-flex.flex-column.lh-sm
48
+ span.fw-medium= user.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : user.username
49
+ span.small.text-body-secondary= `${roleLabel(user.role)} · profile`
50
+ form(action="/logout" method="post")
51
+ button.btn.btn-outline-danger(type="submit" aria-label="Log out")
52
+ i.bi.bi-box-arrow-right
33
53
  //- Below lg the menu drops down over the page (thub.css .thub-nav,
34
54
  //- closed by public/js/actions.js): pages as a two-column grid with
35
55
  //- icons, then the tools in a row, then the account row.
@@ -112,22 +132,16 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
112
132
  )
113
133
  i.bi.bi-circle-half
114
134
  if user
115
- li.thub-nav-break(aria-hidden="true")
116
- li.nav-item.d-flex.align-items-center.me-3.thub-nav-account
135
+ li.nav-item.d-none.d-lg-flex.align-items-center.me-3
117
136
  a.nav-link.d-flex.align-items-center.p-0(
118
137
  href="/profile"
119
138
  data-bs-toggle="tooltip"
120
139
  data-bs-placement="bottom"
121
140
  data-bs-title=`Profile settings (${roleLabel(user.role)})`
122
141
  )
123
- if user.avatarPath
124
- img.rounded-circle.me-2(src=user.avatarPath width="24" height="24" style="object-fit: cover;")
125
- else
126
- span.rounded-circle.bg-secondary.text-white.d-inline-flex.align-items-center.justify-content-center.me-2(style="width: 24px; height: 24px; font-size: 12px;")
127
- = ((user.firstName ? user.firstName[0] : user.username[0]) + (user.lastName ? user.lastName[0] : '')).toUpperCase()
142
+ +navAvatar(24)
128
143
  span= user.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : user.username
129
- span.small.text-body-secondary.ms-2.d-lg-none= `${roleLabel(user.role)} · profile`
130
- li.nav-item.d-flex.align-items-center
144
+ li.nav-item.d-none.d-lg-flex.align-items-center
131
145
  form(action="/logout" method="post")
132
146
  button.btn.btn-sm.btn-outline-danger(
133
147
  type="submit"
@@ -5,14 +5,13 @@
5
5
  //- what the Client reported at registration.
6
6
 
7
7
  //- One device row: "Device" is a udev index (1-8) or a /dev path; fields the
8
- //- form doesn't show (vendorId/productId/subsystem) ride along in data-extra.
8
+ //- form doesn't show (vendorId/productId/subsystem, an ST-Link serial set in
9
+ //- the config file) ride along in data-extra.
9
10
  mixin deviceRow(kind, d)
10
- - const known = ['index', 'path', 'serial', 'baudRate', 'devpath']
11
+ - const known = ['index', 'path', 'baudRate', 'devpath']
11
12
  - const extra = Object.fromEntries(Object.entries(d || {}).filter(([k]) => !known.includes(k)))
12
13
  tr(data-row=kind data-extra=JSON.stringify(extra))
13
14
  td: input.form-control.form-control-sm.font-monospace(data-f="device" value=d ? (d.path || d.index || '') : '' placeholder="1 or /dev/…" required)
14
- if kind === 'stlinks'
15
- td: input.form-control.form-control-sm.font-monospace(data-f="serial" value=d && d.serial || '' placeholder="(from udev)")
16
15
  if kind === 'uarts'
17
16
  td: input.form-control.form-control-sm(data-f="baudRate" type="number" min="50" value=d && d.baudRate || '' placeholder="115200")
18
17
  td: input.form-control.form-control-sm.font-monospace(data-f="devpath" value=d && d.devpath || '' placeholder="3.3.4.1")
@@ -162,7 +161,13 @@ mixin groupsPane(r, returnTo, groupsById)
162
161
 
163
162
  mixin configPanes(r, returnTo, groupsById)
164
163
  - const p = `cfg-${r.id}`
165
- - const current = r.config_desired || r.client_config
164
+ //- What the Client runs — its config as it reported it at its last start
165
+ //- — unless a saved revision is still on its way to it: then that one, so
166
+ //- it can be checked or changed before it's applied. (Always preferring the
167
+ //- saved revision showed an old one forever, after the file changed on the
168
+ //- host or an Import.)
169
+ - const pendingRevision = r.config_desired && r.config_applied_revision < r.config_revision
170
+ - const current = pendingRevision ? r.config_desired : (r.client_config || r.config_desired)
166
171
  - const canEdit = can.operate
167
172
  if r.type !== 'hw'
168
173
  //- no device tabs
@@ -185,8 +190,8 @@ mixin configPanes(r, returnTo, groupsById)
185
190
  fieldset(disabled=!canEdit)
186
191
  +configPane(p, 'stlinks')
187
192
  +capabilitiesStatus(r)
188
- +deviceTable('stlinks', 'ST-Link probes', current.stlinks || [], ['Device', 'Serial', 'udev devpath'])
189
- .form-text Device: a udev index 1–8 (→ /dev/thub/dut&lt;N&gt;-stlink) or a /dev path. The serial is looked up from the udev symlink when empty.
193
+ +deviceTable('stlinks', 'ST-Link probes', current.stlinks || [], ['Device', 'udev devpath'])
194
+ .form-text Device: a udev index 1–8 (→ /dev/thub/dut&lt;N&gt;-stlink) or a /dev path. Which probe a job uses is up to its #[code --command] (#[code $THUB_DUT_STLINK_&lt;n&gt;]).
190
195
  +saveCapabilities(canEdit)
191
196
  +configPane(p, 'uarts')
192
197
  +capabilitiesStatus(r)