@andrian.yablonskyy/thub-coordinator 1.0.28 → 1.0.30

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.
@@ -0,0 +1,205 @@
1
+ //- A Client's capabilities — the hw or sw section of its config — edited on
2
+ //- the resource card's Config tab (README §10). public/js/client-config.js
3
+ //- turns the form into JSON on Save; the Client applies it on its next
4
+ //- heartbeat, then restarts once idle. Starts from what was last saved, else
5
+ //- what the Client reported at registration.
6
+
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.
9
+ mixin deviceRow(kind, d)
10
+ - const known = ['index', 'path', 'serial', 'baudRate', 'devpath']
11
+ - const extra = Object.fromEntries(Object.entries(d || {}).filter(([k]) => !known.includes(k)))
12
+ tr(data-row=kind data-extra=JSON.stringify(extra))
13
+ 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
+ if kind === 'uarts'
17
+ td: input.form-control.form-control-sm(data-f="baudRate" type="number" min="50" value=d && d.baudRate || '' placeholder="115200")
18
+ td: input.form-control.form-control-sm.font-monospace(data-f="devpath" value=d && d.devpath || '' placeholder="3.3.4.1")
19
+ td.text-end
20
+ button.btn.btn-sm.btn-outline-secondary(type="button" data-remove-row aria-label="Remove row")
21
+ i.bi.bi-x-lg
22
+
23
+ mixin relayRow(rl)
24
+ tr(data-row="relays")
25
+ td: input.form-control.form-control-sm(data-f="channel" type="number" min="0" max="7" value=rl ? rl.channel : '' required)
26
+ td: input.form-control.form-control-sm(data-f="baseUrl" type="url" value=rl && rl.baseUrl || '' placeholder="http://localhost:3000")
27
+ td.text-end
28
+ button.btn.btn-sm.btn-outline-secondary(type="button" data-remove-row aria-label="Remove row")
29
+ i.bi.bi-x-lg
30
+
31
+ mixin deviceTable(kind, title, rows, columns)
32
+ .d-flex.justify-content-between.align-items-center.mb-1
33
+ strong.small= title
34
+ button.btn.btn-sm.btn-outline-primary(type="button" data-add-row=kind)
35
+ i.bi.bi-plus-lg.me-1
36
+ | Add
37
+ table.table.table-sm.align-middle.mb-0(data-table=kind)
38
+ thead
39
+ tr
40
+ each c in columns
41
+ th.small.text-body-secondary.fw-normal= c
42
+ th
43
+ tbody
44
+ each d in rows
45
+ if kind === 'relays'
46
+ +relayRow(d)
47
+ else
48
+ +deviceRow(kind, d)
49
+ template(data-template=kind)
50
+ if kind === 'relays'
51
+ +relayRow(null)
52
+ else
53
+ +deviceRow(kind, null)
54
+
55
+ //- The ⚠ shown on a config (sub-)tab while its data differs from what was
56
+ //- loaded — public/js/client-config.js toggles it.
57
+ mixin dirtyIcon()
58
+ i.bi.bi-exclamation-triangle-fill.text-warning.ms-1.d-none(
59
+ data-dirty-icon
60
+ role="img"
61
+ aria-label="changed, not saved"
62
+ title="Changed — not saved yet"
63
+ )
64
+
65
+ mixin configTab(p, key, label)
66
+ li.nav-item(role="presentation")
67
+ button.nav-link(
68
+ id=`${p}-${key}-tab`
69
+ type="button"
70
+ role="tab"
71
+ data-bs-toggle="tab"
72
+ data-bs-target=`#${p}-${key}`
73
+ aria-controls=`${p}-${key}`
74
+ aria-selected="false"
75
+ data-config-tab=key
76
+ )
77
+ = label
78
+ +dirtyIcon()
79
+
80
+ mixin configPane(p, key)
81
+ .tab-pane.fade(id=`${p}-${key}` role="tabpanel" aria-labelledby=`${p}-${key}-tab` tabindex="0" data-config-pane=key)
82
+ block
83
+
84
+ mixin saveCapabilities(canEdit)
85
+ if canEdit
86
+ .d-flex.justify-content-end.mt-3
87
+ button.btn.btn-sm.btn-primary(type="submit") Save capabilities
88
+
89
+ //- Where the saved capabilities stand, at the top of each capability tab.
90
+ mixin capabilitiesStatus(r)
91
+ - const pending = r.config_desired && r.config_applied_revision < r.config_revision
92
+ if pending || (r.config_revision && !r.config_error) || r.config_error
93
+ .d-flex.justify-content-end.gap-1.mb-2
94
+ if pending
95
+ span.badge.text-bg-warning(
96
+ data-bs-toggle="tooltip"
97
+ data-bs-title=`Capabilities revision ${r.config_revision} is saved; the Client applies it on its next heartbeat, then restarts once idle`
98
+ ) not applied yet
99
+ else if r.config_revision && !r.config_error
100
+ span.badge.text-bg-success(data-bs-toggle="tooltip" data-bs-title=`The Client runs capabilities revision ${r.config_applied_revision}`) applied
101
+ if r.config_error
102
+ span.badge.text-bg-danger(data-bs-toggle="tooltip" data-bs-title=r.config_error) refused by the Client
103
+
104
+ //- The Client's config, as tabs of the resource card next to Details:
105
+ //- HW — ST-Link, UART, DUT USB, Power (relays + power control); SW —
106
+ //- Emulator; both — Reboot (the schedule, its own Save). The capability tabs
107
+ //- share one form and one "Save capabilities"; a tab with unsaved changes
108
+ //- shows ⚠ (public/js/client-config.js).
109
+ mixin configTabItems(r)
110
+ - const p = `cfg-${r.id}`
111
+ - const tabs = r.type === 'hw' ? [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'DUT USB'], ['power', 'Power']] : [['emulator', 'Emulator']]
112
+ each t in tabs
113
+ +configTab(p, t[0], t[1])
114
+ +configTab(p, 'reboot', 'Reboot')
115
+
116
+ mixin configPanes(r, returnTo)
117
+ - const p = `cfg-${r.id}`
118
+ - const current = r.config_desired || r.client_config
119
+ - const canEdit = user.role === 'admin'
120
+ if !current
121
+ each t in (r.type === 'hw' ? [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'DUT USB'], ['power', 'Power']] : [['emulator', 'Emulator']])
122
+ +configPane(p, t[0])
123
+ p.small.text-body-secondary.mb-0 This Client is too old to report its config — update it to edit its capabilities here.
124
+ else
125
+ form(
126
+ method="post"
127
+ action=`/resources/${r.id}/config`
128
+ data-client-config=r.type
129
+ data-confirm=`Apply these capabilities to ${r.name}? The Client writes them to its config file on its next heartbeat and restarts once it has no job running.`
130
+ data-confirm-title="Save Client capabilities?"
131
+ data-confirm-ok="Save"
132
+ data-confirm-tone="primary"
133
+ )
134
+ input(type="hidden" name="returnTo" value=returnTo)
135
+ input(type="hidden" name="config")
136
+ fieldset(disabled=!canEdit)
137
+ if r.type === 'sw'
138
+ +configPane(p, 'emulator')
139
+ +capabilitiesStatus(r)
140
+ .row.g-2
141
+ .col-sm-6
142
+ label.form-label.small.mb-0 Image
143
+ input.form-control.form-control-sm.font-monospace(data-f="image" value=current.image || '' placeholder="dut-emulator:2026.08")
144
+ .col-sm-6
145
+ label.form-label.small.mb-0 Registry
146
+ input.form-control.form-control-sm.font-monospace(data-f="registry" value=current.registry || '' placeholder="registry.lab:5000")
147
+ .col-sm-3
148
+ label.form-label.small.mb-0 CPUs
149
+ input.form-control.form-control-sm(data-f="cpus" type="number" step="0.1" min="0.1" value=current.cpus != null ? current.cpus : '' placeholder="2")
150
+ .col-sm-3
151
+ label.form-label.small.mb-0 Memory
152
+ input.form-control.form-control-sm(data-f="memory" value=current.memory || '' placeholder="2g")
153
+ .col-sm-6
154
+ label.form-label.small.mb-0 Container command
155
+ input.form-control.form-control-sm.font-monospace(
156
+ data-f="cmd"
157
+ value=(current.cmd || []).join(' ')
158
+ placeholder="--firmware /downloads/app.bin"
159
+ title="For the image above (not a job's own); space-separated"
160
+ )
161
+ .col-12.d-flex.gap-4.mt-2
162
+ .form-check
163
+ input.form-check-input(type="checkbox" data-f="allowDockerHub" id=`cc-${r.id}-hub` checked=current.allowDockerHub === true)
164
+ label.form-check-label.small(for=`cc-${r.id}-hub`) Pull from Docker Hub
165
+ .form-check
166
+ input.form-check-input(type="checkbox" data-f="allowJobImages" id=`cc-${r.id}-jobimg` checked=current.allowJobImages === true)
167
+ label.form-check-label.small(for=`cc-${r.id}-jobimg`) Run jobs' own images (--docker-image)
168
+ +saveCapabilities(canEdit)
169
+ else
170
+ - const power = current.power || {}
171
+ +configPane(p, 'stlinks')
172
+ +capabilitiesStatus(r)
173
+ +deviceTable('stlinks', 'ST-Link probes', current.stlinks || [], ['Device', 'Serial', 'udev devpath'])
174
+ .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.
175
+ +saveCapabilities(canEdit)
176
+ +configPane(p, 'uarts')
177
+ +capabilitiesStatus(r)
178
+ +deviceTable('uarts', 'UARTs', current.uarts || [], ['Device', 'Baud rate', 'udev devpath'])
179
+ .form-text Device: a udev index 1–8 (→ /dev/thub/dut&lt;N&gt;-uart) or a /dev path. Baud rate: 115200 when empty.
180
+ +saveCapabilities(canEdit)
181
+ +configPane(p, 'usbs')
182
+ +capabilitiesStatus(r)
183
+ +deviceTable('usbs', 'DUT USB devices', current.usbs || [], ['Device', 'udev devpath'])
184
+ .form-text Device: a udev index 1–8 (→ /dev/thub/dut&lt;N&gt;-usb) or a /dev path. udev devpath: the USB port it's plugged into.
185
+ +saveCapabilities(canEdit)
186
+ +configPane(p, 'power')
187
+ +capabilitiesStatus(r)
188
+ +deviceTable('relays', 'Relay channels', current.relays || [], ['Channel (0-7)', 'Board URL'])
189
+ strong.small.d-block.mt-3.mb-1 Power control
190
+ .row.g-2(data-power)
191
+ .col-sm-3
192
+ select.form-select.form-select-sm(data-f="method" aria-label="Power method")
193
+ option(value="" selected=!power.method) none
194
+ option(value="uhubctl" selected=power.method === 'uhubctl') uhubctl
195
+ option(value="relay" selected=power.method === 'relay') relay
196
+ .col-sm-3
197
+ input.form-control.form-control-sm(data-f="hub" value=power.hub || '' placeholder="hub (uhubctl)")
198
+ .col-sm-2
199
+ input.form-control.form-control-sm(data-f="port" type="number" min="1" value=power.port != null ? power.port : '' placeholder="port")
200
+ .col-sm-4
201
+ input.form-control.form-control-sm(data-f="baseUrl" type="url" value=power.baseUrl || '' placeholder="relay URL")
202
+ +saveCapabilities(canEdit)
203
+ +configPane(p, 'reboot')
204
+ dl.row.mb-0
205
+ +rebootScheduleRow(r, returnTo)
@@ -1,3 +1,5 @@
1
+ include client-config
2
+
1
3
  //- Resource actions and the resource info card (modal), shared by the
