@andrian.yablonskyy/thub-coordinator 1.0.29 → 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.
- package/package.json +1 -1
- package/public/css/thub.css +40 -0
- package/public/js/client-config.js +117 -0
- package/public/js/usb-import.js +169 -0
- package/public/js/usb-parse.js +114 -0
- package/public/js/usb-scan.js +99 -0
- package/src/api/admin.js +22 -0
- package/src/api/resource.js +17 -3
- package/src/db/migrations/018_add_client_config.sql +12 -0
- package/src/db/migrations/019_add_name_override.sql +7 -0
- package/src/db/migrations/020_add_usb_scan.sql +8 -0
- package/src/dev/virtual.js +44 -2
- package/src/services/registry.js +131 -3
- package/src/web/routes.js +68 -0
- package/test/scheduler.test.js +64 -0
- package/test/usb-parse.test.js +73 -0
- package/test/views.test.js +35 -0
- package/views/index.pug +4 -0
- package/views/mixins/client-config.pug +205 -0
- package/views/mixins/resource.pug +88 -11
- package/views/resources/list.pug +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.30",
|
|
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": {
|
package/public/css/thub.css
CHANGED
|
@@ -58,3 +58,43 @@
|
|
|
58
58
|
.resource-capabilities {
|
|
59
59
|
font-size: 0.75rem;
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
/* An overlay inside a modal (e.g. "Import to config"'s review note on the
|
|
63
|
+
resource card, public/js/usb-import.js) — Bootstrap doesn't stack modals. */
|
|
64
|
+
.thub-overlay {
|
|
65
|
+
position: absolute;
|
|
66
|
+
inset: 0;
|
|
67
|
+
z-index: 10;
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
justify-content: center;
|
|
71
|
+
padding: 1rem;
|
|
72
|
+
background: rgba(0, 0, 0, 0.45);
|
|
73
|
+
border-radius: inherit;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.thub-overlay > .card {
|
|
77
|
+
max-width: 34rem;
|
|
78
|
+
max-height: 100%;
|
|
79
|
+
overflow: auto;
|
|
80
|
+
}
|
|
81
|
+
|
|
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
|
|
84
|
+
included, at the modal-lg width. */
|
|
85
|
+
.thub-resource-card .nav-tabs .nav-link {
|
|
86
|
+
padding: 0.45rem 0.65rem;
|
|
87
|
+
}
|
|
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;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.tab-content .tab-pane[data-config-pane].active {
|
|
99
|
+
display: block;
|
|
100
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/public/js/client-config.js
|
|
3
|
+
* @description Dashboard: the resource card's Capabilities form — add/remove device rows, and turn the form into
|
|
4
|
+
* the Client's hw/sw config section (JSON) on Save
|
|
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
|
+
(function (){
|
|
18
|
+
const val = (root, f) => root.querySelector(`[data-f="${f}"]`),
|
|
19
|
+
text = (root, f) => (val(root, f)?.value || '').trim(),
|
|
20
|
+
num = (root, f) => (text(root, f) === '' ? undefined : Number(text(root, f))),
|
|
21
|
+
// Only set keys: an empty field means "not configured", not "".
|
|
22
|
+
compact = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== '')),
|
|
23
|
+
device = (value) => (/^\d+$/.test(value) ? { index: Number(value) } : { path: value });
|
|
24
|
+
|
|
25
|
+
function rows(form, kind){
|
|
26
|
+
return [...form.querySelectorAll(`[data-table="${kind}"] tbody [data-row]`)].map((tr) => {
|
|
27
|
+
if (kind === 'relays'){
|
|
28
|
+
return compact({ channel: num(tr, 'channel'), baseUrl: text(tr, 'baseUrl') });
|
|
29
|
+
}
|
|
30
|
+
let extra = {};
|
|
31
|
+
try {
|
|
32
|
+
extra = JSON.parse(tr.dataset.extra || '{}');
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
extra = {};
|
|
36
|
+
}
|
|
37
|
+
return compact({
|
|
38
|
+
...extra,
|
|
39
|
+
...device(text(tr, 'device')),
|
|
40
|
+
serial: text(tr, 'serial'),
|
|
41
|
+
baudRate: num(tr, 'baudRate'),
|
|
42
|
+
devpath: text(tr, 'devpath')
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sectionFrom(form){
|
|
48
|
+
if (form.dataset.clientConfig === 'sw'){
|
|
49
|
+
return compact({
|
|
50
|
+
image: text(form, 'image'),
|
|
51
|
+
registry: text(form, 'registry'),
|
|
52
|
+
cpus: num(form, 'cpus'),
|
|
53
|
+
memory: text(form, 'memory'),
|
|
54
|
+
cmd: text(form, 'cmd') ? text(form, 'cmd').split(/\s+/) : undefined,
|
|
55
|
+
allowDockerHub: val(form, 'allowDockerHub').checked,
|
|
56
|
+
allowJobImages: val(form, 'allowJobImages').checked
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const power = form.querySelector('[data-power]'),
|
|
60
|
+
method = text(power, 'method');
|
|
61
|
+
return {
|
|
62
|
+
stlinks: rows(form, 'stlinks'),
|
|
63
|
+
uarts: rows(form, 'uarts'),
|
|
64
|
+
usbs: rows(form, 'usbs'),
|
|
65
|
+
relays: rows(form, 'relays'),
|
|
66
|
+
power: method ? compact({ method, hub: text(power, 'hub'), port: num(power, 'port'), baseUrl: text(power, 'baseUrl') }) : null
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ⚠ on each config tab whose fields differ from what was loaded. A pane's
|
|
71
|
+
// state: its field values (rows included) plus the hidden per-row extras.
|
|
72
|
+
function paneState(pane){
|
|
73
|
+
const fields = [...pane.querySelectorAll('input, select, textarea')].filter((i) => i.type !== 'hidden')
|
|
74
|
+
.map((i) => (i.type === 'checkbox' ? i.checked : i.value)),
|
|
75
|
+
extras = [...pane.querySelectorAll('tr[data-row]')].map((tr) => tr.dataset.extra || '');
|
|
76
|
+
return JSON.stringify([fields, extras]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
document.querySelectorAll('[data-config-root]').forEach((root) => {
|
|
80
|
+
const panes = [...root.querySelectorAll('[data-config-pane]')],
|
|
81
|
+
loaded = new Map(panes.map((pane) => [pane, paneState(pane)]));
|
|
82
|
+
function update(){
|
|
83
|
+
for (const pane of panes){
|
|
84
|
+
root.querySelector(`[data-config-tab="${pane.dataset.configPane}"] [data-dirty-icon]`)
|
|
85
|
+
?.classList.toggle('d-none', paneState(pane) === loaded.get(pane));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const type of ['input', 'change', 'thub:changed']){
|
|
89
|
+
root.addEventListener(type, update);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
document.querySelectorAll('form[data-client-config]').forEach((form) => {
|
|
94
|
+
form.addEventListener('click', (e) => {
|
|
95
|
+
const add = e.target.closest('[data-add-row]'),
|
|
96
|
+
remove = e.target.closest('[data-remove-row]');
|
|
97
|
+
if (add){
|
|
98
|
+
const kind = add.dataset.addRow,
|
|
99
|
+
tbody = form.querySelector(`[data-table="${kind}"] tbody`);
|
|
100
|
+
if (tbody.children.length >= 8){
|
|
101
|
+
return; // the Client takes at most 8 of each
|
|
102
|
+
}
|
|
103
|
+
tbody.appendChild(form.querySelector(`template[data-template="${kind}"]`).content.cloneNode(true));
|
|
104
|
+
form.dispatchEvent(new Event('thub:changed', { bubbles: true }));
|
|
105
|
+
}
|
|
106
|
+
else if (remove){
|
|
107
|
+
remove.closest('tr').remove();
|
|
108
|
+
form.dispatchEvent(new Event('thub:changed', { bubbles: true }));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
// Before confirm.js (capture phase), so the JSON is in place whatever
|
|
112
|
+
// it decides; a bad number etc. is reported by the server on Save.
|
|
113
|
+
form.addEventListener('submit', () => {
|
|
114
|
+
form.querySelector('input[name="config"]').value = JSON.stringify(sectionFrom(form));
|
|
115
|
+
}, true);
|
|
116
|
+
});
|
|
117
|
+
})();
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
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
|
|
4
|
+
* sub-tabs' device lists from the last `lsusb -tvv`, opens the first changed one, and asks to verify
|
|
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
|
+
(function (){
|
|
18
|
+
const { parseLsusbTree, classifyDevices } = window.thubUsbParse,
|
|
19
|
+
TITLES = { stlinks: 'ST-Link', uarts: 'UART', usbs: 'DUT USB' },
|
|
20
|
+
MAX_INDEX = 8,
|
|
21
|
+
|
|
22
|
+
el = (tag, attrs = {}, ...children) => {
|
|
23
|
+
const node = document.createElement(tag);
|
|
24
|
+
for (const [k, v]of Object.entries(attrs)){
|
|
25
|
+
if (k === 'class'){
|
|
26
|
+
node.className = v;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
node.setAttribute(k, v);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const c of children){
|
|
33
|
+
node.append(c);
|
|
34
|
+
}
|
|
35
|
+
return node;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Updates one device table from the scan, keeping what's stable:
|
|
39
|
+
// - a row already there for a scanned port (same udev devpath) keeps its
|
|
40
|
+
// Device — so its /dev/thub/dut<N>-… symlink, and anything using it,
|
|
41
|
+
// don't move — and what the scan can't know (serial, baud rate, extras);
|
|
42
|
+
// - a newly found device gets the lowest free udev index;
|
|
43
|
+
// - a row with no devpath (an explicit /dev path set by hand) stays;
|
|
44
|
+
// - a row for a port that's not in the scan any more is removed.
|
|
45
|
+
// Returns { rows: [{ devpath, device, isNew, label, id }], removed: [ports] }.
|
|
46
|
+
function fillTable(form, kind, devices){
|
|
47
|
+
const tbody = form.querySelector(`[data-table="${kind}"] tbody`),
|
|
48
|
+
template = form.querySelector(`template[data-template="${kind}"]`),
|
|
49
|
+
rows = [...tbody.querySelectorAll('tr')].map((tr) => ({
|
|
50
|
+
tr,
|
|
51
|
+
devpath: tr.querySelector('[data-f="devpath"]')?.value.trim() || '',
|
|
52
|
+
device: tr.querySelector('[data-f="device"]')?.value.trim() || ''
|
|
53
|
+
})),
|
|
54
|
+
byPort = new Map(rows.filter((r) => r.devpath).map((r) => [r.devpath, r])),
|
|
55
|
+
manual = rows.filter((r) => !r.devpath),
|
|
56
|
+
scanned = new Set(devices.map((d) => d.devpath)),
|
|
57
|
+
used = new Set([...manual, ...rows.filter((r) => scanned.has(r.devpath))].map((r) => r.device)),
|
|
58
|
+
nextIndex = () => {
|
|
59
|
+
for (let i = 1; i <= MAX_INDEX; i++){
|
|
60
|
+
if (!used.has(String(i))){
|
|
61
|
+
used.add(String(i));
|
|
62
|
+
return String(i);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return '';
|
|
66
|
+
},
|
|
67
|
+
result = [],
|
|
68
|
+
|
|
69
|
+
updated = devices.map((d) => {
|
|
70
|
+
const old = byPort.get(d.devpath),
|
|
71
|
+
tr = old ? old.tr : template.content.firstElementChild.cloneNode(true);
|
|
72
|
+
let extra = {};
|
|
73
|
+
try {
|
|
74
|
+
extra = JSON.parse(tr.dataset.extra || '{}');
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
extra = {};
|
|
78
|
+
}
|
|
79
|
+
delete extra.vendorId;
|
|
80
|
+
delete extra.productId;
|
|
81
|
+
if (d.vendorId){
|
|
82
|
+
Object.assign(extra, { vendorId: d.vendorId, productId: d.productId });
|
|
83
|
+
}
|
|
84
|
+
tr.dataset.extra = JSON.stringify(extra);
|
|
85
|
+
const device = old ? old.device : nextIndex();
|
|
86
|
+
tr.querySelector('[data-f="device"]').value = device;
|
|
87
|
+
tr.querySelector('[data-f="devpath"]').value = d.devpath;
|
|
88
|
+
result.push({ devpath: d.devpath, device, isNew: !old, label: d.label, id: d.id });
|
|
89
|
+
return tr;
|
|
90
|
+
});
|
|
91
|
+
tbody.replaceChildren(...updated, ...manual.map((r) => r.tr));
|
|
92
|
+
return { rows: result, removed: rows.filter((r) => r.devpath && !scanned.has(r.devpath)).map((r) => r.devpath) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function overlay(modal, { total, skipped, changes }){
|
|
96
|
+
const content = modal.querySelector('.modal-content'),
|
|
97
|
+
body = el('div', { class: 'card-body small' });
|
|
98
|
+
body.append(el('p', {}, total
|
|
99
|
+
? 'The ST-Link, UART and DUT USB tabs were updated from the last lsusb -tvv scan (⚠ marks what changed). Nothing is saved yet: ' +
|
|
100
|
+
'check them, set what the scan can\'t know (e.g. UART baud rates), then press Save capabilities.'
|
|
101
|
+
: 'The scan has no device a HW Client uses (ST-Link, USB-serial adapter, STM32 COM port), so nothing was changed.'));
|
|
102
|
+
for (const [kind, change]of Object.entries(changes)){
|
|
103
|
+
if (!change.rows.length && !change.removed.length){
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
body.append(el('strong', {}, `${TITLES[kind]} (${change.rows.length})`));
|
|
107
|
+
body.append(el('ul', { class: 'mb-2' },
|
|
108
|
+
...change.rows.map((row) => el('li', {}, `Device ${row.device || '?'} ← port `, el('code', {}, row.devpath), ` ${row.label} (${row.id}) `,
|
|
109
|
+
el('span', { class: `badge ${row.isNew ? 'text-bg-primary' : 'text-bg-secondary'}` }, row.isNew ? 'new' : 'already configured'))),
|
|
110
|
+
...change.removed.map((port) => el('li', { class: 'text-warning-emphasis' }, 'Removed: port ', el('code', {}, port), ' — not in the scan'))));
|
|
111
|
+
}
|
|
112
|
+
if (changes.stlinks?.rows.length){
|
|
113
|
+
body.append(el('p', { class: 'text-body-secondary mb-2' },
|
|
114
|
+
'ST-Link serials aren\'t in lsusb -tvv: the Client looks them up from each probe\'s udev symlink.'));
|
|
115
|
+
}
|
|
116
|
+
if (skipped.length){
|
|
117
|
+
body.append(el('details', {}, el('summary', {}, `Not imported (${skipped.length})`),
|
|
118
|
+
el('ul', { class: 'mb-0' }, ...skipped.map((d) =>
|
|
119
|
+
el('li', {}, `${d.name || d.id} (${d.id}${d.devpath ? `, port ${d.devpath}` : ''}) — ${d.reason}`)))));
|
|
120
|
+
}
|
|
121
|
+
const close = el('button', { type: 'button', class: 'btn btn-primary btn-sm' }, total ? 'Review configuration' : 'OK'),
|
|
122
|
+
card = el('div', { class: 'card shadow', role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': 'thub-import-title' },
|
|
123
|
+
el('div', { class: 'card-header fw-semibold', id: 'thub-import-title' }, total ? 'Verify the imported configuration' : 'Nothing to import'),
|
|
124
|
+
body,
|
|
125
|
+
el('div', { class: 'card-footer text-end' }, close)),
|
|
126
|
+
layer = el('div', { class: 'thub-overlay' }, card);
|
|
127
|
+
content.style.position = 'relative';
|
|
128
|
+
content.appendChild(layer);
|
|
129
|
+
// Esc closes the note, not the whole resource card behind it.
|
|
130
|
+
const onKey = (e) => {
|
|
131
|
+
if (e.key === 'Escape'){
|
|
132
|
+
e.stopPropagation();
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
dismiss();
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
dismiss = () => {
|
|
138
|
+
layer.remove();
|
|
139
|
+
modal.removeEventListener('keydown', onKey, true);
|
|
140
|
+
};
|
|
141
|
+
modal.addEventListener('keydown', onKey, true);
|
|
142
|
+
close.addEventListener('click', dismiss);
|
|
143
|
+
modal.addEventListener('hidden.bs.modal', dismiss, { once: true });
|
|
144
|
+
close.focus();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
document.querySelectorAll('[data-usb-import]').forEach((button) => {
|
|
148
|
+
button.addEventListener('click', () => {
|
|
149
|
+
const pane = button.closest('[data-usb-scan]'),
|
|
150
|
+
modal = button.closest('.modal'),
|
|
151
|
+
form = modal.querySelector('form[data-client-config="hw"]');
|
|
152
|
+
if (!form){
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const { lists, skipped } = classifyDevices(parseLsusbTree(pane.querySelector('[data-usb-output]').textContent)),
|
|
156
|
+
total = Object.values(lists).reduce((n, l) => n + l.length, 0),
|
|
157
|
+
changes = total ? Object.fromEntries(Object.entries(lists).map(([kind, devices]) => [kind, fillTable(form, kind, devices)])) : {};
|
|
158
|
+
if (total){
|
|
159
|
+
form.dispatchEvent(new Event('thub:changed', { bubbles: true })); // ⚠ on what changed
|
|
160
|
+
// Open the first config tab the import changed (else ST-Link).
|
|
161
|
+
const root = form.closest('[data-config-root]'),
|
|
162
|
+
changed = ['stlinks', 'uarts', 'usbs'].find((k) =>
|
|
163
|
+
!root.querySelector(`[data-config-tab="${k}"] [data-dirty-icon]`).classList.contains('d-none')) || 'stlinks';
|
|
164
|
+
bootstrap.Tab.getOrCreateInstance(root.querySelector(`[data-config-tab="${changed}"]`)).show();
|
|
165
|
+
}
|
|
166
|
+
overlay(modal, { total, skipped, changes });
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
})();
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/public/js/usb-parse.js
|
|
3
|
+
* @description Parses `lsusb -tvv` output into USB devices and sorts the ones a HW Client can use into its config
|
|
4
|
+
* lists (ST-Links, UARTs, DUT USB) — for "Import to config" on the resource card; also loadable in Node
|
|
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
|
+
(function (root){
|
|
18
|
+
// vendor:product -> the Client config list it belongs in. The first id of
|
|
19
|
+
// each list is the Client's own default (udev.js), which needs no
|
|
20
|
+
// vendorId/productId on the row.
|
|
21
|
+
const KNOWN = {
|
|
22
|
+
'0483:3748': { kind: 'stlinks', label: 'ST-Link/V2' },
|
|
23
|
+
'0483:374b': { kind: 'stlinks', label: 'ST-Link/V2-1' },
|
|
24
|
+
'0483:374e': { kind: 'stlinks', label: 'STLINK-V3' },
|
|
25
|
+
'0483:374f': { kind: 'stlinks', label: 'STLINK-V3' },
|
|
26
|
+
'0483:3753': { kind: 'stlinks', label: 'STLINK-V3' },
|
|
27
|
+
'0483:3754': { kind: 'stlinks', label: 'STLINK-V3' },
|
|
28
|
+
'0403:6001': { kind: 'uarts', label: 'FTDI FT232R' },
|
|
29
|
+
'0403:6015': { kind: 'uarts', label: 'FTDI FT-X' },
|
|
30
|
+
'0403:6010': { kind: 'uarts', label: 'FTDI FT2232' },
|
|
31
|
+
'0403:6014': { kind: 'uarts', label: 'FTDI FT232H' },
|
|
32
|
+
'10c4:ea60': { kind: 'uarts', label: 'Silicon Labs CP210x' },
|
|
33
|
+
'1a86:7523': { kind: 'uarts', label: 'CH340' },
|
|
34
|
+
'067b:2303': { kind: 'uarts', label: 'Prolific PL2303' },
|
|
35
|
+
'0483:5740': { kind: 'usbs', label: 'STM32 Virtual COM Port (DUT USB)' }
|
|
36
|
+
},
|
|
37
|
+
DEFAULT_IDS = { stlinks: '0483:3748', uarts: '0403:6001', usbs: '0483:5740' },
|
|
38
|
+
MAX_PER_KIND = 8;
|
|
39
|
+
|
|
40
|
+
// Devices from `lsusb -tvv`: one per /sys path (interfaces of the same
|
|
41
|
+
// device repeat it), root hubs included, each as
|
|
42
|
+
// { id, vendorId, productId, name, driver, sysPath, devpath }. `devpath`
|
|
43
|
+
// is what udev calls ATTR{devpath}: the port path after "<bus>-".
|
|
44
|
+
function parseLsusbTree(text){
|
|
45
|
+
const devices = new Map();
|
|
46
|
+
let current = null;
|
|
47
|
+
for (const line of String(text || '').split('\n')){
|
|
48
|
+
if (/^\s*(\/:|\|__)\s/.test(line)){
|
|
49
|
+
current = { driver: (/Driver=([^,]*)/.exec(line) || [])[1] || '' };
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!current){
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const id = /^\s*ID ([0-9a-fA-F]{4}):([0-9a-fA-F]{4})\s*(.*)$/.exec(line);
|
|
56
|
+
if (id){
|
|
57
|
+
Object.assign(current, { vendorId: id[1].toLowerCase(), productId: id[2].toLowerCase(), name: id[3].trim() });
|
|
58
|
+
current.id = `${current.vendorId}:${current.productId}`;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const sys = /\/sys\/bus\/usb\/devices\/([^\s]+)/.exec(line);
|
|
62
|
+
if (sys && current.id){
|
|
63
|
+
const sysPath = sys[1],
|
|
64
|
+
port = /^\d+-([\d.]+)$/.exec(sysPath);
|
|
65
|
+
if (!devices.has(sysPath)){
|
|
66
|
+
devices.set(sysPath, { ...current, sysPath, devpath: port ? port[1] : null });
|
|
67
|
+
}
|
|
68
|
+
current = null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return [...devices.values()];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// { lists: { stlinks, uarts, usbs }, skipped } — the devices a HW Client
|
|
75
|
+
// can use, in port order, each { devpath, id, label, name, driver,
|
|
76
|
+
// vendorId?, productId? } (ids only when not the list's default), at most
|
|
77
|
+
// 8 per list (the rest skipped too).
|
|
78
|
+
function classifyDevices(devices){
|
|
79
|
+
const lists = { stlinks: [], uarts: [], usbs: [] },
|
|
80
|
+
skipped = [],
|
|
81
|
+
byPort = [...devices].sort((a, b) => (a.devpath || '').localeCompare(b.devpath || '', undefined, { numeric: true }));
|
|
82
|
+
for (const d of byPort){
|
|
83
|
+
const known = KNOWN[d.id];
|
|
84
|
+
if (!d.devpath){
|
|
85
|
+
skipped.push({ ...d, reason: 'root hub' });
|
|
86
|
+
}
|
|
87
|
+
else if (!known){
|
|
88
|
+
skipped.push({ ...d, reason: /hub/i.test(d.name) || d.driver.startsWith('hub') ? 'hub' : 'not a device the Client uses' });
|
|
89
|
+
}
|
|
90
|
+
else if (lists[known.kind].length >= MAX_PER_KIND){
|
|
91
|
+
skipped.push({ ...d, reason: `more than ${MAX_PER_KIND} ${known.kind}` });
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
lists[known.kind].push({
|
|
95
|
+
devpath: d.devpath,
|
|
96
|
+
id: d.id,
|
|
97
|
+
label: known.label,
|
|
98
|
+
name: d.name,
|
|
99
|
+
driver: d.driver,
|
|
100
|
+
...(d.id === DEFAULT_IDS[known.kind] ? {} : { vendorId: d.vendorId, productId: d.productId })
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { lists, skipped };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const api = { parseLsusbTree, classifyDevices, KNOWN_USB_DEVICES: KNOWN };
|
|
108
|
+
if (typeof module !== 'undefined' && module.exports){
|
|
109
|
+
module.exports = api;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
root.thubUsbParse = api;
|
|
113
|
+
}
|
|
114
|
+
})(typeof window !== 'undefined' ? window : globalThis);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
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`,
|
|
4
|
+
* then polls until its answer arrives and shows it in place
|
|
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
|
+
(function (){
|
|
18
|
+
const POLL_MS = 2000,
|
|
19
|
+
// The Client answers on its next heartbeat (10 s by default); no answer
|
|
20
|
+
// by then means it's too old to know scan-usb, or went away.
|
|
21
|
+
GIVE_UP_MS = 60_000;
|
|
22
|
+
|
|
23
|
+
document.querySelectorAll('[data-usb-scan]').forEach((pane) => {
|
|
24
|
+
const url = pane.dataset.usbScan,
|
|
25
|
+
button = pane.querySelector('[data-usb-refresh]'),
|
|
26
|
+
status = pane.querySelector('[data-usb-status]'),
|
|
27
|
+
errorBox = pane.querySelector('[data-usb-error]'),
|
|
28
|
+
output = pane.querySelector('[data-usb-output]');
|
|
29
|
+
let timer = null;
|
|
30
|
+
|
|
31
|
+
function show(view){
|
|
32
|
+
if (view.scan){
|
|
33
|
+
status.textContent = `lsusb -tvv at ${view.scan.atText}`;
|
|
34
|
+
errorBox.textContent = view.scan.error || '';
|
|
35
|
+
errorBox.classList.toggle('d-none', !view.scan.error);
|
|
36
|
+
output.textContent = view.scan.output || '';
|
|
37
|
+
output.classList.toggle('d-none', !view.scan.output);
|
|
38
|
+
const importButton = pane.querySelector('[data-usb-import]');
|
|
39
|
+
if (importButton){
|
|
40
|
+
importButton.disabled = !view.scan.output;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function waiting(on){
|
|
46
|
+
if (button){
|
|
47
|
+
button.disabled = on;
|
|
48
|
+
button.querySelector('i').className = on ? 'spinner-border spinner-border-sm me-1' : 'bi bi-arrow-clockwise me-1';
|
|
49
|
+
}
|
|
50
|
+
if (on){
|
|
51
|
+
status.textContent = 'Waiting for the Client to run lsusb -tvv (its next heartbeat)…';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function poll(requestedAt){
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
timer = setTimeout(async () => {
|
|
58
|
+
try {
|
|
59
|
+
const view = await (await fetch(url, { cache: 'no-store', headers: { Accept: 'application/json' } })).json();
|
|
60
|
+
if (!view.pending && view.scan && view.scan.at >= requestedAt){
|
|
61
|
+
waiting(false);
|
|
62
|
+
return show(view);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// network blip: keep polling
|
|
67
|
+
}
|
|
68
|
+
if (Date.now() - Date.parse(requestedAt) > GIVE_UP_MS){
|
|
69
|
+
waiting(false);
|
|
70
|
+
status.textContent = 'No answer from the Client — it may be too old to scan USB devices, or offline. Try again later.';
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
poll(requestedAt);
|
|
74
|
+
}, POLL_MS);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
button?.addEventListener('click', async () => {
|
|
78
|
+
waiting(true);
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(url, { method: 'POST', headers: { Accept: 'application/json' } }),
|
|
81
|
+
view = await res.json();
|
|
82
|
+
if (!res.ok){
|
|
83
|
+
throw new Error(view.error || `HTTP ${res.status}`);
|
|
84
|
+
}
|
|
85
|
+
poll(view.requestedAt);
|
|
86
|
+
}
|
|
87
|
+
catch (err){
|
|
88
|
+
waiting(false);
|
|
89
|
+
status.textContent = `Couldn't ask the Client: ${err.message}`;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// A request still pending when the page loaded (e.g. after a reload).
|
|
94
|
+
if (pane.dataset.pending && pane.dataset.requestedAt){
|
|
95
|
+
waiting(true);
|
|
96
|
+
poll(pane.dataset.requestedAt);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
})();
|
package/src/api/admin.js
CHANGED
|
@@ -51,6 +51,28 @@ function createAdminRouter({ services }){
|
|
|
51
51
|
res.status(204).end();
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
// Rename a Client: { name } ("" = back to its own name).
|
|
55
|
+
router.post('/resources/:id/rename', (req, res, next) => {
|
|
56
|
+
try {
|
|
57
|
+
const r = services.registry.rename(req.params.id, req.body?.name);
|
|
58
|
+
res.json({ id: r.id, name: r.name, nameOverride: r.name_override, clientName: r.reported_name });
|
|
59
|
+
}
|
|
60
|
+
catch (err){
|
|
61
|
+
next(err);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// A Client's capabilities (its hw or sw config section): { config }.
|
|
66
|
+
router.post('/resources/:id/config', (req, res, next) => {
|
|
67
|
+
try {
|
|
68
|
+
const r = services.registry.setClientConfig(req.params.id, req.body?.config);
|
|
69
|
+
res.json({ id: r.id, revision: r.config_revision, applied: r.config_applied_revision });
|
|
70
|
+
}
|
|
71
|
+
catch (err){
|
|
72
|
+
next(err);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
54
76
|
// Reboot a Client's host now (its running job is canceled first).
|
|
55
77
|
router.post('/resources/:id/reboot', (req, res, next) => {
|
|
56
78
|
try {
|
package/src/api/resource.js
CHANGED
|
@@ -51,7 +51,7 @@ function createResourceRouter({ services, config }){
|
|
|
51
51
|
// the resource by name (see registry.registerAuto).
|
|
52
52
|
router.post('/resources/register', requireJoinKey(config), (req, res, next) => {
|
|
53
53
|
try {
|
|
54
|
-
const { clientId, name, type, labels, groups, hostInfo, capabilities } = req.body;
|
|
54
|
+
const { clientId, name, type, labels, groups, hostInfo, capabilities, config: clientConfig } = req.body;
|
|
55
55
|
if (!clientId){
|
|
56
56
|
return res.status(400).json({ error: 'clientId is required (persisted in the Client\'s .client-id file)' });
|
|
57
57
|
}
|
|
@@ -66,6 +66,7 @@ function createResourceRouter({ services, config }){
|
|
|
66
66
|
groups: groups || [],
|
|
67
67
|
hostInfo,
|
|
68
68
|
capabilities,
|
|
69
|
+
config: clientConfig,
|
|
69
70
|
remoteAddr: req.ip,
|
|
70
71
|
clientVersion: appVersion(req, 'client')
|
|
71
72
|
});
|
|
@@ -82,9 +83,15 @@ function createResourceRouter({ services, config }){
|
|
|
82
83
|
// matching happens (see the same note in api/agent.js).
|
|
83
84
|
const auth = requireRole('resource');
|
|
84
85
|
|
|
86
|
+
// The Client's `lsusb` output for a scan-usb command (README §10).
|
|
87
|
+
router.post('/resources/:id/usb-scan', auth, requireOwnResource, (req, res) => {
|
|
88
|
+
const stored = services.registry.storeUsbScan(req.params.id, req.body || {});
|
|
89
|
+
res.status(stored ? 204 : 409).end();
|
|
90
|
+
});
|
|
91
|
+
|
|
85
92
|
router.post('/resources/:id/heartbeat', auth, requireOwnResource, (req, res, next) => {
|
|
86
93
|
try {
|
|
87
|
-
const { state, activeJobId, localLock, metrics, addresses, hostUptimeSec, activity, rebootSchedule } = req.body;
|
|
94
|
+
const { state, activeJobId, localLock, metrics, addresses, hostUptimeSec, activity, rebootSchedule, configRevision, configError } = req.body;
|
|
88
95
|
services.registry.heartbeat(req.params.id, {
|
|
89
96
|
state,
|
|
90
97
|
activeJobId,
|
|
@@ -95,7 +102,9 @@ function createResourceRouter({ services, config }){
|
|
|
95
102
|
clientVersion: appVersion(req, 'client'),
|
|
96
103
|
hostUptimeSec,
|
|
97
104
|
activity,
|
|
98
|
-
rebootSchedule
|
|
105
|
+
rebootSchedule,
|
|
106
|
+
configRevision,
|
|
107
|
+
configError
|
|
99
108
|
});
|
|
100
109
|
const commands = services.commands.drain(req.params.id),
|
|
101
110
|
// Repeated on every heartbeat until the Client reports the new
|
|
@@ -109,6 +118,11 @@ function createResourceRouter({ services, config }){
|
|
|
109
118
|
if (reboot){
|
|
110
119
|
commands.push({ command: 'set-reboot-schedule', cron: reboot.cron });
|
|
111
120
|
}
|
|
121
|
+
// Capabilities saved on the Config tab — likewise until applied.
|
|
122
|
+
const pendingConfig = services.registry.pendingConfig(req.params.id);
|
|
123
|
+
if (pendingConfig){
|
|
124
|
+
commands.push({ command: 'set-config', ...pendingConfig });
|
|
125
|
+
}
|
|
112
126
|
// Removal pending (jobs.removeResource): once the Client reports its
|
|
113
127
|
// job stopped, finish it. This response still goes out; the next
|
|
114
128
|
// heartbeat gets 401, the token being gone with the resource.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Client capabilities edited from the dashboard (README §10, resource card
|
|
2
|
+
-- Config tab). `client_config` is the editable part of the Client's config
|
|
3
|
+
-- (its hw or sw section, secrets excluded) as it last reported it at
|
|
4
|
+
-- registration; `config_desired` is what an admin saved, at
|
|
5
|
+
-- `config_revision`. While the Client reports an older applied revision
|
|
6
|
+
-- (`config_applied_revision`), every heartbeat reply carries a set-config
|
|
7
|
+
-- command. `config_error` is why it last refused one (NULL: none).
|
|
8
|
+
ALTER TABLE resources ADD COLUMN client_config TEXT;
|
|
9
|
+
ALTER TABLE resources ADD COLUMN config_desired TEXT;
|
|
10
|
+
ALTER TABLE resources ADD COLUMN config_revision INTEGER NOT NULL DEFAULT 0;
|
|
11
|
+
ALTER TABLE resources ADD COLUMN config_applied_revision INTEGER NOT NULL DEFAULT 0;
|
|
12
|
+
ALTER TABLE resources ADD COLUMN config_error TEXT;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- Renaming a Client from the dashboard (README §10): `name_override` is the
|
|
2
|
+
-- name an admin gave it, which registrations keep using (a Client presents
|
|
3
|
+
-- its own name — config `name` or systemd instance — on every start, which
|
|
4
|
+
-- would otherwise undo the rename). `reported_name` is that own name, shown
|
|
5
|
+
-- as a hint and restored by clearing the override. NULL: no override.
|
|
6
|
+
ALTER TABLE resources ADD COLUMN name_override TEXT;
|
|
7
|
+
ALTER TABLE resources ADD COLUMN reported_name TEXT;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- "Connected USB devices" (resource card tab, README §10): the Client runs
|
|
2
|
+
-- `lsusb` only when an admin presses Refresh — a scan-usb command with its
|
|
3
|
+
-- next heartbeat — and posts the output back. `usb_scan` is the last result
|
|
4
|
+
-- (JSON {output, error, at}); `usb_scan_requested_at`/`_request_id` the
|
|
5
|
+
-- pending request, so the tab can tell a fresh result from an old one.
|
|
6
|
+
ALTER TABLE resources ADD COLUMN usb_scan TEXT;
|
|
7
|
+
ALTER TABLE resources ADD COLUMN usb_scan_requested_at TEXT;
|
|
8
|
+
ALTER TABLE resources ADD COLUMN usb_scan_request_id TEXT;
|