@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,343 @@
1
+ /**
2
+ * @file packages/coordinator/src/web/routes.js
3
+ * @description Dashboard (Pug) routes: overview, resources, groups, jobs, agents, profile pages (README §10)
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 fs = require('node:fs'),
17
+ os = require('node:os'),
18
+ path = require('node:path'),
19
+ express = require('express'),
20
+ multer = require('multer'),
21
+ { requireAdminSession, requireAdminRole } = require('../auth'),
22
+ { attachJobStream } = require('../api/sse'),
23
+ { RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES } = require('@andrian.yablonskyy/thub-common');
24
+
25
+ // §10.1: avatar uploads are small, single images — a hard size cap and an
26
+ // allow-list of image mimetypes, same spirit as the join-key/token checks
27
+ // elsewhere (reject outright rather than trying to sanitize).
28
+ const AVATAR_MAX_BYTES = 2 * 1024 * 1024,
29
+ AVATAR_EXT_BY_MIMETYPE = {
30
+ 'image/png': 'png',
31
+ 'image/jpeg': 'jpg',
32
+ 'image/gif': 'gif',
33
+ 'image/webp': 'webp'
34
+ },
35
+ avatarUpload = multer({ dest: os.tmpdir(), limits: { fileSize: AVATAR_MAX_BYTES } });
36
+
37
+ // §10 Web dashboard: server-rendered Pug + Bootstrap 5.3, with the live
38
+ // views hitting the same kind of SSE stream the Agent uses (§6.4), just
39
+ // authenticated by session cookie instead of a bearer token.
40
+ function createWebRouter({ services, config }){
41
+ const router = express.Router();
42
+
43
+ function flash(req, type, text){
44
+ req.session.flash = req.session.flash || [];
45
+ req.session.flash.push({ type, text });
46
+ }
47
+
48
+ router.use((req, res, next) => {
49
+ const user = req.session?.user || null;
50
+ res.locals.user = user;
51
+ res.locals.messages = req.session?.flash || [];
52
+ if (req.session){
53
+ req.session.flash = [];
54
+ }
55
+
56
+ // Every dashboard timestamp is rendered here on the server (Pug), so
57
+ // without this every user would see the *server's* local time — not
58
+ // their own (§10.1). Falls back to UTC for the (rare) pre-login page.
59
+ const tz = user?.timezone || 'UTC';
60
+ res.locals.fmtDate = (iso, fallback = '—') =>
61
+ iso ? new Date(iso).toLocaleString('en-US', { timeZone: tz }) : fallback;
62
+
63
+ // Idle timeout (§10.1): re-applied on every request (not just login)
64
+ // so a mid-session profile change takes effect immediately, and so
65
+ // `rolling: true` (server.js) actually extends by *this* user's chosen
66
+ // duration each time, not whatever the session happened to start with.
67
+ if (req.session && user){
68
+ req.session.cookie.maxAge = user.sessionTimeoutMin * 60 * 1000;
69
+ }
70
+
71
+ next();
72
+ });
73
+
74
+ router.get('/login', (req, res) => {
75
+ if (req.session.user){
76
+ return res.redirect('/');
77
+ }
78
+ res.render('login', { title: 'Sign in' });
79
+ });
80
+
81
+ router.post('/login', (req, res) => {
82
+ const { username, password } = req.body,
83
+ user = services.adminUsers.verify(username, password);
84
+ if (!user){
85
+ return res.status(401).render('login', { title: 'Sign in', error: 'Invalid credentials' });
86
+ }
87
+ req.session.user = user;
88
+ req.session.cookie.maxAge = user.sessionTimeoutMin * 60 * 1000;
89
+ res.redirect('/');
90
+ });
91
+
92
+ router.post('/logout', (req, res) => {
93
+ req.session.destroy(() => res.redirect('/login'));
94
+ });
95
+
96
+ router.use(requireAdminSession);
97
+
98
+ // §10.1: every logged-in user (admin or viewer) manages their own
99
+ // profile — display name, avatar, timezone, theme and idle session
100
+ // timeout. Not gated by requireAdminRole: this only ever touches the
101
+ // caller's own account (req.session.user.id), never another user's.
102
+ router.get('/profile', (req, res) => {
103
+ res.render('profile', {
104
+ title: 'Profile',
105
+ timezones: Intl.supportedValuesOf('timeZone'),
106
+ sessionTimeoutOptions: services.adminUsers.SESSION_TIMEOUT_OPTIONS_MIN
107
+ });
108
+ });
109
+
110
+ router.post('/profile', (req, res) => {
111
+ avatarUpload.single('avatar')(req, res, (uploadErr) => {
112
+ if (uploadErr){
113
+ flash(
114
+ req,
115
+ 'danger',
116
+ uploadErr.code === 'LIMIT_FILE_SIZE'
117
+ ? `Avatar image must be under ${AVATAR_MAX_BYTES / (1024 * 1024)}MB.`
118
+ : uploadErr.message
119
+ );
120
+ return res.redirect('/profile');
121
+ }
122
+
123
+ const userId = req.session.user.id,
124
+ fields = {
125
+ username: req.body.username,
126
+ firstName: req.body.firstName || null,
127
+ lastName: req.body.lastName || null,
128
+ timezone: req.body.timezone,
129
+ sessionTimeoutMin: Number(req.body.sessionTimeoutMin)
130
+ };
131
+
132
+ if (req.file){
133
+ const ext = AVATAR_EXT_BY_MIMETYPE[req.file.mimetype];
134
+ if (!ext){
135
+ fs.rmSync(req.file.path, { force: true });
136
+ flash(req, 'danger', `Unsupported image type "${req.file.mimetype}" — use PNG, JPEG, GIF or WebP.`);
137
+ }
138
+ else {
139
+ // Clear any previous avatar under a different extension so
140
+ // switching PNG -> JPEG doesn't leave the old file behind.
141
+ for (const e of Object.values(AVATAR_EXT_BY_MIMETYPE)){
142
+ fs.rmSync(path.join(config.avatarsDir, `${userId}.${e}`), { force: true });
143
+ }
144
+ fs.renameSync(req.file.path, path.join(config.avatarsDir, `${userId}.${ext}`));
145
+ fields.avatarPath = `/avatars/${userId}.${ext}`;
146
+ }
147
+ }
148
+
149
+ try {
150
+ req.session.user = services.adminUsers.updateProfile(userId, fields);
151
+ flash(req, 'success', 'Profile updated.');
152
+ }
153
+ catch (err){
154
+ flash(req, 'danger', err.message);
155
+ }
156
+ res.redirect('/profile');
157
+ });
158
+ });
159
+
160
+ // Quick theme toggle (navbar button, §10.1) — a separate, minimal
161
+ // endpoint so switching theme doesn't require resubmitting the whole
162
+ // profile form. Persists per-account so it's the same on every device,
163
+ // not just the browser that clicked it (public/js/theme.js).
164
+ router.post('/profile/theme', (req, res) => {
165
+ try {
166
+ req.session.user = services.adminUsers.updateProfile(req.session.user.id, { theme: req.body.theme });
167
+ res.json({ ok: true, theme: req.session.user.theme });
168
+ }
169
+ catch (err){
170
+ res.status(err.status || 400).json({ ok: false, error: err.message });
171
+ }
172
+ });
173
+
174
+ // Self-service password change (§10.1) — separate from the rest of the
175
+ // profile form since it needs the *current* password re-entered, and a
176
+ // mismatch between newPassword/confirmPassword is a form-level check
177
+ // that has nothing to do with the other fields.
178
+ router.post('/profile/password', (req, res) => {
179
+ const { currentPassword, newPassword, confirmPassword } = req.body;
180
+ if (newPassword !== confirmPassword){
181
+ flash(req, 'danger', 'New password and confirmation do not match.');
182
+ return res.redirect('/profile');
183
+ }
184
+ try {
185
+ services.adminUsers.changePassword(req.session.user.id, currentPassword, newPassword);
186
+ flash(req, 'success', 'Password changed.');
187
+ }
188
+ catch (err){
189
+ flash(req, 'danger', err.message);
190
+ }
191
+ res.redirect('/profile');
192
+ });
193
+
194
+ router.get('/', (req, res) => {
195
+ const resources = services.registry.list(),
196
+ queueLength = services.jobs.list({ state: JOB_STATES.QUEUED, limit: 1000 }).length,
197
+ dayAgo = new Date(Date.now() - 86400 * 1000).toISOString(),
198
+ jobsLast24h = services.jobs
199
+ .list({ limit: 1000 })
200
+ .filter((j) => j.created_at >= dayAgo).length,
201
+ onlineCount = resources.filter((r) => r.status !== RESOURCE_STATES.OUT_OF_SERVICE && r.status !== RESOURCE_STATES.REGISTERED).length;
202
+ res.render('index', { title: 'Overview', active: 'overview', resources, queueLength, jobsLast24h, onlineCount });
203
+ });
204
+
205
+ router.get('/resources', (req, res) => {
206
+ const groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g]));
207
+ res.render('resources/list', {
208
+ title: 'Resources',
209
+ active: 'resources',
210
+ resources: services.registry.list(),
211
+ groupsById
212
+ });
213
+ });
214
+
215
+ router.post('/resources/:id/maintenance', requireAdminRole, (req, res) => {
216
+ services.registry.setMaintenance(req.params.id, req.body.enabled === '1');
217
+ res.redirect('/resources');
218
+ });
219
+
220
+ router.post('/resources/:id/rotate-token', requireAdminRole, (req, res) => {
221
+ const { generateToken, hashToken } = require('../services/tokens');
222
+ const token = generateToken('res');
223
+ services.db.prepare('UPDATE resources SET token_hash = ? WHERE id = ?').run(hashToken(token), req.params.id);
224
+ services.events.record('resource', req.params.id, 'resource.token_rotated', {});
225
+ flash(req, 'warning', `New resource token (copy it now): ${token}`);
226
+ res.redirect('/resources');
227
+ });
228
+
229
+ router.get('/jobs', (req, res) => {
230
+ const filters = { state: req.query.state || undefined, source: req.query.source || undefined },
231
+ jobs = services.jobs.list({ ...filters, limit: 200 }).map((j) => ({
232
+ ...j,
233
+ resource: j.resource_id ? services.registry.get(j.resource_id) : null
234
+ }));
235
+ res.render('jobs/list', {
236
+ title: 'Jobs',
237
+ active: 'jobs',
238
+ jobs,
239
+ filters,
240
+ canManage: req.session.user.role === 'admin'
241
+ });
242
+ });
243
+
244
+ // "Reset" the queue: cancel everything active. "Clean": delete finished
245
+ // job history. Two distinct, deliberately separate destructive actions
246
+ // (§13.1) — reset stops in-flight work, clean clears past work.
247
+ router.post('/jobs/reset-queue', requireAdminRole, (req, res) => {
248
+ const canceled = services.jobs.resetQueue();
249
+ flash(req, 'warning', `Canceled ${canceled} active job(s).`);
250
+ res.redirect('/jobs');
251
+ });
252
+
253
+ router.post('/jobs/clean-history', requireAdminRole, (req, res) => {
254
+ const deleted = services.jobs.cleanHistory();
255
+ flash(req, 'warning', `Deleted ${deleted} finished job(s) and their logs/artifacts.`);
256
+ res.redirect('/jobs');
257
+ });
258
+
259
+ router.get('/jobs/:id', (req, res) => {
260
+ const job = services.jobs.get(req.params.id);
261
+ if (!job){
262
+ return res.status(404).send('Not found');
263
+ }
264
+ job.resource = job.resource_id ? services.registry.get(job.resource_id) : null;
265
+ const artifacts = services.artifacts.listForJob(job.id).map((a) => ({ ...a, url: services.artifacts.signedUrl(a) })),
266
+ jobActive = ACTIVE_JOB_STATES.has(job.state);
267
+ res.render('jobs/show', {
268
+ title: job.id,
269
+ active: 'jobs',
270
+ job,
271
+ artifacts,
272
+ jobActive,
273
+ canCancel: req.session.user.role === 'admin' && jobActive
274
+ });
275
+ });
276
+
277
+ router.post('/jobs/:id/cancel', requireAdminRole, (req, res) => {
278
+ services.jobs.cancel(req.params.id, { isAdmin: true });
279
+ res.redirect(`/jobs/${req.params.id}`);
280
+ });
281
+
282
+ // Plain, one-shot fetch — used by the log viewer for a job that's
283
+ // already finished, so it doesn't open a live connection (§SSE) for
284
+ // output that will never change again.
285
+ router.get('/jobs/:id/logs', (req, res) => {
286
+ res.json({ lines: services.logs.listSince(req.params.id, 0) });
287
+ });
288
+
289
+ router.get('/jobs/:id/stream', (req, res) => {
290
+ attachJobStream(req, res, { jobId: req.params.id, services, config });
291
+ });
292
+
293
+ // Resource groups (§13.1) — their own page since they're a distinct
294
+ // concept from both Resources (which declare membership in their own
295
+ // Client config) and Agents. Viewable by anyone logged in; only admins
296
+ // can create/rename/delete.
297
+ router.get('/groups', (req, res) => {
298
+ const groups = services.groups.list().map((g) => ({
299
+ ...g,
300
+ resourceCount: services.registry.list().filter((r) => r.group_ids.includes(g.id)).length
301
+ }));
302
+ res.render('groups/list', {
303
+ title: 'Groups',
304
+ active: 'groups',
305
+ groups,
306
+ canManage: req.session.user.role === 'admin'
307
+ });
308
+ });
309
+
310
+ router.post('/groups', requireAdminRole, (req, res) => {
311
+ services.groups.create({ name: req.body.name, comment: req.body.comment });
312
+ res.redirect('/groups');
313
+ });
314
+
315
+ router.post('/groups/:id', requireAdminRole, (req, res) => {
316
+ services.groups.update(req.params.id, { name: req.body.name, comment: req.body.comment });
317
+ res.redirect('/groups');
318
+ });
319
+
320
+ router.post('/groups/:id/delete', requireAdminRole, (req, res) => {
321
+ services.groups.remove(req.params.id);
322
+ flash(req, 'warning', 'Group deleted; any resource that listed it just stopped matching on it.');
323
+ res.redirect('/groups');
324
+ });
325
+
326
+ router.get('/admin/agents', requireAdminRole, (req, res) => {
327
+ res.render('admin/agents', { title: 'Agents', active: 'agents', agents: services.agents.list() });
328
+ });
329
+
330
+ router.post('/admin/agents', requireAdminRole, (req, res) => {
331
+ const { agent, token } = services.agents.create({ name: req.body.name, kind: req.body.kind });
332
+ res.render('admin/agents', { title: 'Agents', active: 'agents', agents: services.agents.list(), newToken: token });
333
+ });
334
+
335
+ router.post('/admin/agents/:id/revoke', requireAdminRole, (req, res) => {
336
+ services.agents.revoke(req.params.id);
337
+ res.redirect('/admin/agents');
338
+ });
339
+
340
+ return router;
341
+ }
342
+
343
+ module.exports = { createWebRouter };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @file packages/coordinator/test/admin-users.test.js
3
+ * @description Tests: admin user profile defaults, partial updates, and username/theme/timeout validation
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
+ { openDb } = require('../src/db'),
19
+ { createAdminUsersService } = require('../src/services/admin-users');
20
+
21
+ function buildService(){
22
+ const db = openDb(':memory:');
23
+ return createAdminUsersService(db);
24
+ }
25
+
26
+ test('a newly created user gets profile defaults (UTC, auto theme, 60min timeout)', () => {
27
+ const adminUsers = buildService();
28
+ adminUsers.create({ username: 'alice', password: 'hunter2', role: 'admin' });
29
+
30
+ const user = adminUsers.verify('alice', 'hunter2');
31
+ assert.equal(user.timezone, 'UTC');
32
+ assert.equal(user.theme, 'auto');
33
+ assert.equal(user.sessionTimeoutMin, 60);
34
+ assert.equal(user.firstName, null);
35
+ assert.equal(user.avatarPath, null);
36
+ });
37
+
38
+ test('updateProfile only touches the fields passed, leaving the rest as-is', () => {
39
+ const adminUsers = buildService(),
40
+ { id } = adminUsers.create({ username: 'bob', password: 'hunter2', role: 'viewer' });
41
+
42
+ adminUsers.updateProfile(id, { firstName: 'Bob', lastName: 'Builder', timezone: 'America/New_York' });
43
+ const afterFirst = adminUsers.getById(id);
44
+ assert.equal(afterFirst.firstName, 'Bob');
45
+ assert.equal(afterFirst.timezone, 'America/New_York');
46
+ assert.equal(afterFirst.username, 'bob'); // untouched
47
+
48
+ adminUsers.updateProfile(id, { theme: 'dark' });
49
+ const afterSecond = adminUsers.getById(id);
50
+ assert.equal(afterSecond.theme, 'dark');
51
+ assert.equal(afterSecond.firstName, 'Bob'); // still there — partial update didn't clobber it
52
+ });
53
+
54
+ test('renaming a user to a username already held by someone else is rejected (409)', () => {
55
+ const adminUsers = buildService();
56
+ adminUsers.create({ username: 'alice', password: 'x', role: 'admin' });
57
+ const { id: bobId } = adminUsers.create({ username: 'bob', password: 'x', role: 'viewer' });
58
+
59
+ assert.throws(() => adminUsers.updateProfile(bobId, { username: 'alice' }), (err) => err.status === 409);
60
+ });
61
+
62
+ test('renaming a user to their own current username is not a conflict', () => {
63
+ const adminUsers = buildService(),
64
+ { id } = adminUsers.create({ username: 'alice', password: 'x', role: 'admin' });
65
+
66
+ assert.doesNotThrow(() => adminUsers.updateProfile(id, { username: 'alice' }));
67
+ });
68
+
69
+ test('an invalid theme is rejected (400)', () => {
70
+ const adminUsers = buildService(),
71
+ { id } = adminUsers.create({ username: 'alice', password: 'x', role: 'admin' });
72
+
73
+ assert.throws(() => adminUsers.updateProfile(id, { theme: 'rainbow' }), (err) => err.status === 400);
74
+ });
75
+
76
+ test('a session timeout outside the offered presets is rejected (400)', () => {
77
+ const adminUsers = buildService(),
78
+ { id } = adminUsers.create({ username: 'alice', password: 'x', role: 'admin' });
79
+
80
+ assert.throws(() => adminUsers.updateProfile(id, { sessionTimeoutMin: 7 }), (err) => err.status === 400);
81
+ });
82
+
83
+ test('resetPassword creates the user as admin when the username does not exist yet', () => {
84
+ const adminUsers = buildService();
85
+
86
+ adminUsers.resetPassword({ username: 'admin', password: 'newpass' });
87
+ const user = adminUsers.verify('admin', 'newpass');
88
+ assert.ok(user);
89
+ assert.equal(user.role, 'admin');
90
+ });
91
+
92
+ test('resetPassword on an existing user changes only the password, not the role', () => {
93
+ const adminUsers = buildService();
94
+ adminUsers.create({ username: 'carol', password: 'oldpass', role: 'viewer' });
95
+
96
+ adminUsers.resetPassword({ username: 'carol', password: 'newpass' });
97
+
98
+ assert.equal(adminUsers.verify('carol', 'oldpass'), null);
99
+ const user = adminUsers.verify('carol', 'newpass');
100
+ assert.ok(user);
101
+ assert.equal(user.role, 'viewer'); // not silently promoted to admin
102
+ });
103
+
104
+ test('changePassword succeeds with the correct current password', () => {
105
+ const adminUsers = buildService(),
106
+ { id } = adminUsers.create({ username: 'dave', password: 'oldpass', role: 'admin' });
107
+
108
+ adminUsers.changePassword(id, 'oldpass', 'newpass');
109
+
110
+ assert.equal(adminUsers.verify('dave', 'oldpass'), null);
111
+ assert.ok(adminUsers.verify('dave', 'newpass'));
112
+ });
113
+
114
+ test('changePassword is rejected (401) with the wrong current password, leaving it unchanged', () => {
115
+ const adminUsers = buildService(),
116
+ { id } = adminUsers.create({ username: 'dave', password: 'oldpass', role: 'admin' });
117
+
118
+ assert.throws(() => adminUsers.changePassword(id, 'wrongpass', 'newpass'), (err) => err.status === 401);
119
+ assert.ok(adminUsers.verify('dave', 'oldpass'));
120
+ });
121
+
122
+ test('changePassword rejects an empty new password (400)', () => {
123
+ const adminUsers = buildService(),
124
+ { id } = adminUsers.create({ username: 'dave', password: 'oldpass', role: 'admin' });
125
+
126
+ assert.throws(() => adminUsers.changePassword(id, 'oldpass', ''), (err) => err.status === 400);
127
+ });