2
4
  //- Overview and Resources pages. `returnTo` is the page the action forms
3
5
  //- redirect back to.
@@ -307,9 +309,11 @@ mixin resourceCard(r, groupsById, returnTo)
307
309
  .modal-header
308
310
  h5.modal-title(id=`resource-card-${r.id}-title`)= r.name
309
311
  button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
310
- .modal-body.pt-2
311
- //- Details: what the Client is and reports. Config: settings sent
312
- //- to it (today the scheduled reboot); read-only for viewers.
312
+ //- data-config-root: where public/js/client-config.js tracks unsaved
313
+ //- changes (⚠ on the config tabs).
314
+ .modal-body.pt-2(data-config-root)
315
+ //- Details: what the Client is and reports. Then its config tabs
316
+ //- (client-config.pug; read-only for viewers), then its USB devices.
313
317
  ul.nav.nav-tabs.mb-3(role="tablist")
314
318
  li.nav-item(role="presentation")
315
319
  button.nav-link.active(
@@ -321,27 +325,68 @@ mixin resourceCard(r, groupsById, returnTo)
321
325
  aria-controls=`rc-${r.id}-details`
322
326
  aria-selected="true"
323
327
  ) Details
328
+ +configTabItems(r)
324
329
  li.nav-item(role="presentation")
