@andrian.yablonskyy/thub-coordinator 1.0.0

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.
Files changed (53) hide show
  1. package/README.md +157 -0
  2. package/bin/thub-admin.js +190 -0
  3. package/config.json +30 -0
  4. package/eslint.config.js +115 -0
  5. package/package.json +32 -0
  6. package/public/css/thub.css +6 -0
  7. package/public/js/copy-to-clipboard.js +36 -0
  8. package/public/js/log-viewer.js +76 -0
  9. package/public/js/profile-theme.js +28 -0
  10. package/public/js/theme.js +51 -0
  11. package/public/js/tooltips.js +18 -0
  12. package/src/api/admin.js +112 -0
  13. package/src/api/agent.js +110 -0
  14. package/src/api/resource.js +211 -0
  15. package/src/api/sse.js +96 -0
  16. package/src/auth.js +94 -0
  17. package/src/config.js +110 -0
  18. package/src/db/index.js +61 -0
  19. package/src/db/migrations/001_init.sql +104 -0
  20. package/src/db/migrations/003_split_job_id_counters.sql +5 -0
  21. package/src/db/migrations/004_add_client_id.sql +8 -0
  22. package/src/db/migrations/005_add_groups.sql +14 -0
  23. package/src/db/migrations/006_events_allow_group.sql +20 -0
  24. package/src/db/migrations/007_add_user_profile.sql +10 -0
  25. package/src/server.js +179 -0
  26. package/src/services/admin-users.js +174 -0
  27. package/src/services/agents.js +56 -0
  28. package/src/services/artifacts.js +149 -0
  29. package/src/services/bus.js +23 -0
  30. package/src/services/commands.js +43 -0
  31. package/src/services/events.js +37 -0
  32. package/src/services/groups.js +79 -0
  33. package/src/services/heartbeat.js +56 -0
  34. package/src/services/jobs.js +355 -0
  35. package/src/services/logs.js +70 -0
  36. package/src/services/registry.js +347 -0
  37. package/src/services/retention.js +44 -0
  38. package/src/services/scheduler.js +101 -0
  39. package/src/services/tokens.js +28 -0
  40. package/src/web/routes.js +343 -0
  41. package/test/admin-users.test.js +127 -0
  42. package/test/scheduler.test.js +375 -0
  43. package/views/admin/agents.pug +53 -0
  44. package/views/groups/list.pug +94 -0
  45. package/views/index.pug +37 -0
  46. package/views/jobs/list.pug +45 -0
  47. package/views/jobs/show.pug +42 -0
  48. package/views/layout.pug +73 -0
  49. package/views/login.pug +16 -0
  50. package/views/mixins/log-viewer.pug +14 -0
  51. package/views/mixins/status-badge.pug +7 -0
  52. package/views/profile.pug +77 -0
  53. package/views/resources/list.pug +65 -0
