@andrian.yablonskyy/thub-coordinator 1.0.29 → 1.0.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/public/css/thub.css +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/server.js +13 -0
- package/src/services/jobs.js +6 -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/layout.pug +6 -1
- package/views/mixins/client-config.pug +205 -0
- package/views/mixins/resource.pug +88 -11
- package/views/resources/list.pug +4 -0
|
@@ -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;
|
package/src/dev/virtual.js
CHANGED
|
@@ -66,6 +66,27 @@ const DEV_AGENT_NAME = 'virtual-agent',
|
|
|
66
66
|
[0.4, 'ci', 'sw', 'PASSED', 201, null],
|
|
67
67
|
[0.1, 'cli', 'sw', 'PASSED', 73, 'alice']
|
|
68
68
|
],
|
|
69
|
+
// What `lsusb -tvv` prints on a lab host with a DUT slot plugged in.
|
|
70
|
+
VIRTUAL_LSUSB = [
|
|
71
|
+
'/: Bus 001.Port 001: Dev 001, Class=root_hub, Driver=xhci_hcd/12p, 480M',
|
|
72
|
+
' ID 1d6b:0002 Linux Foundation 2.0 root hub',
|
|
73
|
+
' /sys/bus/usb/devices/usb1 /dev/bus/usb/001/001',
|
|
74
|
+
' |__ Port 003: Dev 002, If 0, Class=Hub, Driver=hub/4p, 480M',
|
|
75
|
+
' ID 05e3:0610 Genesys Logic, Inc. Hub',
|
|
76
|
+
' /sys/bus/usb/devices/1-3 /dev/bus/usb/001/002',
|
|
77
|
+
' |__ Port 001: Dev 004, If 0, Class=Vendor Specific Class, Driver=, 12M',
|
|
78
|
+
' ID 0483:3748 STMicroelectronics ST-LINK/V2',
|
|
79
|
+
' /sys/bus/usb/devices/1-3.1 /dev/bus/usb/001/004',
|
|
80
|
+
' |__ Port 002: Dev 005, If 0, Class=Vendor Specific Class, Driver=ftdi_sio, 12M',
|
|
81
|
+
' ID 0403:6001 Future Technology Devices International, Ltd FT232 Serial (UART) IC',
|
|
82
|
+
' /sys/bus/usb/devices/1-3.2 /dev/bus/usb/001/005',
|
|
83
|
+
' |__ Port 003: Dev 006, If 0, Class=Communications, Driver=cdc_acm, 12M',
|
|
84
|
+
' ID 0483:5740 STMicroelectronics Virtual COM Port',
|
|
85
|
+
' /sys/bus/usb/devices/1-3.3 /dev/bus/usb/001/006',
|
|
86
|
+
'/: Bus 002.Port 001: Dev 001, Class=root_hub, Driver=xhci_hcd/4p, 5000M',
|
|
87
|
+
' ID 1d6b:0003 Linux Foundation 3.0 root hub',
|
|
88
|
+
' /sys/bus/usb/devices/usb2 /dev/bus/usb/002/001'
|
|
89
|
+
].join('\n'),
|
|
69
90
|
LOG_SCRIPT = [
|
|
70
91
|
['runner', 'downloading app.bin (virtual)'],
|
|
71
92
|
['runner', 'cloning yourorg/firmware-tests at main (virtual)'],
|
|
@@ -167,6 +188,22 @@ function startVirtualClients(services, config){
|
|
|
167
188
|
labels: def.labels,
|
|
168
189
|
hostInfo: { hostname: `${def.name}.virtual`, platform: 'linux', addresses: [{ iface: 'eth0', address: def.address, family: 'IPv4' }], timeZone: 'UTC' },
|
|
169
190
|
capabilities: { ...def.capabilities, rebootSupported: false },
|
|
191
|
+
// Its "config file": the hw/sw section, as a real Client reports it.
|
|
192
|
+
config: def.type === 'sw'
|
|
193
|
+
? {
|
|
194
|
+
image: def.capabilities.sw.image,
|
|
195
|
+
allowDockerHub: def.capabilities.sw.allowDockerHub,
|
|
196
|
+
allowJobImages: def.capabilities.sw.allowJobImages,
|
|
197
|
+
cpus: def.capabilities.sw.cpus,
|
|
198
|
+
memory: def.capabilities.sw.memory
|
|
199
|
+
}
|
|
200
|
+
: {
|
|
201
|
+
stlinks: [{ index: 1, serial: '066DFF485457725187092834', devpath: '3.3.4.3.1' }],
|
|
202
|
+
uarts: [{ index: 1, baudRate: 115200, devpath: '3.3.3.2' }],
|
|
203
|
+
usbs: [{ index: 1 }],
|
|
204
|
+
relays: [{ channel: 0, baseUrl: 'http://localhost:3000' }],
|
|
205
|
+
power: { method: 'relay' }
|
|
206
|
+
},
|
|
170
207
|
remoteAddr: '127.0.0.1',
|
|
171
208
|
clientVersion: version
|
|
172
209
|
});
|
|
@@ -186,9 +223,14 @@ function startVirtualClients(services, config){
|
|
|
186
223
|
clientVersion: version,
|
|
187
224
|
hostUptimeSec: Math.round((Date.now() - bootedAt) / 1000),
|
|
188
225
|
activity: { state: c.activity.state, jobId: c.activity.jobId, durationSec: Math.round((Date.now() - c.activity.since) / 1000) },
|
|
189
|
-
rebootSchedule: registry.get(resourceId)?.reboot_schedule || null // "applies" whatever is saved
|
|
226
|
+
rebootSchedule: registry.get(resourceId)?.reboot_schedule || null, // "applies" whatever is saved
|
|
227
|
+
configRevision: registry.get(resourceId)?.config_revision || 0 // ...and any saved config
|
|
190
228
|
});
|
|
191
|
-
services.commands.drain(resourceId)
|
|
229
|
+
for (const command of services.commands.drain(resourceId)){
|
|
230
|
+
if (command.command === 'scan-usb'){ // a plausible lsusb for the dashboard
|
|
231
|
+
registry.storeUsbScan(resourceId, { requestId: command.requestId, output: VIRTUAL_LSUSB, error: null });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
192
234
|
}
|
|
193
235
|
|
|
194
236
|
const beat = () => {
|
package/src/server.js
CHANGED
|
@@ -90,6 +90,14 @@ function buildServices(config){
|
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
function commonPath(){
|
|
94
|
+
return path.dirname(require.resolve('@andrian.yablonskyy/thub-common/package.json'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function commonVersion(){
|
|
98
|
+
return require('@andrian.yablonskyy/thub-common/package.json').version;
|
|
99
|
+
}
|
|
100
|
+
|
|
93
101
|
function createApp(config, services){
|
|
94
102
|
const app = express();
|
|
95
103
|
app.set('view engine', 'pug');
|
|
@@ -98,6 +106,10 @@ function createApp(config, services){
|
|
|
98
106
|
app.locals.services = services;
|
|
99
107
|
// Shown in every dashboard page's footer (layout.pug).
|
|
100
108
|
app.locals.coordinatorVersion = require('../package.json').version;
|
|
109
|
+
// The thub-common this process actually loaded (it validates job specs):
|
|
110
|
+
// installs keep the one they got, so it can lag behind — shown on the
|
|
111
|
+
// dashboard and logged at startup to make that visible.
|
|
112
|
+
app.locals.commonVersion = commonVersion();
|
|
101
113
|
|
|
102
114
|
app.use(express.json({ limit: '2mb' }));
|
|
103
115
|
app.use(express.urlencoded({ extended: true }));
|
|
@@ -162,6 +174,7 @@ function start(configPath){
|
|
|
162
174
|
const server = app.listen(config.port, config.host, () => {
|
|
163
175
|
console.log(`Config: ${config.configPath || '(built-in defaults)'}; data: ${config.dataDir}`);
|
|
164
176
|
console.log(`TestHub Coordinator listening on http://${config.host}:${config.port}`);
|
|
177
|
+
console.log(`Coordinator v${require('../package.json').version}, thub-common v${commonVersion()} (${commonPath()})`);
|
|
165
178
|
});
|
|
166
179
|
|
|
167
180
|
return { app, server, services, config };
|
package/src/services/jobs.js
CHANGED
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
16
|
const { validateJobSpec, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
|
|
17
|
-
{ paginate } = require('./list-prefs')
|
|
17
|
+
{ paginate } = require('./list-prefs'),
|
|
18
|
+
// Named in spec errors: a field the Agent sends but this thub-common
|
|
19
|
+
// doesn't know means the Coordinator needs an update.
|
|
20
|
+
VERSIONS = `Coordinator v${require('../../package.json').version}, ` +
|
|
21
|
+
`thub-common v${require('@andrian.yablonskyy/thub-common/package.json').version}`;
|
|
18
22
|
|
|
19
23
|
function rowToJob(row){
|
|
20
24
|
if (!row){
|
|
@@ -131,7 +135,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
131
135
|
rawSpec?.priority === undefined ? { ...rawSpec, priority: DEFAULT_PRIORITY[source] } : rawSpec
|
|
132
136
|
);
|
|
133
137
|
if (!valid){
|
|
134
|
-
throw Object.assign(new Error(`Invalid job spec: ${errors.join('; ')}`), { status: 400 });
|
|
138
|
+
throw Object.assign(new Error(`Invalid job spec (${VERSIONS}): ${errors.join('; ')}`), { status: 400 });
|
|
135
139
|
}
|
|
136
140
|
spec.source = source;
|
|
137
141
|
|
package/src/services/registry.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
16
|
const { v4: uuid } = require('uuid'),
|
|
17
|
-
{ RESOURCE_STATES, BUSY_SOURCES, ACTIVE_JOB_STATES, compareVersions, parseCron } = require('@andrian.yablonskyy/thub-common'),
|
|
17
|
+
{ RESOURCE_STATES, BUSY_SOURCES, ACTIVE_JOB_STATES, compareVersions, parseCron, validateClientConfig } = require('@andrian.yablonskyy/thub-common'),
|
|
18
18
|
{ generateToken, hashToken } = require('./tokens');
|
|
19
19
|
|
|
20
20
|
function rowToResource(row){
|
|
@@ -27,6 +27,9 @@ function rowToResource(row){
|
|
|
27
27
|
group_ids: JSON.parse(row.group_ids || '[]'),
|
|
28
28
|
host_info: row.host_info ? JSON.parse(row.host_info) : null,
|
|
29
29
|
capabilities: row.capabilities ? JSON.parse(row.capabilities) : null,
|
|
30
|
+
client_config: row.client_config ? JSON.parse(row.client_config) : null,
|
|
31
|
+
config_desired: row.config_desired ? JSON.parse(row.config_desired) : null,
|
|
32
|
+
usb_scan: row.usb_scan ? JSON.parse(row.usb_scan) : null,
|
|
30
33
|
activity: row.activity ? JSON.parse(row.activity) : null
|
|
31
34
|
};
|
|
32
35
|
}
|
|
@@ -187,7 +190,25 @@ function createRegistryService(db, { bus, events }){
|
|
|
187
190
|
// this registration is allowed to reclaim it. Anything else (IDLE, BUSY,
|
|
188
191
|
// MAINTENANCE, REGISTERED-but-just-created) means a process might still
|
|
189
192
|
// be actively using that identity, so it stays a hard conflict.
|
|
190
|
-
|
|
193
|
+
// Plus `config`: the editable part of the Client's config (its hw or sw
|
|
194
|
+
// section, secrets excluded) — the starting point for the resource card's
|
|
195
|
+
// Config tab. Stored as reported, if it's a plain object of sane size.
|
|
196
|
+
// A dashboard rename (name_override) wins over the name the Client
|
|
197
|
+
// presents; that one is kept as reported_name.
|
|
198
|
+
function registerAuto({ config, ...registration }){
|
|
199
|
+
const known = registration.clientId ? getByClientId(registration.clientId) : null,
|
|
200
|
+
result = registerAutoInner({ ...registration, name: known?.name_override || registration.name });
|
|
201
|
+
db.prepare('UPDATE resources SET reported_name = ? WHERE id = ?').run(registration.name, result.resourceId);
|
|
202
|
+
if (config && typeof config === 'object' && !Array.isArray(config)){
|
|
203
|
+
const json = JSON.stringify(config);
|
|
204
|
+
if (json.length <= 64 * 1024){
|
|
205
|
+
db.prepare('UPDATE resources SET client_config = ? WHERE id = ?').run(json, result.resourceId);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function registerAutoInner({ clientId, name, type, labels = [], groups = [], hostInfo, capabilities, remoteAddr, clientVersion = null }){
|
|
191
212
|
const resourceToken = generateToken('res'),
|
|
192
213
|
remote = normalizeRemoteAddr(remoteAddr);
|
|
193
214
|
hostInfo = {
|
|
@@ -307,7 +328,10 @@ function createRegistryService(db, { bus, events }){
|
|
|
307
328
|
// Reconcile status from a heartbeat's self-reported state (§5.1).
|
|
308
329
|
function heartbeat(
|
|
309
330
|
resourceId,
|
|
310
|
-
{
|
|
331
|
+
{
|
|
332
|
+
state, activeJobId, localLock, metrics, addresses, remoteAddr, clientVersion = null, hostUptimeSec, activity, rebootSchedule,
|
|
333
|
+
configRevision, configError
|
|
334
|
+
} = {}
|
|
311
335
|
){
|
|
312
336
|
const resource = get(resourceId);
|
|
313
337
|
if (!resource){
|
|
@@ -340,6 +364,16 @@ function createRegistryService(db, { bus, events }){
|
|
|
340
364
|
busyReason = null;
|
|
341
365
|
}
|
|
342
366
|
|
|
367
|
+
// The dashboard config revision the Client has applied, and why it last
|
|
368
|
+
// refused one — older Clients report neither.
|
|
369
|
+
if (Number.isInteger(configRevision)){
|
|
370
|
+
db.prepare('UPDATE resources SET config_applied_revision = ?, config_error = ? WHERE id = ?').run(
|
|
371
|
+
configRevision,
|
|
372
|
+
typeof configError === 'string' && configError ? configError.slice(0, 1000) : null,
|
|
373
|
+
resourceId
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
343
377
|
// The reboot schedule the Client applies (null: none); older Clients
|
|
344
378
|
// don't report one at all — then leave what's stored.
|
|
345
379
|
if (rebootSchedule !== undefined){
|
|
@@ -539,6 +573,95 @@ function createRegistryService(db, { bus, events }){
|
|
|
539
573
|
return db.prepare('SELECT * FROM resources WHERE remove_requested_at IS NOT NULL').all().map(rowToResource);
|
|
540
574
|
}
|
|
541
575
|
|
|
576
|
+
// "Connected USB devices" tab: Refresh asks the Client to run `lsusb` on
|
|
577
|
+
// its next heartbeat (a scan-usb command, delivered by the caller's bus
|
|
578
|
+
// via commands). Only while it's connected — it would otherwise run
|
|
579
|
+
// whenever it next reconnects.
|
|
580
|
+
function requestUsbScan(resourceId, { by } = {}){
|
|
581
|
+
const r = get(resourceId);
|
|
582
|
+
if (!r){
|
|
583
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
584
|
+
}
|
|
585
|
+
if (r.status === RESOURCE_STATES.OUT_OF_SERVICE || !r.last_heartbeat_at){
|
|
586
|
+
throw Object.assign(new Error(`${r.name} is offline — it can only be scanned while it's connected`), { status: 409 });
|
|
587
|
+
}
|
|
588
|
+
const requestId = uuid(),
|
|
589
|
+
requestedAt = new Date().toISOString();
|
|
590
|
+
db.prepare('UPDATE resources SET usb_scan_requested_at = ?, usb_scan_request_id = ? WHERE id = ?').run(requestedAt, requestId, resourceId);
|
|
591
|
+
bus.emit('command', { resourceId, command: 'scan-usb', requestId });
|
|
592
|
+
events.record('resource', resourceId, 'resource.usb_scan_requested', { by });
|
|
593
|
+
return { requestId, requestedAt };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// The Client's answer (POST /resources/:id/usb-scan): kept only for the
|
|
597
|
+
// request still pending, capped in size.
|
|
598
|
+
function storeUsbScan(resourceId, { requestId, output, error }){
|
|
599
|
+
const r = get(resourceId);
|
|
600
|
+
if (!r || !requestId || requestId !== r.usb_scan_request_id){
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
const scan = {
|
|
604
|
+
output: typeof output === 'string' ? output.slice(0, 64 * 1024) : '',
|
|
605
|
+
error: typeof error === 'string' && error ? error.slice(0, 1000) : null,
|
|
606
|
+
at: new Date().toISOString()
|
|
607
|
+
};
|
|
608
|
+
db.prepare('UPDATE resources SET usb_scan = ?, usb_scan_request_id = NULL WHERE id = ?').run(JSON.stringify(scan), resourceId);
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Rename from the dashboard (resource card). The new name must be free;
|
|
613
|
+
// it's kept across the Client's re-registrations (name_override). An
|
|
614
|
+
// empty name — or the Client's own — goes back to the Client's own name.
|
|
615
|
+
// Jobs reference the resource id, so their history follows the rename.
|
|
616
|
+
function rename(resourceId, newName, { by } = {}){
|
|
617
|
+
const r = get(resourceId);
|
|
618
|
+
if (!r){
|
|
619
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
620
|
+
}
|
|
621
|
+
const wanted = typeof newName === 'string' ? newName.trim() : '',
|
|
622
|
+
name = wanted || r.reported_name || r.name;
|
|
623
|
+
if (!/^[A-Za-z0-9._@+-]{1,64}$/.test(name)){
|
|
624
|
+
throw Object.assign(new Error('A Client name is 1-64 letters, digits or . _ @ + - (no spaces)'), { status: 400 });
|
|
625
|
+
}
|
|
626
|
+
const owner = getByName(name);
|
|
627
|
+
if (owner && owner.id !== resourceId){
|
|
628
|
+
throw Object.assign(new Error(`The name "${name}" is already used by another Client (${owner.status})`), { status: 409 });
|
|
629
|
+
}
|
|
630
|
+
const override = name === r.reported_name ? null : name;
|
|
631
|
+
db.prepare('UPDATE resources SET name = ?, name_override = ? WHERE id = ?').run(name, override, resourceId);
|
|
632
|
+
if (name !== r.name){
|
|
633
|
+
events.record('resource', resourceId, 'resource.renamed', { from: r.name, to: name, by });
|
|
634
|
+
}
|
|
635
|
+
return get(resourceId);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Client capabilities edited on the resource card's Config tab: validated
|
|
639
|
+
// like the Client itself will, stored at the next revision, and delivered
|
|
640
|
+
// with its next heartbeat (pendingConfig).
|
|
641
|
+
function setClientConfig(resourceId, config, { by } = {}){
|
|
642
|
+
const r = get(resourceId);
|
|
643
|
+
if (!r){
|
|
644
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
645
|
+
}
|
|
646
|
+
const { valid, errors } = validateClientConfig(r.type, config);
|
|
647
|
+
if (!valid){
|
|
648
|
+
throw Object.assign(new Error(`Invalid ${r.type.toUpperCase()} config: ${errors.join('; ')}`), { status: 400 });
|
|
649
|
+
}
|
|
650
|
+
db.prepare('UPDATE resources SET config_desired = ?, config_revision = config_revision + 1, config_error = NULL WHERE id = ?')
|
|
651
|
+
.run(JSON.stringify(config), resourceId);
|
|
652
|
+
events.record('resource', resourceId, 'resource.config_saved', { by, revision: get(resourceId).config_revision });
|
|
653
|
+
return get(resourceId);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// { revision, type, config } while the Client hasn't applied the saved one.
|
|
657
|
+
function pendingConfig(resourceId){
|
|
658
|
+
const r = get(resourceId);
|
|
659
|
+
if (!r || !r.config_desired || r.config_applied_revision >= r.config_revision){
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
return { revision: r.config_revision, type: r.type, config: r.config_desired };
|
|
663
|
+
}
|
|
664
|
+
|
|
542
665
|
// Scheduled host reboot (resource card): a cron expression, validated
|
|
543
666
|
// here; '' or null clears it. Delivered on the Client's next heartbeat.
|
|
544
667
|
function setRebootSchedule(resourceId, cron, { by } = {}){
|
|
@@ -603,6 +726,11 @@ function createRegistryService(db, { bus, events }){
|
|
|
603
726
|
remove,
|
|
604
727
|
setRebootSchedule,
|
|
605
728
|
pendingRebootSchedule,
|
|
729
|
+
setClientConfig,
|
|
730
|
+
pendingConfig,
|
|
731
|
+
rename,
|
|
732
|
+
requestUsbScan,
|
|
733
|
+
storeUsbScan,
|
|
606
734
|
requestRemoval,
|
|
607
735
|
pendingRemovals,
|
|
608
736
|
findIdleCandidates,
|
package/src/web/routes.js
CHANGED
|
@@ -425,6 +425,74 @@ function createWebRouter({ services, config }){
|
|
|
425
425
|
res.redirect(returnTo(req, '/resources'));
|
|
426
426
|
});
|
|
427
427
|
|
|
428
|
+
// "Connected USB devices" tab (public/js/usb-scan.js): Refresh queues a
|
|
429
|
+
// scan-usb for the Client's next heartbeat; the tab then polls the GET
|
|
430
|
+
// until the answer is in. JSON both ways — the modal stays open.
|
|
431
|
+
function usbScanView(req, r){
|
|
432
|
+
const tz = req.session.user.timezone || 'UTC',
|
|
433
|
+
scan = r.usb_scan;
|
|
434
|
+
return {
|
|
435
|
+
pending: Boolean(r.usb_scan_request_id),
|
|
436
|
+
requestedAt: r.usb_scan_requested_at,
|
|
437
|
+
scan: scan ? { ...scan, atText: formatDateTime(scan.at, { timeZone: tz }) } : null
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
router.post('/resources/:id/usb-scan', requireAdminRole, (req, res) => {
|
|
442
|
+
try {
|
|
443
|
+
services.registry.requestUsbScan(req.params.id, { by: req.session.user.username });
|
|
444
|
+
res.json(usbScanView(req, services.registry.get(req.params.id)));
|
|
445
|
+
}
|
|
446
|
+
catch (err){
|
|
447
|
+
res.status(err.status || 500).json({ error: err.message });
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
router.get('/resources/:id/usb-scan', (req, res) => {
|
|
452
|
+
const r = services.registry.get(req.params.id);
|
|
453
|
+
if (!r){
|
|
454
|
+
return res.status(404).json({ error: 'Unknown resource' });
|
|
455
|
+
}
|
|
456
|
+
res.set('Cache-Control', 'no-store').json(usbScanView(req, r));
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Rename (resource card, Name row); empty = back to the Client's own name.
|
|
460
|
+
router.post('/resources/:id/rename', requireAdminRole, (req, res) => {
|
|
461
|
+
try {
|
|
462
|
+
const before = services.registry.get(req.params.id)?.name,
|
|
463
|
+
r = services.registry.rename(req.params.id, req.body.name, { by: req.session.user.username });
|
|
464
|
+
if (r.name !== before){
|
|
465
|
+
flash(req, 'success', `Renamed "${before}" to "${r.name}".` + (r.name_override ? '' : '\nThat\'s the Client\'s own name again.'));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
catch (err){
|
|
469
|
+
flash(req, 'danger', err.message);
|
|
470
|
+
}
|
|
471
|
+
res.redirect(returnTo(req, '/resources'));
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// Capabilities (resource card, Config tab): public/js/client-config.js
|
|
475
|
+
// sends the edited hw/sw section as JSON in `config`. Applied by the Client
|
|
476
|
+
// on its next heartbeat, then it restarts once idle.
|
|
477
|
+
router.post('/resources/:id/config', requireAdminRole, (req, res) => {
|
|
478
|
+
try {
|
|
479
|
+
let config;
|
|
480
|
+
try {
|
|
481
|
+
config = JSON.parse(req.body.config || '');
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
throw Object.assign(new Error('The config form sent no valid JSON — reload the page and try again'), { status: 400 });
|
|
485
|
+
}
|
|
486
|
+
const r = services.registry.setClientConfig(req.params.id, config, { by: req.session.user.username });
|
|
487
|
+
flash(req, 'success', `Capabilities of ${r.name} saved (revision ${r.config_revision}).\n` +
|
|
488
|
+
'The Client applies them on its next heartbeat, then restarts once it has no job running.');
|
|
489
|
+
}
|
|
490
|
+
catch (err){
|
|
491
|
+
flash(req, 'danger', err.message);
|
|
492
|
+
}
|
|
493
|
+
res.redirect(returnTo(req, '/resources'));
|
|
494
|
+
});
|
|
495
|
+
|
|
428
496
|
// Scheduled host reboot (resource card): save a cron expression, or clear
|
|
429
497
|
// it; the Client applies it on its next heartbeat.
|
|
430
498
|
router.post('/resources/:id/reboot-schedule', requireAdminRole, (req, res) => {
|
package/test/scheduler.test.js
CHANGED
|
@@ -618,6 +618,70 @@ test('reboot now: refused offline or without a helper; cancels a running job fir
|
|
|
618
618
|
assert.equal(after.message, 'Canceled: user reboot request');
|
|
619
619
|
});
|
|
620
620
|
|
|
621
|
+
test('capabilities from the dashboard: reported config stored, saved at a new revision, resent until applied', () => {
|
|
622
|
+
const { registry } = buildTestServices(),
|
|
623
|
+
{ resourceId } = registry.registerAuto({
|
|
624
|
+
clientId: 'c-cfg', name: 'lab-cfg', type: 'sw', labels: [], capabilities: { sw: { image: 'emu' } },
|
|
625
|
+
config: { image: 'emu:1', cpus: 2, memory: '2g' }
|
|
626
|
+
});
|
|
627
|
+
assert.deepEqual(registry.get(resourceId).client_config, { image: 'emu:1', cpus: 2, memory: '2g' });
|
|
628
|
+
assert.equal(registry.pendingConfig(resourceId), null);
|
|
629
|
+
|
|
630
|
+
assert.throws(() => registry.setClientConfig(resourceId, { memory: 'lots' }), /Invalid SW config: sw\.memory/);
|
|
631
|
+
const saved = registry.setClientConfig(resourceId, { image: 'emu:2', cpus: 1, memory: '1g', allowJobImages: true });
|
|
632
|
+
assert.equal(saved.config_revision, 1);
|
|
633
|
+
assert.deepEqual(registry.pendingConfig(resourceId), { revision: 1, type: 'sw', config: { image: 'emu:2', cpus: 1, memory: '1g', allowJobImages: true } });
|
|
634
|
+
|
|
635
|
+
registry.heartbeat(resourceId, { state: 'idle' }); // an older Client: reports nothing
|
|
636
|
+
assert.equal(registry.pendingConfig(resourceId).revision, 1);
|
|
637
|
+
registry.heartbeat(resourceId, { state: 'idle', configRevision: 1 });
|
|
638
|
+
assert.equal(registry.pendingConfig(resourceId), null);
|
|
639
|
+
|
|
640
|
+
registry.setClientConfig(resourceId, { image: 'emu:3' });
|
|
641
|
+
registry.heartbeat(resourceId, { state: 'idle', configRevision: 2, configError: 'Config revision 2 refused: bad' });
|
|
642
|
+
assert.equal(registry.pendingConfig(resourceId), null); // refused ones aren't resent
|
|
643
|
+
assert.match(registry.get(resourceId).config_error, /refused: bad/);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test('rename from the dashboard: kept across re-registration, unique, and resettable to the Client\'s own name', () => {
|
|
647
|
+
const { registry } = buildTestServices(),
|
|
648
|
+
reg = (name) => registry.get(registry.registerAuto({ clientId: 'c-ren', name, type: 'sw', labels: [] }).resourceId),
|
|
649
|
+
r = reg('dut1');
|
|
650
|
+
registerResource(registry, { name: 'taken', type: 'sw' });
|
|
651
|
+
|
|
652
|
+
assert.equal(registry.rename(r.id, ' bench-7 ').name, 'bench-7');
|
|
653
|
+
assert.equal(reg('dut1').name, 'bench-7'); // the Client restarts, still presenting "dut1"
|
|
654
|
+
assert.equal(registry.get(r.id).reported_name, 'dut1');
|
|
655
|
+
|
|
656
|
+
assert.throws(() => registry.rename(r.id, 'taken'), /already used by another Client/);
|
|
657
|
+
assert.throws(() => registry.rename(r.id, 'has space'), /1-64 letters/);
|
|
658
|
+
|
|
659
|
+
const reset = registry.rename(r.id, '');
|
|
660
|
+
assert.deepEqual([reset.name, reset.name_override], ['dut1', null]);
|
|
661
|
+
assert.equal(reg('dut2').name, 'dut2'); // no override: the Client's own name again
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test('USB scan: only while connected; the answer is kept for the pending request only', () => {
|
|
665
|
+
const { bus, registry } = buildTestServices(),
|
|
666
|
+
commands = [],
|
|
667
|
+
offline = registerResource(registry, { name: 'usb-off', type: 'hw' }),
|
|
668
|
+
r = registerResource(registry, { name: 'usb-on', type: 'hw' });
|
|
669
|
+
registry.heartbeat(r.id, { state: 'idle' });
|
|
670
|
+
bus.on('command', (c) => commands.push(c));
|
|
671
|
+
|
|
672
|
+
assert.throws(() => registry.requestUsbScan(offline.id), /offline/);
|
|
673
|
+
const { requestId } = registry.requestUsbScan(r.id, { by: 'admin' });
|
|
674
|
+
assert.deepEqual(commands, [{ resourceId: r.id, command: 'scan-usb', requestId }]);
|
|
675
|
+
|
|
676
|
+
assert.equal(registry.storeUsbScan(r.id, { requestId: 'stale', output: 'x' }), false);
|
|
677
|
+
assert.equal(registry.get(r.id).usb_scan, null);
|
|
678
|
+
assert.equal(registry.storeUsbScan(r.id, { requestId, output: 'Bus 001 Device 004: ID 0483:3748 ST-LINK/V2', error: null }), true);
|
|
679
|
+
const stored = registry.get(r.id);
|
|
680
|
+
assert.match(stored.usb_scan.output, /ST-LINK/);
|
|
681
|
+
assert.equal(stored.usb_scan_request_id, null); // no longer pending
|
|
682
|
+
assert.equal(registry.storeUsbScan(r.id, { requestId, output: 'again' }), false); // answered once
|
|
683
|
+
});
|
|
684
|
+
|
|
621
685
|
test('a resource can belong to several groups at once', () => {
|
|
622
686
|
const { registry, groups } = buildTestServices(),
|
|
623
687
|
g1 = groups.create({ name: 'g1' }),
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/usb-parse.test.js
|
|
3
|
+
* @description Tests: parsing `lsusb -tvv` and sorting devices into a HW Client's config lists ("Import to config")
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const test = require('node:test'),
|
|
17
|
+
assert = require('node:assert/strict'),
|
|
18
|
+
{ parseLsusbTree, classifyDevices } = require('../public/js/usb-parse');
|
|
19
|
+
|
|
20
|
+
const LSUSB = `/: Bus 001.Port 001: Dev 001, Class=root_hub, Driver=xhci_hcd/12p, 480M
|
|
21
|
+
ID 1d6b:0002 Linux Foundation 2.0 root hub
|
|
22
|
+
/sys/bus/usb/devices/usb1 /dev/bus/usb/001/001
|
|
23
|
+
|__ Port 003: Dev 002, If 0, Class=Hub, Driver=hub/4p, 480M
|
|
24
|
+
ID 05e3:0610 Genesys Logic, Inc. Hub
|
|
25
|
+
/sys/bus/usb/devices/1-3 /dev/bus/usb/001/002
|
|
26
|
+
|__ Port 010: Dev 009, If 0, Class=Vendor Specific Class, Driver=, 12M
|
|
27
|
+
ID 0483:3748 STMicroelectronics ST-LINK/V2
|
|
28
|
+
/sys/bus/usb/devices/1-3.10 /dev/bus/usb/001/009
|
|
29
|
+
|__ Port 002: Dev 005, If 0, Class=Vendor Specific Class, Driver=ftdi_sio, 12M
|
|
30
|
+
ID 0403:6001 Future Technology Devices International, Ltd FT232 Serial (UART) IC
|
|
31
|
+
/sys/bus/usb/devices/1-3.2 /dev/bus/usb/001/005
|
|
32
|
+
|__ Port 001: Dev 004, If 0, Class=Vendor Specific Class, Driver=, 12M
|
|
33
|
+
ID 0483:374b STMicroelectronics ST-LINK/V2.1
|
|
34
|
+
/sys/bus/usb/devices/1-3.1 /dev/bus/usb/001/004
|
|
35
|
+
|__ Port 001: Dev 004, If 1, Class=Mass Storage, Driver=usb-storage, 12M
|
|
36
|
+
ID 0483:374b STMicroelectronics ST-LINK/V2.1
|
|
37
|
+
/sys/bus/usb/devices/1-3.1 /dev/bus/usb/001/004
|
|
38
|
+
|__ Port 004: Dev 007, If 0, Class=Vendor Specific Class, Driver=ch341, 12M
|
|
39
|
+
ID 1a86:7523 QinHeng Electronics CH340 serial converter
|
|
40
|
+
/sys/bus/usb/devices/1-3.4 /dev/bus/usb/001/007
|
|
41
|
+
|__ Port 005: Dev 003, If 0, Class=Communications, Driver=cdc_acm, 12M
|
|
42
|
+
ID 0483:5740 STMicroelectronics Virtual COM Port
|
|
43
|
+
/sys/bus/usb/devices/1-5 /dev/bus/usb/001/003
|
|
44
|
+
|__ Port 006: Dev 008, If 0, Class=Human Interface Device, Driver=usbhid, 1.5M
|
|
45
|
+
ID 046d:c077 Logitech, Inc. Mouse
|
|
46
|
+
/sys/bus/usb/devices/1-6 /dev/bus/usb/001/008`;
|
|
47
|
+
|
|
48
|
+
test('parses every device once, with its ids, name, driver and udev devpath', () => {
|
|
49
|
+
const devices = parseLsusbTree(LSUSB);
|
|
50
|
+
assert.equal(devices.length, 8); // the ST-Link/V2-1's two interfaces are one device
|
|
51
|
+
const ftdi = devices.find((d) => d.id === '0403:6001');
|
|
52
|
+
assert.deepEqual([ftdi.devpath, ftdi.driver, ftdi.sysPath], ['3.2', 'ftdi_sio', '1-3.2']);
|
|
53
|
+
assert.equal(devices.find((d) => d.sysPath === 'usb1').devpath, null);
|
|
54
|
+
assert.deepEqual(parseLsusbTree(''), []);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('sorts devices into the Client\'s lists in port order, with ids only when not the default', () => {
|
|
58
|
+
const { lists, skipped } = classifyDevices(parseLsusbTree(LSUSB));
|
|
59
|
+
assert.deepEqual(lists.stlinks.map((d) => [d.devpath, d.vendorId, d.productId]), [['3.1', '0483', '374b'], ['3.10', undefined, undefined]]);
|
|
60
|
+
assert.deepEqual(lists.uarts.map((d) => [d.devpath, d.id, d.vendorId]), [['3.2', '0403:6001', undefined], ['3.4', '1a86:7523', '1a86']]);
|
|
61
|
+
assert.deepEqual(lists.usbs.map((d) => d.devpath), ['5']);
|
|
62
|
+
assert.deepEqual(skipped.map((d) => [d.id, d.reason]), [
|
|
63
|
+
['1d6b:0002', 'root hub'], ['05e3:0610', 'hub'], ['046d:c077', 'not a device the Client uses']
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('at most 8 per list; the rest are skipped', () => {
|
|
68
|
+
const ftdi = { id: '0403:6001', vendorId: '0403', productId: '6001', name: 'FT232', driver: 'ftdi_sio' },
|
|
69
|
+
many = Array.from({ length: 10 }, (_, i) => ({ ...ftdi, devpath: `2.${i + 1}` })),
|
|
70
|
+
{ lists, skipped } = classifyDevices(many);
|
|
71
|
+
assert.equal(lists.uarts.length, 8);
|
|
72
|
+
assert.deepEqual(skipped.map((d) => d.reason), ['more than 8 uarts', 'more than 8 uarts']);
|
|
73
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/views.test.js
|
|
3
|
+
* @description Tests: every dashboard Pug template compiles (catches syntax the Pug parser doesn't support, e.g. ??)
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const test = require('node:test'),
|
|
17
|
+
assert = require('node:assert/strict'),
|
|
18
|
+
fs = require('node:fs'),
|
|
19
|
+
path = require('node:path'),
|
|
20
|
+
pug = require('pug');
|
|
21
|
+
|
|
22
|
+
const VIEWS = path.join(__dirname, '..', 'views');
|
|
23
|
+
|
|
24
|
+
function pugFiles(dir){
|
|
25
|
+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
|
|
26
|
+
e.isDirectory() ? pugFiles(path.join(dir, e.name)) : e.name.endsWith('.pug') ? [path.join(dir, e.name)] : []);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('every view compiles', () => {
|
|
30
|
+
const files = pugFiles(VIEWS);
|
|
31
|
+
assert.ok(files.length > 10);
|
|
32
|
+
for (const file of files){
|
|
33
|
+
assert.doesNotThrow(() => pug.compileFile(file), `${path.relative(VIEWS, file)}`);
|
|
34
|
+
}
|
|
35
|
+
});
|
package/views/index.pug
CHANGED
package/views/layout.pug
CHANGED
|
@@ -12,7 +12,12 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
|
|
|
12
12
|
.container-fluid
|
|
13
13
|
a.navbar-brand.d-flex.flex-column.lh-1.py-1(href="/")
|
|
14
14
|
span TestHub
|
|
15
|
-
small.text-body-secondary.fw-normal.mt-1(
|
|
15
|
+
small.text-body-secondary.fw-normal.mt-1(
|
|
16
|
+
style="font-size: .7rem;"
|
|
17
|
+
data-bs-toggle="tooltip"
|
|
18
|
+
data-bs-placement="bottom"
|
|
19
|
+
data-bs-title=`Coordinator v${coordinatorVersion} · thub-common v${commonVersion} (validates job specs)`
|
|
20
|
+
)
|
|
16
21
|
| ver. #{coordinatorVersion}
|
|
17
22
|
if user && user.role !== 'admin' && updates.coordinatorUpdate
|
|
18
23
|
span.badge.text-bg-warning.ms-1(
|