325
330
  button.nav-link(
326
- id=`rc-${r.id}-config-tab`
331
+ id=`rc-${r.id}-usb-tab`
327
332
  type="button"
328
333
  role="tab"
329
334
  data-bs-toggle="tab"
330
- data-bs-target=`#rc-${r.id}-config`
331
- aria-controls=`rc-${r.id}-config`
335
+ data-bs-target=`#rc-${r.id}-usb`
336
+ aria-controls=`rc-${r.id}-usb`
332
337
  aria-selected="false"
333
- ) Config
338
+ ) Connected USB devices
339
+
334
340
  .tab-content
335
341
  .tab-pane.fade.show.active(id=`rc-${r.id}-details` role="tabpanel" aria-labelledby=`rc-${r.id}-details-tab` tabindex="0")
336
342
  +resourceDetails(r, groupsById, returnTo, local, external)
337
- .tab-pane.fade(id=`rc-${r.id}-config` role="tabpanel" aria-labelledby=`rc-${r.id}-config-tab` tabindex="0")
338
- dl.row.mb-0
339
- +rebootScheduleRow(r, returnTo)
340
- if user.role === 'admin'
341
- .modal-footer
342
- +rebootButton(r, returnTo)
343
+ +configPanes(r, returnTo)
344
+ .tab-pane.fade(id=`rc-${r.id}-usb` role="tabpanel" aria-labelledby=`rc-${r.id}-usb-tab` tabindex="0")
345
+ +usbScanPane(r)
346
+
347
+ //- "Connected USB devices" (resource card tab): the Client's `lsusb`, run
348
+ //- only on Refresh (admins; public/js/usb-scan.js fetches and fills it in).
349
+ mixin usbScanPane(r)
350
+ - const offline = r.status === 'OUT_OF_SERVICE' || !r.last_heartbeat_at
351
+ - const scan = r.usb_scan
352
+ //- Import to config (public/js/usb-import.js): HW Clients that report
353
+ //- their config only; enabled once there's a scan to import from.
354
+ - const importWhy = r.type !== 'hw' ? 'Only HW Clients have USB device lists to import into' : !(r.config_desired || r.client_config) ? 'This Client is too old to report its config — update it first' : null
355
+ div(data-usb-scan=`/resources/${r.id}/usb-scan` data-pending=r.usb_scan_request_id ? '1' : null data-requested-at=r.usb_scan_requested_at)
356
+ .d-flex.align-items-center.gap-2.mb-2
357
+ if user.role === 'admin'
358
+ if offline
359
+ span.d-inline-block(tabindex="0" data-bs-toggle="tooltip" data-bs-title="Offline — a Client can only be scanned while it's connected")
360
+ button.btn.btn-sm.btn-outline-primary(type="button" disabled style="pointer-events: none;")
361
+ i.bi.bi-arrow-clockwise.me-1
362
+ | Refresh
363
+ else
364
+ button.btn.btn-sm.btn-outline-primary(type="button" data-usb-refresh)
365
+ i.bi.bi-arrow-clockwise.me-1
366
+ | Refresh
367
+ if importWhy
368
+ span.d-inline-block(tabindex="0" data-bs-toggle="tooltip" data-bs-title=importWhy)
369
+ button.btn.btn-sm.btn-outline-secondary(type="button" disabled style="pointer-events: none;")
370
+ i.bi.bi-box-arrow-in-down.me-1
371
+ | Import to config
372
+ else
373
+ button.btn.btn-sm.btn-outline-secondary(
374
+ type="button"
375
+ data-usb-import
376
+ disabled=!(scan && scan.output)
377
+ title="Fill the ST-Link, UART and DUT USB tabs from this scan, for you to review and save"
378
+ )
379
+ i.bi.bi-box-arrow-in-down.me-1
380
+ | Import to config
381
+ span.small.text-body-secondary(data-usb-status)
382
+ if scan
383
+ = `lsusb -tvv at ${fmtDate(scan.at)}`
384
+ else
385
+ | Not scanned yet#{user.role === 'admin' ? ' — press Refresh to run lsusb -tvv on the Client' : ''}.
386
+ .alert.alert-danger.py-2.small(data-usb-error class=scan && scan.error ? null : 'd-none')= scan && scan.error || ''
387
+ pre.bg-body-tertiary.p-2.rounded.small.mb-0(data-usb-output style="max-height: 360px; overflow: auto;" class=scan && scan.output ? null : 'd-none')= scan && scan.output || ''
343
388
 