@@ -0,0 +1,375 @@
1
+ /**
2
+ * @file packages/coordinator/test/scheduler.test.js
3
+ * @description Tests: scheduler, registry, groups, job id allocation, and admin queue operations
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
+ os = require('node:os'),
20
+ path = require('node:path'),
21
+ { EventEmitter } = require('node:events'),
22
+
23
+ { openDb } = require('../src/db'),
24
+ { createEventsService } = require('../src/services/events'),
25
+ { createRegistryService } = require('../src/services/registry'),
26
+ { createAgentsService } = require('../src/services/agents'),
27
+ { createGroupsService } = require('../src/services/groups'),
28
+ { createArtifactsService } = require('../src/services/artifacts'),
29
+ { createJobsService } = require('../src/services/jobs'),
30
+ { createScheduler } = require('../src/services/scheduler'),
31
+ { createHeartbeatMonitor } = require('../src/services/heartbeat'),
32
+ { JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common');
33
+
34
+ function buildTestServices(overrides = {}){
35
+ const db = openDb(':memory:'),
36
+ // Own bus per test, not the module-level singleton (production's
37
+ // there's-only-one-Coordinator-process bus) — sharing it across every
38
+ // test in this file would pile up listeners test after test with no
39
+ // teardown, eventually tripping Node's MaxListenersExceededWarning.
40
+ bus = new EventEmitter(),
41
+ events = createEventsService(db),
42
+ registry = createRegistryService(db, { bus, events }),
43
+ agents = createAgentsService(db, { events }),
44
+ groups = createGroupsService(db, { events, registry }),
45
+ artifactsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-test-artifacts-')),
46
+ config = {
47
+ scheduler: { assignAckTimeoutSec: 15, requeueOnLost: true, maxQueuedPerAgent: 20, tickIntervalSec: 3600 },
48
+ jobs: { defaultTimeoutSec: 1800, maxTimeoutSec: 14400 },
49
+ heartbeat: { intervalSec: 10, missedLimit: 3, sweepIntervalSec: 3600 },
50
+ artifactsDir,
51
+ sessionSecret: 'test-secret',
52
+ publicUrl: 'http://localhost:8080',
53
+ artifacts: { linkTtlHours: 1 },
54
+ ...overrides
55
+ },
56
+ artifacts = createArtifactsService(db, { config }),
57
+ jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
58
+ scheduler = createScheduler(db, { bus, events, registry, config }),
59
+ heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config });
60
+ return { db, registry, agents, groups, artifacts, jobs, scheduler, heartbeatMonitor, artifactsDir };
61
+ }
62
+
63
+ function registerResource(registry, { name, type, labels = [], groups = [], clientId } = {}){
64
+ const { resourceId } = registry.registerAuto({ clientId: clientId || `client-${name}`, name, type, labels, groups });
65
+ return registry.get(resourceId);
66
+ }
67
+
68
+ function makeSpec(overrides = {}){
69
+ return {
70
+ target: { type: 'sw', labels: [] },
71
+ firmware: { url: 'https://x/app.bin' },
72
+ tests: { url: 'https://x/tests.tar.gz' },
73
+ ...overrides
74
+ };
75
+ }
76
+
77
+ test('scheduler assigns a queued job to a matching idle resource', () => {
78
+ const { registry, agents, jobs, scheduler } = buildTestServices(),
79
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
80
+ resource = registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: [] });
81
+ registry.heartbeat(resource.id, { state: 'idle' });
82
+
83
+ const job = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
84
+ assert.equal(job.state, JOB_STATES.QUEUED);
85
+
86
+ scheduler.runPass();
87
+
88
+ const assigned = jobs.get(job.id);
89
+ assert.equal(assigned.state, JOB_STATES.ASSIGNED);
90
+ assert.equal(assigned.resource_id, resource.id);
91
+ assert.equal(registry.get(resource.id).status, RESOURCE_STATES.BUSY);
92
+ });
93
+
94
+ test('job submission is rejected (422) when no resource could ever satisfy its labels', () => {
95
+ const { registry, agents, jobs } = buildTestServices(),
96
+ { agent } = agents.create({ name: 'ci', kind: 'ci' });
97
+ registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: ['board:a'] });
98
+
99
+ assert.throws(
100
+ () =>
101
+ jobs.create({
102
+ agentId: agent.id,
103
+ source: 'cli',
104
+ spec: makeSpec({ target: { type: 'sw', labels: ['board:b'] } })
105
+ }),
106
+ /No registered resource/
107
+ );
108
+ });
109
+
110
+ test('a job stays QUEUED while its only matching resource is busy', () => {
111
+ const { registry, agents, jobs, scheduler } = buildTestServices(),
112
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
113
+ resource = registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: ['board:a'] });
114
+ registry.heartbeat(resource.id, { state: 'idle' });
115
+
116
+ const first = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', labels: ['board:a'] } }) }),
117
+ second = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', labels: ['board:a'] } }) });
118
+ scheduler.runPass();
119
+
120
+ assert.equal(jobs.get(first.id).state, JOB_STATES.ASSIGNED);
121
+ assert.equal(jobs.get(second.id).state, JOB_STATES.QUEUED);
122
+ });
123
+
124
+ test('heartbeat sweeper marks a silent resource OUT_OF_SERVICE and requeues its active job', () => {
125
+ const { db, registry, agents, jobs, scheduler, heartbeatMonitor } = buildTestServices(),
126
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
127
+ resource = registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: [] });
128
+ registry.heartbeat(resource.id, { state: 'idle' });
129
+
130
+ const job = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
131
+ scheduler.runPass();
132
+ assert.equal(jobs.get(job.id).state, JOB_STATES.ASSIGNED);
133
+
134
+ // Simulate 3+ missed heartbeats (heartbeatIntervalSec=10) by backdating last_heartbeat_at.
135
+ const staleTs = new Date(Date.now() - 60_000).toISOString();
136
+ db.prepare('UPDATE resources SET last_heartbeat_at = ? WHERE id = ?').run(staleTs, resource.id);
137
+
138
+ heartbeatMonitor.sweepOnce();
139
+
140
+ assert.equal(registry.get(resource.id).status, RESOURCE_STATES.OUT_OF_SERVICE);
141
+ // requeueOnLost defaults true and attempt was 1, so LOST -> QUEUED.
142
+ assert.equal(jobs.get(job.id).state, JOB_STATES.QUEUED);
143
+ });
144
+
145
+ test('job ids are split by source: A-##### for ci, M-##### for cli', () => {
146
+ const { registry, agents, jobs } = buildTestServices(),
147
+ { agent } = agents.create({ name: 'mixed', kind: 'ci' });
148
+ registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: [] });
149
+
150
+ const ciJob = jobs.create({ agentId: agent.id, source: 'ci', spec: makeSpec() }),
151
+ cliJob = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() }),
152
+ ciJob2 = jobs.create({ agentId: agent.id, source: 'ci', spec: makeSpec() });
153
+
154
+ assert.match(ciJob.id, /^A-\d{5}$/);
155
+ assert.match(cliJob.id, /^M-\d{5}$/);
156
+ // Independent counters: the second ci job increments the A series only.
157
+ assert.equal(Number(ciJob2.id.slice(2)), Number(ciJob.id.slice(2)) + 1);
158
+ });
159
+
160
+ test('admin resetQueue cancels every active job and leaves finished ones alone', () => {
161
+ const { registry, agents, jobs, scheduler } = buildTestServices(),
162
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
163
+ resource = registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: [] });
164
+ registry.heartbeat(resource.id, { state: 'idle' });
165
+
166
+ const assigned = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
167
+ scheduler.runPass();
168
+ assert.equal(jobs.get(assigned.id).state, JOB_STATES.ASSIGNED);
169
+
170
+ const queued = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() }),
171
+ finished = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
172
+ jobs.setState(finished.id, JOB_STATES.PASSED);
173
+
174
+ const canceled = jobs.resetQueue();
175
+
176
+ assert.equal(canceled, 2);
177
+ assert.equal(jobs.get(assigned.id).state, JOB_STATES.CANCELED);
178
+ assert.equal(jobs.get(queued.id).state, JOB_STATES.CANCELED);
179
+ assert.equal(jobs.get(finished.id).state, JOB_STATES.PASSED); // untouched
180
+ });
181
+
182
+ test('admin cleanHistory deletes finished jobs, their artifacts on disk, and leaves active jobs alone', () => {
183
+ const { registry, agents, artifacts, jobs, artifactsDir } = buildTestServices(),
184
+ { agent } = agents.create({ name: 'ci', kind: 'ci' });
185
+ registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: [] });
186
+
187
+ const finished = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
188
+ jobs.setState(finished.id, JOB_STATES.PASSED);
189
+ artifacts.storeGenerated(finished.id, 'console.log', Buffer.from('hello'), 'text/plain');
190
+ assert.ok(fs.existsSync(path.join(artifactsDir, finished.id)));
191
+
192
+ const active = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() }),
193
+
194
+ deleted = jobs.cleanHistory();
195
+
196
+ assert.equal(deleted, 1);
197
+ assert.equal(jobs.get(finished.id), undefined);
198
+ assert.equal(fs.existsSync(path.join(artifactsDir, finished.id)), false);
199
+ assert.ok(jobs.get(active.id)); // active job untouched
200
+ });
201
+
202
+ test('resource re-registration overwrites type/labels/status and marks its stale active job LOST', () => {
203
+ const { registry, agents, jobs, scheduler } = buildTestServices(),
204
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
205
+ resource = registerResource(registry, { name: 'lab-hw-01', type: 'hw', labels: ['board:a'] });
206
+ registry.heartbeat(resource.id, { state: 'idle' });
207
+
208
+ const job = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'hw', labels: ['board:a'] } }) });
209
+ scheduler.runPass();
210
+ assert.equal(jobs.get(job.id).state, JOB_STATES.ASSIGNED);
211
+ assert.equal(registry.get(resource.id).status, RESOURCE_STATES.BUSY);
212
+
213
+ // Simulate the daemon crashing and restarting with a changed config.
214
+ const { resourceId } = registry.registerAuto({
215
+ clientId: 'client-lab-hw-01',
216
+ name: 'lab-hw-01',
217
+ type: 'sw',
218
+ labels: ['board:b']
219
+ });
220
+ assert.equal(resourceId, resource.id);
221
+
222
+ const reregistered = registry.get(resource.id);
223
+ assert.equal(reregistered.type, 'sw');
224
+ assert.deepEqual(reregistered.labels, ['board:b']);
225
+ assert.equal(reregistered.status, RESOURCE_STATES.REGISTERED);
226
+ assert.equal(reregistered.busy_source, null);
227
+
228
+ // The stale job it was holding is now LOST (then requeued, since attempt < 2).
229
+ assert.equal(jobs.get(job.id).state, JOB_STATES.QUEUED);
230
+ });
231
+
232
+ test('resource re-registration does not clear an admin-set MAINTENANCE status', () => {
233
+ const { registry } = buildTestServices(),
234
+ resource = registerResource(registry, { name: 'lab-sw-02', type: 'sw', labels: [] });
235
+ registry.heartbeat(resource.id, { state: 'idle' });
236
+ registry.setMaintenance(resource.id, true);
237
+
238
+ registry.registerAuto({ clientId: 'client-lab-sw-02', name: 'lab-sw-02', type: 'sw', labels: [] });
239
+
240
+ assert.equal(registry.get(resource.id).status, RESOURCE_STATES.MAINTENANCE);
241
+ });
242
+
243
+ test('renaming a Client (same clientId, new name) updates the same resource in place', () => {
244
+ const { registry } = buildTestServices(),
245
+ resource = registerResource(registry, { clientId: 'stable-uuid-1', name: 'old-name', type: 'sw', labels: [] }),
246
+
247
+ { resourceId } = registry.registerAuto({ clientId: 'stable-uuid-1', name: 'new-name', type: 'sw', labels: [] });
248
+
249
+ assert.equal(resourceId, resource.id);
250
+ assert.equal(registry.get(resource.id).name, 'new-name');
251
+ assert.equal(registry.list().length, 1); // not a second resource
252
+ });
253
+
254
+ test('a name already claimed by a different clientId is rejected, whether new or a rename', () => {
255
+ const { registry } = buildTestServices();
256
+ registerResource(registry, { clientId: 'uuid-a', name: 'shared-name', type: 'sw', labels: [] });
257
+ registerResource(registry, { clientId: 'uuid-b', name: 'other-name', type: 'sw', labels: [] });
258
+
259
+ assert.throws(
260
+ () => registry.registerAuto({ clientId: 'uuid-c', name: 'shared-name', type: 'sw', labels: [] }),
261
+ /already registered by a different client/
262
+ );
263
+ assert.throws(
264
+ () => registry.registerAuto({ clientId: 'uuid-b', name: 'shared-name', type: 'sw', labels: [] }),
265
+ /already used by another client/
266
+ );
267
+ });
268
+
269
+ test('a name held by an OUT_OF_SERVICE (abandoned) client can be reclaimed by a new registration', () => {
270
+ const { registry } = buildTestServices(),
271
+ abandoned = registerResource(registry, { clientId: 'old-uuid', name: 'lab-01', type: 'sw', labels: [] });
272
+ registry.markOutOfService(abandoned.id);
273
+
274
+ const { resourceId } = registry.registerAuto({ clientId: 'new-uuid', name: 'lab-01', type: 'hw', labels: ['x'] });
275
+
276
+ assert.equal(resourceId, abandoned.id); // took over the same row, not a duplicate
277
+ const reclaimed = registry.get(resourceId);
278
+ assert.equal(reclaimed.client_id, 'new-uuid');
279
+ assert.equal(reclaimed.type, 'hw');
280
+ assert.equal(reclaimed.status, RESOURCE_STATES.REGISTERED);
281
+ assert.equal(registry.list().length, 1);
282
+ });
283
+
284
+ test('a name held by an OUT_OF_SERVICE client can be reclaimed via a rename too, without duplicating the row', () => {
285
+ const { registry } = buildTestServices(),
286
+ abandoned = registerResource(registry, { clientId: 'old-uuid', name: 'lab-02', type: 'sw', labels: [] });
287
+ registry.markOutOfService(abandoned.id);
288
+ const renaming = registerResource(registry, { clientId: 'live-uuid', name: 'my-bench', type: 'sw', labels: [] }),
289
+
290
+ { resourceId } = registry.registerAuto({ clientId: 'live-uuid', name: 'lab-02', type: 'sw', labels: [] });
291
+
292
+ assert.equal(resourceId, renaming.id);
293
+ assert.equal(registry.get(renaming.id).name, 'lab-02');
294
+ // The abandoned row is still there (history preserved), just renamed out of the way.
295
+ assert.equal(registry.list().length, 2);
296
+ assert.equal(registry.get(abandoned.id).name, `lab-02__stale-${abandoned.id}`);
297
+ });
298
+
299
+ test('a legacy resource with no clientId (pre-dates the feature) is adopted by name on first registration', () => {
300
+ const { db, registry } = buildTestServices(),
301
+ resource = registerResource(registry, { clientId: 'will-be-overwritten', name: 'legacy-01', type: 'sw', labels: [] });
302
+ // Simulate a pre-migration row: no client_id yet.
303
+ db.prepare('UPDATE resources SET client_id = NULL WHERE id = ?').run(resource.id);
304
+
305
+ const { resourceId } = registry.registerAuto({ clientId: 'brand-new-uuid', name: 'legacy-01', type: 'sw', labels: ['x'] });
306
+
307
+ assert.equal(resourceId, resource.id); // adopted, not duplicated
308
+ assert.equal(registry.get(resource.id).client_id, 'brand-new-uuid');
309
+ assert.equal(registry.list().length, 1);
310
+ });
311
+
312
+ test('a job with target.group only schedules onto resources that are members of that group', () => {
313
+ const { registry, agents, groups, jobs, scheduler } = buildTestServices(),
314
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
315
+ groupA = groups.create({ name: 'group-a' }),
316
+ groupB = groups.create({ name: 'group-b' }),
317
+
318
+ inGroupA = registerResource(registry, { name: 'lab-a', type: 'sw', groups: [groupA.id] }),
319
+ inGroupB = registerResource(registry, { name: 'lab-b', type: 'sw', groups: [groupB.id] });
320
+ registry.heartbeat(inGroupA.id, { state: 'idle' });
321
+ registry.heartbeat(inGroupB.id, { state: 'idle' });
322
+
323
+ const job = jobs.create({
324
+ agentId: agent.id,
325
+ source: 'cli',
326
+ spec: makeSpec({ target: { type: 'sw', labels: [], group: groupA.id } })
327
+ });
328
+ scheduler.runPass();
329
+
330
+ const assigned = jobs.get(job.id);
331
+ assert.equal(assigned.state, JOB_STATES.ASSIGNED);
332
+ assert.equal(assigned.resource_id, inGroupA.id); // not inGroupB, despite being IDLE and type-matching
333
+ });
334
+
335
+ test('a job with target.group is rejected (422) when no resource is ever a member of that group', () => {
336
+ const { registry, agents, groups, jobs } = buildTestServices(),
337
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
338
+ group = groups.create({ name: 'empty-group' });
339
+ registerResource(registry, { name: 'lab-01', type: 'sw', groups: [] }); // exists, but not a member
340
+
341
+ assert.throws(
342
+ () =>
343
+ jobs.create({
344
+ agentId: agent.id,
345
+ source: 'cli',
346
+ spec: makeSpec({ target: { type: 'sw', labels: [], group: group.id } })
347
+ }),
348
+ /No registered resource/
349
+ );
350
+ });
351
+
352
+ test('a resource can belong to several groups at once', () => {
353
+ const { registry, groups } = buildTestServices(),
354
+ g1 = groups.create({ name: 'g1' }),
355
+ g2 = groups.create({ name: 'g2' }),
356
+
357
+ resource = registerResource(registry, { name: 'multi-group', type: 'sw', groups: [g1.id, g2.id] });
358
+
359
+ assert.deepEqual(registry.get(resource.id).group_ids, [g1.id, g2.id]);
360
+ });
361
+
362
+ test('deleting a group strips it from every resource that listed it, without touching the resource otherwise', () => {
363
+ const { registry, groups } = buildTestServices(),
364
+ g1 = groups.create({ name: 'to-delete' }),
365
+ g2 = groups.create({ name: 'keep-me' }),
366
+ resource = registerResource(registry, { name: 'lab-01', type: 'sw', labels: ['x'], groups: [g1.id, g2.id] });
367
+
368
+ groups.remove(g1.id);
369
+
370
+ const after = registry.get(resource.id);
371
+ assert.deepEqual(after.group_ids, [g2.id]);
372
+ assert.deepEqual(after.labels, ['x']); // untouched
373
+ assert.equal(groups.get(g1.id), undefined);
374
+ assert.ok(groups.get(g2.id));
375
+ });
@@ -0,0 +1,53 @@
1
+ extends ../layout
2
+
3
+ block content
4
+ .d-flex.justify-content-between.align-items-center.mb-3
5
+ h1.h4.mb-0 Agents (CI / developers)
6
+ button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addAgentModal") Register agent
7
+
8
+ if newToken
9
+ .alert.alert-warning
10
+ strong Token (shown once):
11
+ |
12
+ code= newToken
13
+
14
+ table.table.table-hover.align-middle
15
+ thead
16
+ tr
17
+ th Name
18
+ th Kind
19
+ th Created
20
+ th Last used
21
+ th Status
22
+ th
23
+ tbody
24
+ each a in agents
25
+ tr(class=a.revoked_at ? 'opacity-50' : '')
26
+ td= a.name
27
+ td.text-uppercase= a.kind
28
+ td= fmtDate(a.created_at)
29
+ td= fmtDate(a.last_used_at, 'never')
30
+ td= a.revoked_at ? 'revoked' : 'active'
31
+ td
32
+ if !a.revoked_at
33
+ form(method="post" action=`/admin/agents/${a.id}/revoke` onsubmit="return confirm('Revoke this agent token?')")
34
+ button.btn.btn-sm.btn-outline-danger(type="submit") Revoke
35
+
36
+ .modal(tabindex="-1" id="addAgentModal")
37
+ .modal-dialog
38
+ .modal-content
39
+ form(method="post" action="/admin/agents")
40
+ .modal-header
41
+ h5.modal-title Register agent
42
+ button.btn-close(type="button" data-bs-dismiss="modal")
43
+ .modal-body
44
+ .mb-3
45
+ label.form-label Name
46
+ input.form-control(type="text" name="name" placeholder="ci-firmware or jane.dev" required)
47
+ .mb-3
48
+ label.form-label Kind
49
+ select.form-select(name="kind")
50
+ option(value="ci") ci
51
+ option(value="cli") cli
52
+ .modal-footer
53
+ button.btn.btn-primary(type="submit") Create
@@ -0,0 +1,94 @@
1
+ extends ../layout
2
+
3
+ block content
4
+ .d-flex.justify-content-between.align-items-center.mb-3
5
+ h1.h4.mb-0 Groups
6
+ if canManage
7
+ button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addGroupModal") Add group
8
+
9
+ p.text-body-secondary.small.
10
+ A job can be constrained to run only on resources in a group with `thub run --group &lt;id&gt;` (§13.1).
11
+ A resource joins a group by listing its id under `groups` in the Client's own config — membership isn't set here.
12
+
13
+ if groups.length
14
+ table.table.table-hover.align-middle
15
+ thead
16
+ tr
17
+ th Name
18
+ th ID
19
+ th Comment
20
+ th Resources
21
+ th Created
22
+ if canManage
23
+ th Actions
24
+ tbody
25
+ each g in groups
26
+ tr
27
+ td= g.name
28
+ td
29
+ code.small.me-1= g.id
30
+ button.btn.btn-sm.btn-outline-secondary.copy-btn(
31
+ type="button"
32
+ aria-label="Copy group ID to clipboard"
33
+ data-bs-toggle="tooltip"
34
+ data-bs-title="Copy to clipboard"
35
+ data-copy=g.id
36
+ )
37
+ i.bi.bi-clipboard
38
+ td= g.comment || '—'
39
+ td= g.resourceCount
40
+ td= fmtDate(g.created_at)
41
+ if canManage
42
+ td
43
+ button.btn.btn-sm.btn-outline-secondary(type="button" data-bs-toggle="modal" data-bs-target=`#editGroupModal-${g.id}`) Edit
44
+ form.d-inline.ms-1(method="post" action=`/groups/${g.id}/delete` onsubmit="return confirm('Delete this group? Any resource listing it will just stop matching on it.')")
45
+ button.btn.btn-sm.btn-outline-danger(type="submit") Delete
46
+
47
+ .modal(tabindex="-1" id=`editGroupModal-${g.id}`)
48
+ .modal-dialog
49
+ .modal-content
50
+ form(method="post" action=`/groups/${g.id}`)
51
+ .modal-header
52
+ h5.modal-title Edit group
53
+ button.btn-close(type="button" data-bs-dismiss="modal")
54
+ .modal-body
55
+ .mb-3
56
+ label.form-label Name
57
+ input.form-control(type="text" name="name" value=g.name required)
58
+ .mb-3
59
+ label.form-label Comment
60
+ textarea.form-control(name="comment" rows="2")= g.comment || ''
61
+ p.text-body-secondary.small.mb-0
62
+ | ID:
63
+ code.me-1= g.id
64
+ button.btn.btn-sm.btn-outline-secondary.copy-btn(
65
+ type="button"
66
+ aria-label="Copy group ID to clipboard"
67
+ data-bs-toggle="tooltip"
68
+ data-bs-title="Copy to clipboard"
69
+ data-copy=g.id
70
+ )
71
+ i.bi.bi-clipboard
72
+ .modal-footer
73
+ button.btn.btn-primary(type="submit") Save
74
+ else
75
+ p.text-body-secondary No groups yet.
76
+
77
+ if canManage
78
+ .modal(tabindex="-1" id="addGroupModal")
79
+ .modal-dialog
80
+ .modal-content
81
+ form(method="post" action="/groups")
82
+ .modal-header
83
+ h5.modal-title Add group
84
+ button.btn-close(type="button" data-bs-dismiss="modal")
85
+ .modal-body
86
+ .mb-3
87
+ label.form-label Name
88
+ input.form-control(type="text" name="name" placeholder="ci-nightly" required)
89
+ .mb-3
90
+ label.form-label Comment
91
+ textarea.form-control(name="comment" rows="2" placeholder="Optional")
92
+ p.text-body-secondary.small.mb-0 The group's id (a UUID) is generated once it's created.
93
+ .modal-footer
94
+ button.btn.btn-primary(type="submit") Create
@@ -0,0 +1,37 @@
1
+ extends layout
2
+ include mixins/status-badge
3
+
4
+ block content
5
+ h1.h4.mb-2 Overview
6
+
7
+ .row.g-3.mb-4
8
+ .col-md-4
9
+ .card
10
+ .card-body
11
+ h6.card-subtitle.mb-2.text-body-secondary Queue length
12
+ p.display-6= queueLength
13
+ .col-md-4
14
+ .card
15
+ .card-body
16
+ h6.card-subtitle.mb-2.text-body-secondary Jobs in last 24h
17
+ p.display-6= jobsLast24h
18
+ .col-md-4
19
+ .card
20
+ .card-body
21
+ h6.card-subtitle.mb-2.text-body-secondary Resources online
22
+ p.display-6= `${onlineCount} / ${resources.length}`
23
+
24
+ .row.g-3
25
+ each r in resources
26
+ .col-sm-6.col-md-4.col-lg-3
27
+ .card.h-100(class=`border-${{IDLE:'success',BUSY:'primary',OUT_OF_SERVICE:'danger',MAINTENANCE:'secondary',REGISTERED:'secondary'}[r.status] || 'secondary'}`)
28
+ .card-body
29
+ h5.card-title= r.name
30
+ p.card-text.mb-1
31
+ +resourceBadge(r.status)
32
+ |
33
+ = ' ' + r.type.toUpperCase()
34
+ if r.busy_source
35
+ p.card-text.small.text-body-secondary busy: #{r.busy_source} #{r.busy_reason ? '— ' + r.busy_reason : ''}
36
+ p.card-text.small.text-body-secondary
37
+ | heartbeat: #{fmtDate(r.last_heartbeat_at, 'never')}
@@ -0,0 +1,45 @@
1
+ extends ../layout
2
+ include ../mixins/status-badge
3
+
4
+ block content
5
+ .d-flex.justify-content-between.align-items-center.mb-3
6
+ h1.h4.mb-0 Jobs
7
+ if canManage
8
+ .btn-group
9
+ form(method="post" action="/jobs/reset-queue" onsubmit="return confirm('Cancel every queued/assigned/preparing/running job? This stops in-flight work.')")
10
+ button.btn.btn-sm.btn-outline-warning(type="submit") Reset queue
11
+ form.ms-1(method="post" action="/jobs/clean-history" onsubmit="return confirm('Permanently delete all finished jobs and their logs/artifacts? This cannot be undone.')")
12
+ button.btn.btn-sm.btn-outline-danger(type="submit") Clean history
13
+
14
+ form.row.g-2.mb-3(method="get")
15
+ .col-auto
16
+ select.form-select.form-select-sm(name="state" onchange="this.form.submit()")
17
+ option(value="") any state
18
+ each s in ['QUEUED','ASSIGNED','PREPARING','RUNNING','PASSED','FAILED','ERROR','TIMEOUT','CANCELED','LOST']
19
+ option(value=s selected=filters.state === s)= s
20
+ .col-auto
21
+ select.form-select.form-select-sm(name="source" onchange="this.form.submit()")
22
+ option(value="") any source
23
+ option(value="ci" selected=filters.source === 'ci') ci
24
+ option(value="cli" selected=filters.source === 'cli') cli
25
+
26
+ table.table.table-hover.align-middle
27
+ thead
28
+ tr
29
+ th Job
30
+ th Source
31
+ th User
32
+ th State
33
+ th Resource
34
+ th Created
35
+ th Finished
36
+ tbody
37
+ each j in jobs
38
+ tr
39
+ td: a(href=`/jobs/${j.id}`)= j.id
40
+ td.text-uppercase= j.source
41
+ td= j.spec.user || '—'
42
+ td: +jobBadge(j.state)
43
+ td= j.resource ? j.resource.name : '—'
44
+ td= fmtDate(j.created_at)
45
+ td= fmtDate(j.finished_at)
@@ -0,0 +1,42 @@
1
+ extends ../layout
2
+ include ../mixins/status-badge
3
+ include ../mixins/log-viewer
4
+
5
+ block content
6
+ .d-flex.justify-content-between.align-items-center.mb-3
7
+ h1.h4.mb-0
8
+ = job.id
9
+ |
10
+ +jobBadge(job.state)
11
+ if canCancel
12
+ form(method="post" action=`/jobs/${job.id}/cancel`)
13
+ button.btn.btn-outline-danger.btn-sm(type="submit") Cancel
14
+
15
+ dl.row
16
+ dt.col-sm-2 Source
17
+ dd.col-sm-10.text-uppercase= job.source
18
+ if job.spec.user
19
+ dt.col-sm-2 User
20
+ dd.col-sm-10= job.spec.user
21
+ dt.col-sm-2 Resource
22
+ dd.col-sm-10= job.resource ? job.resource.name : '—'
23
+ dt.col-sm-2 Target
24
+ dd.col-sm-10= `${job.spec.target.type} — ${(job.spec.target.labels || []).join(', ')}`
25
+ dt.col-sm-2 Created
26
+ dd.col-sm-10= fmtDate(job.created_at)
27
+ if job.message
28
+ dt.col-sm-2 Message
29
+ dd.col-sm-10= job.message
30
+
31
+ h2.h6.mt-4 Log
32
+ +logViewer(job.id, jobActive)
33
+
34
+ h2.h6.mt-4#artifacts Artifacts
35
+ if artifacts.length
36
+ ul.list-group
37
+ each a in artifacts
38
+ li.list-group-item.d-flex.justify-content-between
39
+ span= a.name
40
+ a(href=a.url) download
41
+ else
42
+ p.text-body-secondary No artifacts yet.