@andrian.yablonskyy/thub-coordinator 1.0.10 → 1.0.11
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/README.md
CHANGED
|
@@ -157,7 +157,7 @@ Server-rendered Pug templates styled with Bootstrap 5.3, with a little vanilla J
|
|
|
157
157
|
| `/admin/agents` | Register/revoke CI and developer agents; token shown once. Shows each agent's last reported version. |
|
|
158
158
|
| `/profile` | Every logged-in user's own account settings: display name, avatar, timezone (renders every dashboard timestamp), theme, idle session timeout, password change. |
|
|
159
159
|
|
|
160
|
-
**Resource card.** Clicking a resource on `/` or `/resources` opens a card with its name, type, status, Client version, IP addresses, labels, groups, busy source/reason, last heartbeat and (admins only) the maintenance/rotate-token actions. IP addresses are the Client host's interface addresses except loopback (reported at registration and on every heartbeat) plus the external address — the one the Coordinator last saw the Client connect from, taken from `X-Forwarded-For` when the request came through a proxy `trustProxy` trusts.
|
|
160
|
+
**Resource card.** Clicking a resource on `/` or `/resources` opens a card with its name, type, status, Client version, IP addresses, capabilities (reported at registration: HW udev devices with a **missing** flag for any whose device node doesn't exist, relay channels and power control; SW image, source and limits), labels, groups, busy source/reason, last heartbeat and (admins only) the maintenance/rotate-token actions. IP addresses are the Client host's interface addresses except loopback (reported at registration and on every heartbeat) plus the external address — the one the Coordinator last saw the Client connect from, taken from `X-Forwarded-For` when the request came through a proxy `trustProxy` trusts.
|
|
161
161
|
|
|
162
162
|
The Coordinator's own version (`ver. X.Y.Z`) is shown under the TestHub logo, top left of every page.
|
|
163
163
|
|
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.11",
|
|
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/src/api/resource.js
CHANGED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- What the Client reported it can drive at registration (README §5.1,
|
|
2
|
+
-- §10): its udev devices (with whether each is present), relays and power
|
|
3
|
+
-- control for HW, or emulator image and limits for SW. JSON, NULL for
|
|
4
|
+
-- Clients too old to report it.
|
|
5
|
+
ALTER TABLE resources ADD COLUMN capabilities TEXT;
|
package/src/services/registry.js
CHANGED
|
@@ -25,7 +25,8 @@ function rowToResource(row){
|
|
|
25
25
|
...row,
|
|
26
26
|
labels: JSON.parse(row.labels || '[]'),
|
|
27
27
|
group_ids: JSON.parse(row.group_ids || '[]'),
|
|
28
|
-
host_info: row.host_info ? JSON.parse(row.host_info) : null
|
|
28
|
+
host_info: row.host_info ? JSON.parse(row.host_info) : null,
|
|
29
|
+
capabilities: row.capabilities ? JSON.parse(row.capabilities) : null
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -51,6 +52,52 @@ function normalizeRemoteAddr(addr){
|
|
|
51
52
|
return typeof addr === 'string' && addr ? addr.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/, '$1') : null;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// `capabilities` comes straight from the Client and is rendered on the
|
|
56
|
+
// dashboard, so keep only the known shape, as plain strings/numbers/bools.
|
|
57
|
+
const MAX_DEVICES = 8,
|
|
58
|
+
str = (v, max = 256) => (typeof v === 'string' && v ? v.slice(0, max) : null),
|
|
59
|
+
num = (v) => (Number.isFinite(v) ? v : null),
|
|
60
|
+
capList = (v) => (Array.isArray(v) ? v.slice(0, MAX_DEVICES) : []),
|
|
61
|
+
device = (d) => ({
|
|
62
|
+
path: str(d?.path),
|
|
63
|
+
index: num(d?.index),
|
|
64
|
+
serial: str(d?.serial, 64),
|
|
65
|
+
baudRate: num(d?.baudRate),
|
|
66
|
+
present: typeof d?.present === 'boolean' ? d.present : null
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
function sanitizeCapabilities(caps){
|
|
70
|
+
if (!caps || typeof caps !== 'object'){
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (caps.sw && typeof caps.sw === 'object'){
|
|
74
|
+
const sw = caps.sw;
|
|
75
|
+
return {
|
|
76
|
+
sw: {
|
|
77
|
+
image: str(sw.image),
|
|
78
|
+
registry: str(sw.registry),
|
|
79
|
+
allowDockerHub: sw.allowDockerHub === true,
|
|
80
|
+
cpus: num(sw.cpus),
|
|
81
|
+
memory: str(sw.memory, 16)
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (caps.hw && typeof caps.hw === 'object'){
|
|
86
|
+
const hw = caps.hw,
|
|
87
|
+
power = hw.power && typeof hw.power === 'object' ? hw.power : null;
|
|
88
|
+
return {
|
|
89
|
+
hw: {
|
|
90
|
+
stlinks: capList(hw.stlinks).map(device),
|
|
91
|
+
uarts: capList(hw.uarts).map(device),
|
|
92
|
+
usbs: capList(hw.usbs).map(device),
|
|
93
|
+
relays: capList(hw.relays).map((r) => ({ channel: num(r?.channel), baseUrl: str(r?.baseUrl) })),
|
|
94
|
+
power: power ? { method: str(power.method, 32), hub: str(power.hub, 64), port: num(power.port) } : null
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
54
101
|
function createRegistryService(db, { bus, events }){
|
|
55
102
|
function get(id){
|
|
56
103
|
return rowToResource(db.prepare('SELECT * FROM resources WHERE id = ?').get(id));
|
|
@@ -106,10 +153,12 @@ function createRegistryService(db, { bus, events }){
|
|
|
106
153
|
// this registration is allowed to reclaim it. Anything else (IDLE, BUSY,
|
|
107
154
|
// MAINTENANCE, REGISTERED-but-just-created) means a process might still
|
|
108
155
|
// be actively using that identity, so it stays a hard conflict.
|
|
109
|
-
function registerAuto({ clientId, name, type, labels = [], groups = [], hostInfo, remoteAddr, clientVersion = null }){
|
|
156
|
+
function registerAuto({ clientId, name, type, labels = [], groups = [], hostInfo, capabilities, remoteAddr, clientVersion = null }){
|
|
110
157
|
const resourceToken = generateToken('res'),
|
|
111
158
|
remote = normalizeRemoteAddr(remoteAddr);
|
|
112
159
|
hostInfo = { ...(hostInfo || {}), addresses: sanitizeAddresses(hostInfo?.addresses) || [] };
|
|
160
|
+
const caps = sanitizeCapabilities(capabilities),
|
|
161
|
+
capsJson = caps ? JSON.stringify(caps) : null;
|
|
113
162
|
let existing = getByClientId(clientId);
|
|
114
163
|
|
|
115
164
|
if (!existing){
|
|
@@ -157,7 +206,7 @@ function createRegistryService(db, { bus, events }){
|
|
|
157
206
|
db.prepare(
|
|
158
207
|
`UPDATE resources
|
|
159
208
|
SET token_hash = ?, client_id = ?, name = ?, type = ?, labels = ?, group_ids = ?, host_info = ?,
|
|
160
|
-
remote_addr = ?, client_version = ?, status = ?, busy_source = NULL, busy_reason = NULL
|
|
209
|
+
remote_addr = ?, client_version = ?, capabilities = ?, status = ?, busy_source = NULL, busy_reason = NULL
|
|
161
210
|
WHERE id = ?`
|
|
162
211
|
).run(
|
|
163
212
|
hashToken(resourceToken),
|
|
@@ -169,6 +218,7 @@ function createRegistryService(db, { bus, events }){
|
|
|
169
218
|
JSON.stringify(hostInfo),
|
|
170
219
|
remote,
|
|
171
220
|
clientVersion,
|
|
221
|
+
capsJson,
|
|
172
222
|
nextStatus,
|
|
173
223
|
existing.id
|
|
174
224
|
);
|
|
@@ -187,8 +237,9 @@ function createRegistryService(db, { bus, events }){
|
|
|
187
237
|
|
|
188
238
|
const id = `res_${uuid()}`;
|
|
189
239
|
db.prepare(
|
|
190
|
-
`INSERT INTO resources (id, name, type, status, labels, group_ids, host_info, remote_addr, client_version,
|
|
191
|
-
|
|
240
|
+
`INSERT INTO resources (id, name, type, status, labels, group_ids, host_info, remote_addr, client_version, capabilities,
|
|
241
|
+
token_hash, client_id, created_at)
|
|
242
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
192
243
|
).run(
|
|
193
244
|
id,
|
|
194
245
|
name,
|
|
@@ -199,6 +250,7 @@ function createRegistryService(db, { bus, events }){
|
|
|
199
250
|
JSON.stringify(hostInfo),
|
|
200
251
|
remote,
|
|
201
252
|
clientVersion,
|
|
253
|
+
capsJson,
|
|
202
254
|
hashToken(resourceToken),
|
|
203
255
|
clientId,
|
|
204
256
|
new Date().toISOString()
|
|
@@ -33,6 +33,62 @@ mixin resourceActions(r, returnTo)
|
|
|
33
33
|
i.bi.bi-arrow-repeat
|
|
34
34
|
+updateButton(`/resources/${r.id}/update`, r.update_to, returnTo)
|
|
35
35
|
|
|
36
|
+
//- One udev device line: path (or udev index / ST-Link serial), extra
|
|
37
|
+
//- detail, and whether the node was present when the Client registered.
|
|
38
|
+
mixin capDevice(d, detail)
|
|
39
|
+
li
|
|
40
|
+
code= d.path || (d.serial ? `serial ${d.serial}` : `index ${d.index}`)
|
|
41
|
+
if detail
|
|
42
|
+
span.text-body-secondary.small.ms-2= detail
|
|
43
|
+
if d.present === false
|
|
44
|
+
span.badge.text-bg-danger.ms-2(
|
|
45
|
+
data-bs-toggle="tooltip"
|
|
46
|
+
data-bs-title="The device node didn't exist when the Client registered — check the adapter and udev rules"
|
|
47
|
+
) missing
|
|
48
|
+
|
|
49
|
+
//- What the Client reported it can drive at registration (HW: udev
|
|
50
|
+
//- devices, relays, power; SW: emulator image and limits).
|
|
51
|
+
mixin capabilitiesList(caps)
|
|
52
|
+
if !caps
|
|
53
|
+
span.text-body-secondary —
|
|
54
|
+
else if caps.sw
|
|
55
|
+
ul.list-unstyled.mb-0
|
|
56
|
+
li
|
|
57
|
+
span.text-body-secondary.small.me-2 Image
|
|
58
|
+
code= caps.sw.image || '—'
|
|
59
|
+
li
|
|
60
|
+
span.text-body-secondary.small.me-2 Source
|
|
61
|
+
= caps.sw.registry ? caps.sw.registry : 'no local registry'
|
|
62
|
+
= caps.sw.allowDockerHub ? ', then Docker Hub' : ' (Docker Hub off)'
|
|
63
|
+
li
|
|
64
|
+
span.text-body-secondary.small.me-2 Limits
|
|
65
|
+
= `${caps.sw.cpus || '—'} CPU, ${caps.sw.memory || '—'}`
|
|
66
|
+
else if caps.hw
|
|
67
|
+
- const kinds = [['stlinks', 'ST-Link'], ['uarts', 'UART'], ['usbs', 'USB']]
|
|
68
|
+
- const empty = kinds.every(([k]) => !caps.hw[k].length) && !caps.hw.relays.length && !caps.hw.power
|
|
69
|
+
if empty
|
|
70
|
+
span.text-body-secondary no devices configured
|
|
71
|
+
each pair in kinds
|
|
72
|
+
if caps.hw[pair[0]].length
|
|
73
|
+
.small.text-body-secondary.mt-1= pair[1]
|
|
74
|
+
ul.list-unstyled.mb-0.ms-2
|
|
75
|
+
each d in caps.hw[pair[0]]
|
|
76
|
+
+capDevice(d, pair[0] === 'uarts' && d.baudRate ? `${d.baudRate} baud` : null)
|
|
77
|
+
if caps.hw.relays.length
|
|
78
|
+
.small.text-body-secondary.mt-1 Relays
|
|
79
|
+
ul.list-unstyled.mb-0.ms-2
|
|
80
|
+
each rl in caps.hw.relays
|
|
81
|
+
li
|
|
82
|
+
= `channel ${rl.channel}`
|
|
83
|
+
if rl.baseUrl
|
|
84
|
+
code.ms-2= rl.baseUrl
|
|
85
|
+
if caps.hw.power
|
|
86
|
+
.small.text-body-secondary.mt-1 Power
|
|
87
|
+
.ms-2
|
|
88
|
+
= caps.hw.power.method
|
|
89
|
+
if caps.hw.power.method === 'uhubctl' && caps.hw.power.hub
|
|
90
|
+
span.text-body-secondary.small.ms-2= `hub ${caps.hw.power.hub} port ${caps.hw.power.port}`
|
|
91
|
+
|
|
36
92
|
mixin resourceCard(r, groupsById, returnTo)
|
|
37
93
|
- const isLoopback = (a) => /^127\./.test(a) || a === '::1' || a === 'localhost'
|
|
38
94
|
- const local = ((r.host_info && r.host_info.addresses) || []).filter((a) => !isLoopback(a.address))
|
|
@@ -69,6 +125,9 @@ mixin resourceCard(r, groupsById, returnTo)
|
|
|
69
125
|
data-bs-toggle="tooltip"
|
|
70
126
|
data-bs-title="The address the Coordinator last saw this Client connect from"
|
|
71
127
|
) external
|
|
128
|
+
dt.col-sm-4 Capabilities
|
|
129
|
+
dd.col-sm-8
|
|
130
|
+
+capabilitiesList(r.capabilities)
|
|
72
131
|
dt.col-sm-4 Labels
|
|
73
132
|
dd.col-sm-8
|
|
74
133
|
if r.labels.length
|