344
- //- Reboot the Client's host now (card footer): confirmed first — naming the
389
+ //- Reboot the Client's host now (card's Actions row, icon only): confirmed first — naming the
345
390
  //- job that gets canceled, if one is running — then sent with the Client's
346
391
  //- next heartbeat. Disabled while it's offline or has no reboot helper.
347
392
  mixin rebootButton(r, returnTo)
@@ -349,12 +394,11 @@ mixin rebootButton(r, returnTo)
349
394
  - const unsupported = r.capabilities && r.capabilities.rebootSupported === false
350
395
  - const why = offline ? 'Offline — a Client can only be rebooted while connected' : unsupported ? "This host has no reboot helper — reinstall the Client as root" : null
351
396
  if why
352
- span.d-inline-block(tabindex="0" data-bs-toggle="tooltip" data-bs-title=why)
353
- button.btn.btn-outline-danger(type="button" disabled style="pointer-events: none;")
354
- i.bi.bi-bootstrap-reboot.me-1
355
- | Reboot
397
+ span.d-inline-block.ms-1(tabindex="0" data-bs-toggle="tooltip" data-bs-title=`Reboot unavailable: ${why}`)
398
+ button.btn.btn-sm.btn-outline-danger(type="button" disabled style="pointer-events: none;" aria-label="Reboot")
399
+ i.bi.bi-power
356
400
  else
357
- form(
401
+ form.d-inline.ms-1(
358
402
  method="post"
359
403
  action=`/resources/${r.id}/reboot`
360
404
  data-confirm=r.activeJob
@@ -365,15 +409,48 @@ mixin rebootButton(r, returnTo)
365
409
  data-confirm-dismiss="Don't reboot"
366
410
  )
367
411
  input(type="hidden" name="returnTo" value=returnTo)
368
- button.btn.btn-outline-danger(type="submit")
369
- i.bi.bi-bootstrap-reboot.me-1
370
- | Reboot
412
+ button.btn.btn-sm.btn-outline-danger(
413
+ type="submit"
414
+ aria-label=`Reboot ${r.name}'s host`
415
+ data-bs-toggle="tooltip"
416
+ data-bs-title=r.activeJob ? `Reboot the host (cancels running job ${r.activeJob.id})` : 'Reboot the host'
417
+ )
418
+ i.bi.bi-power
371
419
 
372
420
  //- The resource card's Details tab.
373
421
  mixin resourceDetails(r, groupsById, returnTo, local, external)
374
422
  dl.row.mb-0
375
423
  dt.col-sm-4 Name
376
- dd.col-sm-8= r.name
424
+ dd.col-sm-8
425
+ span= r.name
426
+ if r.name_override && r.reported_name
427
+ span.text-body-secondary.small.ms-2(
428
+ data-bs-toggle="tooltip"
429
+ data-bs-title="Renamed on the dashboard; this is the name the Client itself reports"
430
+ )= `(Client: ${r.reported_name})`
431
+ if user.role === 'admin'
432
+ button.btn.btn-sm.btn-link.p-0.ms-2.align-baseline(
433
+ type="button"
434
+ data-bs-toggle="collapse"
435
+ data-bs-target=`#rename-${r.id}`
436
+ aria-expanded="false"
437
+ aria-controls=`rename-${r.id}`
438
+ aria-label=`Rename ${r.name}`
439
+ )
440
+ i.bi.bi-pencil(data-bs-toggle="tooltip" data-bs-title="Rename")
441
+ .collapse(id=`rename-${r.id}`)
442
+ form.d-flex.gap-1.mt-1(method="post" action=`/resources/${r.id}/rename`)
443
+ input(type="hidden" name="returnTo" value=returnTo)
444
+ input.form-control.form-control-sm(
445
+ name="name"
446
+ value=r.name
447
+ maxlength="64"
448
+ pattern="[A-Za-z0-9._@+\-]*"
449
+ placeholder=r.reported_name || ''
450
+ aria-label=`New name for ${r.name}`
451
+ )
452
+ button.btn.btn-sm.btn-primary(type="submit") Rename
453
+ .form-text Kept across the Client's restarts. Empty = back to the Client's own name#{r.reported_name ? ` (${r.reported_name})` : ''}.
377
454
  dt.col-sm-4 Type
378
455
  dd.col-sm-8.text-uppercase= r.type
379
456
  dt.col-sm-4 Status
@@ -422,3 +499,4 @@ mixin resourceDetails(r, groupsById, returnTo, local, external)
422
499
  dt.col-sm-4 Actions
423
500
  dd.col-sm-8.mb-0
424
501
  +resourceActions(r, returnTo)
502
+ +rebootButton(r, returnTo)
@@ -60,3 +60,7 @@ block content
60
60
  block scripts
61
61
  script(src="/js/resource-card.js")
62
62
  script(src="/js/remove-resource.js")
63
+ script(src="/js/client-config.js")
64
+ script(src="/js/usb-scan.js")
65
+ script(src="/js/usb-parse.js")
66
+ script(src="/js/usb-import.js")