@andrian.yablonskyy/thub-coordinator 1.0.37 → 1.0.38

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 (44) hide show
  1. package/package.json +2 -2
  2. package/public/css/thub.css +129 -0
  3. package/public/js/config-import.js +37 -33
  4. package/public/js/copy-to-clipboard.js +21 -18
  5. package/public/js/help.js +93 -0
  6. package/public/js/list-search.js +51 -0
  7. package/public/js/live.js +287 -0
  8. package/public/js/log-viewer.js +123 -11
  9. package/public/js/remove-resource.js +5 -3
  10. package/public/js/resource-card.js +14 -13
  11. package/public/js/tooltips.js +3 -1
  12. package/src/server.js +20 -2
  13. package/src/services/jobs.js +29 -12
  14. package/src/services/list-prefs.js +4 -0
  15. package/src/services/live.js +124 -0
  16. package/src/services/search.js +59 -0
  17. package/src/web/routes.js +130 -21
  18. package/test/list-prefs.test.js +6 -0
  19. package/test/live.test.js +153 -0
  20. package/test/search.test.js +97 -0
  21. package/test/views.test.js +30 -0
  22. package/views/admin/agents.pug +28 -10
  23. package/views/groups/list.pug +14 -1
  24. package/views/help/_agent-cli.pug +103 -0
  25. package/views/help/_agent-setup.pug +72 -0
  26. package/views/help/_ci.pug +44 -0
  27. package/views/help/_client-machines.pug +87 -0
  28. package/views/help/_client-setup.pug +122 -0
  29. package/views/help/_coordinator.pug +134 -0
  30. package/views/help/_docker.pug +124 -0
  31. package/views/help/_env.pug +127 -0
  32. package/views/help/_git.pug +129 -0
  33. package/views/help/_mixins.pug +24 -0
  34. package/views/help/_overview.pug +118 -0
  35. package/views/help/_quick-start.pug +37 -0
  36. package/views/help/_troubleshooting.pug +42 -0
  37. package/views/help/index.pug +67 -0
  38. package/views/index.pug +3 -2
  39. package/views/jobs/list.pug +6 -3
  40. package/views/layout.pug +24 -1
  41. package/views/mixins/list-controls.pug +36 -0
  42. package/views/mixins/log-viewer.pug +25 -3
  43. package/views/mixins/resource.pug +1 -1
  44. package/views/resources/list.pug +7 -4
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/live.js
3
+ * @description Dashboard live updates: detects when what a list page shows (resources, agents, jobs) has changed and notifies subscribers
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 crypto = require('node:crypto'),
17
+ { ACTIVE_JOB_STATES } = require('@andrian.yablonskyy/thub-common');
18
+
19
+ // How the dashboard stays current (README §10): the page holds one
20
+ // GET /live stream that only says *which* topics changed; it then re-fetches
21
+ // itself and swaps the parts that differ (public/js/live.js). Rather than
22
+ // every place that changes a resource or agent having to announce it, a
23
+ // fingerprint of each topic is recomputed and compared — right away on bus
24
+ // events (a job starting or finishing shows up at once) and every
25
+ // `intervalMs` as the catch-all for everything else (heartbeats, the
26
+ // sweeper, maintenance, renames, update requests, …).
27
+ const TOPICS = ['resources', 'agents', 'jobs'],
28
+ BUS_EVENTS = ['job.queued', 'job.assigned', 'job.state', 'job.finished', 'resource.idle', 'resource.reregistered'];
29
+
30
+ function createLiveService(db, { bus, updates, intervalMs = 5000, debounceMs = 250 } = {}){
31
+ const subscribers = new Set(),
32
+ activeStates = [...ACTIVE_JOB_STATES],
33
+ hash = (value) => crypto.createHash('sha1').update(JSON.stringify(value)).digest('base64'),
34
+ // One cheap query per topic covering everything its pages display.
35
+ // Resources: every column (tens of rows) — last_heartbeat_at included,
36
+ // since the pages show it. Jobs: counts and latest times of the whole
37
+ // table (new, finished and deleted jobs) plus every active job's state.
38
+ queries = {
39
+ resources: () => [db.prepare('SELECT * FROM resources ORDER BY id').all(), updates?.status().latest],
40
+ agents: () => [
41
+ db.prepare('SELECT id, name, kind, version, update_to, last_used_at, revoked_at FROM agents ORDER BY id').all(),
42
+ updates?.status().latest
43
+ ],
44
+ jobs: () => [
45
+ db.prepare('SELECT COUNT(*) AS n, MAX(created_at) AS created, MAX(finished_at) AS finished FROM jobs').get(),
46
+ db.prepare(
47
+ `SELECT id, state, resource_id, message FROM jobs WHERE state IN (${activeStates.map(() => '?').join(',')}) ORDER BY id`
48
+ ).all(...activeStates)
49
+ ]
50
+ };
51
+ let fingerprints = null,
52
+ timer = null,
53
+ pending = null;
54
+
55
+ function compute(){
56
+ return Object.fromEntries(TOPICS.map((t) => [t, hash(queries[t]())]));
57
+ }
58
+
59
+ // Compare with the last fingerprints; tell every subscriber what changed.
60
+ function check(){
61
+ pending = null;
62
+ if (!subscribers.size){
63
+ return [];
64
+ }
65
+ const next = compute(),
66
+ changed = fingerprints ? TOPICS.filter((t) => next[t] !== fingerprints[t]) : [];
67
+ fingerprints = next;
68
+ if (changed.length){
69
+ for (const fn of subscribers){
70
+ try {
71
+ fn(changed);
72
+ }
73
+ catch {
74
+ // one broken subscriber (a closed stream) mustn't stop the others
75
+ }
76
+ }
77
+ }
78
+ return changed;
79
+ }
80
+
81
+ // Bus events arrive in bursts (state + log + finished): check once.
82
+ function soon(){
83
+ if (subscribers.size && !pending){
84
+ pending = setTimeout(check, debounceMs);
85
+ pending.unref?.();
86
+ }
87
+ }
88
+
89
+ function start(){
90
+ fingerprints = compute();
91
+ timer = setInterval(check, intervalMs);
92
+ timer.unref?.();
93
+ for (const e of BUS_EVENTS){
94
+ bus.on(e, soon);
95
+ }
96
+ }
97
+
98
+ function stop(){
99
+ clearInterval(timer);
100
+ clearTimeout(pending);
101
+ timer = pending = null;
102
+ fingerprints = null;
103
+ for (const e of BUS_EVENTS){
104
+ bus.off(e, soon);
105
+ }
106
+ }
107
+
108
+ // Nothing runs while no dashboard is open.
109
+ function subscribe(fn){
110
+ subscribers.add(fn);
111
+ if (subscribers.size === 1){
112
+ start();
113
+ }
114
+ return () => {
115
+ if (subscribers.delete(fn) && !subscribers.size){
116
+ stop();
117
+ }
118
+ };
119
+ }
120
+
121
+ return { subscribe, check, subscriberCount: () => subscribers.size, TOPICS };
122
+ }
123
+
124
+ module.exports = { createLiveService, TOPICS };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/search.js
3
+ * @description Dashboard search (?q=): splits the query into words; a record matches when every word occurs in one of its fields
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 MAX_QUERY = 200,
17
+ MAX_TERMS = 10;
18
+
19
+ // " Lab-HW nucleo " -> ['lab-hw', 'nucleo']. Case-insensitive, words in
20
+ // any order; capped so a pasted wall of text can't build a huge query.
21
+ function parseTerms(q){
22
+ if (typeof q !== 'string'){
23
+ return [];
24
+ }
25
+ return [...new Set(q.slice(0, MAX_QUERY).toLowerCase().split(/\s+/).filter(Boolean))].slice(0, MAX_TERMS);
26
+ }
27
+
28
+ // The query as shown back in the search box (trimmed, capped).
29
+ function normalizeQuery(q){
30
+ return typeof q === 'string' ? q.slice(0, MAX_QUERY).trim() : '';
31
+ }
32
+
33
+ // In-memory lists (resources, groups, agents): `fields` are the record's
34
+ // searchable values — strings, numbers, arrays of them, or null.
35
+ function matches(fields, terms){
36
+ if (!terms.length){
37
+ return true;
38
+ }
39
+ const text = fields.flat(Infinity).filter((v) => v !== null && v !== undefined && v !== '').join('\n').toLowerCase();
40
+ return terms.every((t) => text.includes(t));
41
+ }
42
+
43
+ // SQL lists (jobs): every term must match at least one of `expressions`
44
+ // (SQL expressions yielding text). LIKE is case-insensitive for ASCII in
45
+ // SQLite; % and _ in the query are matched literally.
46
+ function likeClause(expressions, terms){
47
+ if (!terms.length){
48
+ return { sql: '', params: [] };
49
+ }
50
+ const one = `(${expressions.map((e) => `COALESCE(${e}, '') LIKE ? ESCAPE '\\'`).join(' OR ')})`,
51
+ params = [];
52
+ for (const t of terms){
53
+ const pattern = `%${t.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
54
+ params.push(...expressions.map(() => pattern));
55
+ }
56
+ return { sql: terms.map(() => one).join(' AND '), params };
57
+ }
58
+
59
+ module.exports = { parseTerms, normalizeQuery, matches, likeClause, MAX_QUERY };
package/src/web/routes.js CHANGED
@@ -25,7 +25,8 @@ const fs = require('node:fs'),
25
25
  nextCronRun
26
26
  } = require('@andrian.yablonskyy/thub-common'),
27
27
  { retentionCutoff, JOB_RETENTION } = require('../services/cleanup'),
28
- listPrefs = require('../services/list-prefs');
28
+ listPrefs = require('../services/list-prefs'),
29
+ search = require('../services/search');
29
30
 
30
31
  // Resources page sort keys (list-prefs.js) -> comparators. Empty values
31
32
  // (never heartbeated, no version reported) sort last in both directions.
@@ -38,6 +39,15 @@ const RESOURCE_SORT_VALUE = {
38
39
  },
39
40
  compareResourceValues = (sort, a, b) =>
40
41
  sort === 'version' ? compareVersions(a, b) : String(a).localeCompare(String(b), undefined, { numeric: true }),
42
+ // Agents page (list-prefs `agents`): never-used / never-reported last.
43
+ AGENT_SORT_VALUE = {
44
+ name: (a) => a.name,
45
+ kind: (a) => a.kind,
46
+ version: (a) => a.version,
47
+ created: (a) => a.created_at,
48
+ used: (a) => a.last_used_at,
49
+ status: (a) => (a.revoked_at ? 'revoked' : 'active')
50
+ },
41
51
 
42
52
  // §10.1: avatar uploads are small, single images — a hard size cap and an
43
53
  // allow-list of image mimetypes, same spirit as the join-key/token checks
@@ -144,7 +154,7 @@ function createWebRouter({ services, config }){
144
154
  // error toast (never auto-closes) — on a page view, not the polling
145
155
  // endpoint or a POST whose redirect would drop it.
146
156
  const updateError = res.locals.updates.coordinatorUpdateError;
147
- if (updateError && user?.role === 'admin' && req.method === 'GET' && req.path !== '/updates/status' &&
157
+ if (updateError && user?.role === 'admin' && req.method === 'GET' && req.path !== '/updates/status' && !req.thubPassive &&
148
158
  req.session.seenUpdateError !== updateError.at){
149
159
  req.session.seenUpdateError = updateError.at;
150
160
  res.locals.messages = [
@@ -156,7 +166,9 @@ function createWebRouter({ services, config }){
156
166
  res.locals.nextCronRun = nextCronRun;
157
167
  res.locals.formatDateTime = formatDateTime;
158
168
  res.locals.currentPath = req.originalUrl;
159
- if (req.session){
169
+ // A live-update re-fetch (server.js, req.thubPassive) renders the page
170
+ // only for its data: flash messages stay for the next real page view.
171
+ if (req.session && !req.thubPassive){
160
172
  req.session.flash = [];
161
173
  }
162
174
 
@@ -181,9 +193,12 @@ function createWebRouter({ services, config }){
181
193
  // so a mid-session profile change takes effect immediately, and so
182
194
  // `rolling: true` (server.js) actually extends by *this* user's chosen
183
195
  // duration each time, not whatever the session happened to start with.
184
- if (req.session && user){
196
+ if (req.session && user && !req.thubPassive){
185
197
  req.session.cookie.maxAge = user.sessionTimeoutMin * 60 * 1000;
186
198
  }
199
+ if (req.thubPassive){
200
+ res.set('Cache-Control', 'no-store');
201
+ }
187
202
 
188
203
  next();
189
204
  });
@@ -308,6 +323,54 @@ function createWebRouter({ services, config }){
308
323
  res.redirect('/profile');
309
324
  });
310
325
 
326
+ // Live updates (services/live.js, public/js/live.js): which topics
327
+ // changed — `resources`, `agents`, `jobs` — never the data itself; the
328
+ // page re-fetches itself for that, with the same session and templates.
329
+ // A passive request (server.js): it doesn't extend the idle timeout, and
330
+ // it ends with `session-ended` once the session expires or logs out.
331
+ const LIVE_PING_MS = 25_000,
332
+ LIVE_SESSION_CHECK_MS = 15_000;
333
+ router.get('/live', (req, res) => {
334
+ res.writeHead(200, {
335
+ 'Content-Type': 'text/event-stream',
336
+ 'Cache-Control': 'no-cache, no-transform',
337
+ Connection: 'keep-alive',
338
+ 'X-Accel-Buffering': 'no'
339
+ });
340
+ res.flushHeaders?.();
341
+ const write = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
342
+ // retry: how soon EventSource reconnects after the connection drops.
343
+ res.write('retry: 5000\n\n');
344
+ write('hello', { topics: services.live.TOPICS });
345
+
346
+ const unsubscribe = services.live.subscribe((topics) => write('changed', { topics })),
347
+ // Comment lines keep proxies from timing the idle stream out.
348
+ ping = setInterval(() => res.write(': ping\n\n'), LIVE_PING_MS),
349
+ sessionCheck = setInterval(() => {
350
+ req.sessionStore.get(req.sessionID, (err, sess) => {
351
+ if (!err && !sess?.user){
352
+ write('session-ended', {});
353
+ res.end();
354
+ }
355
+ });
356
+ }, LIVE_SESSION_CHECK_MS);
357
+ req.on('close', () => {
358
+ unsubscribe();
359
+ clearInterval(ping);
360
+ clearInterval(sessionCheck);
361
+ });
362
+ });
363
+
364
+ // Help (views/help/): product guide and setup reference for every
365
+ // signed-in user. Examples use this Coordinator's URL — the configured
366
+ // publicUrl, unless that's still the localhost default and the page was
367
+ // opened from elsewhere.
368
+ router.get('/help', (req, res) => {
369
+ const origin = `${req.protocol}://${req.get('host')}`,
370
+ coordinatorUrl = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/?$/.test(config.publicUrl || '') ? origin : config.publicUrl;
371
+ res.render('help/index', { title: 'Help', active: 'help', coordinatorUrl: String(coordinatorUrl).replace(/\/+$/, '') });
372
+ });
373
+
311
374
  router.get('/', (req, res) => {
312
375
  const resources = withActiveJobs(services.registry.list()),
313
376
  queueLength = services.jobs.list({ state: JOB_STATES.QUEUED, limit: 1000 }).length,
@@ -317,15 +380,21 @@ function createWebRouter({ services, config }){
317
380
  .filter((j) => j.created_at >= dayAgo).length,
318
381
  onlineCount = resources.filter((r) => r.status !== RESOURCE_STATES.OUT_OF_SERVICE && r.status !== RESOURCE_STATES.REGISTERED).length,
319
382
  groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g]));
320
- res.render('index', { title: 'Overview', active: 'overview', resources, queueLength, jobsLast24h, onlineCount, groupsById });
383
+ res.render('index', { title: 'Overview', active: 'overview', liveTopics: 'resources jobs', resources, queueLength, jobsLast24h, onlineCount, groupsById });
321
384
  });
322
385
 
323
386
  router.get('/resources', (req, res) => {
324
387
  const groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g])),
325
- view = listView(req, res, 'resources'),
388
+ q = search.normalizeQuery(req.query.q),
389
+ terms = search.parseTerms(q),
390
+ view = listView(req, res, 'resources', { q }),
326
391
  { sort, dir, size } = view.prefs,
327
392
  value = RESOURCE_SORT_VALUE[sort],
328
- sorted = services.registry.list().sort((a, b) => {
393
+ sorted = services.registry.list().filter((r) => search.matches([
394
+ r.id, r.name, r.reported_name, r.type, r.status, r.busy_source, r.busy_reason, r.client_version, r.remote_addr,
395
+ r.labels, r.group_ids, r.group_ids.map((id) => groupsById[id]?.name),
396
+ r.host_info?.hostname, (r.host_info?.addresses || []).map((a) => a.address)
397
+ ], terms)).sort((a, b) => {
329
398
  const va = value(a),
330
399
  vb = value(b);
331
400
  if (va == null || vb == null){
@@ -338,9 +407,10 @@ function createWebRouter({ services, config }){
338
407
  res.render('resources/list', {
339
408
  title: 'Resources',
340
409
  active: 'resources',
410
+ liveTopics: 'resources',
341
411
  resources: withActiveJobs(sorted.slice(pagination.offset, pagination.offset + pagination.limit)),
342
412
  groupsById,
343
- list: { ...view, pagination, pageUrl }
413
+ list: { ...view, pagination, pageUrl, q }
344
414
  });
345
415
  });
346
416
 
@@ -677,7 +747,11 @@ function createWebRouter({ services, config }){
677
747
  }));
678
748
 
679
749
  router.get('/jobs', (req, res) => {
680
- const filters = { state: req.query.state || undefined, source: req.query.source || undefined },
750
+ const filters = {
751
+ state: req.query.state || undefined,
752
+ source: req.query.source || undefined,
753
+ q: search.normalizeQuery(req.query.q) || undefined
754
+ },
681
755
  view = listView(req, res, 'jobs', filters),
682
756
  { rows, pagination } = services.jobs.page({ ...filters, ...view.prefs, page: req.query.page }),
683
757
  jobs = rows.map((j) => ({
@@ -689,9 +763,10 @@ function createWebRouter({ services, config }){
689
763
  res.render('jobs/list', {
690
764
  title: 'Jobs',
691
765
  active: 'jobs',
766
+ liveTopics: 'jobs',
692
767
  jobs,
693
768
  filters,
694
- list: { ...view, pagination, pageUrl },
769
+ list: { ...view, pagination, pageUrl, q: filters.q },
695
770
  // A row's Cancel comes back to this exact view (filters, sort, page).
696
771
  returnTo: pageUrl(),
697
772
  canManage: req.session.user.role === 'admin',
@@ -788,14 +863,19 @@ function createWebRouter({ services, config }){
788
863
  // Client config) and Agents. Viewable by anyone logged in; only admins
789
864
  // can create/rename/delete.
790
865
  router.get('/groups', (req, res) => {
791
- const groups = services.groups.list().map((g) => ({
792
- ...g,
793
- resourceCount: services.registry.list().filter((r) => r.group_ids.includes(g.id)).length
794
- }));
866
+ const q = search.normalizeQuery(req.query.q),
867
+ terms = search.parseTerms(q),
868
+ resources = services.registry.list(),
869
+ all = services.groups.list(),
870
+ groups = all
871
+ .filter((g) => search.matches([g.id, g.name, g.comment], terms))
872
+ .map((g) => ({ ...g, resourceCount: resources.filter((r) => r.group_ids.includes(g.id)).length }));
795
873
  res.render('groups/list', {
796
874
  title: 'Groups',
797
875
  active: 'groups',
798
876
  groups,
877
+ q,
878
+ totalCount: all.length,
799
879
  canManage: req.session.user.role === 'admin'
800
880
  });
801
881
  });
@@ -816,13 +896,42 @@ function createWebRouter({ services, config }){
816
896
  res.redirect('/groups');
817
897
  });
818
898
 
819
- router.get('/admin/agents', requireAdminRole, (req, res) => {
820
- res.render('admin/agents', { title: 'Agents', active: 'agents', agents: services.agents.list() });
821
- });
899
+ // Agents page: searched (?q=), sorted and paged like Resources (§10.1).
900
+ // Also what registering one renders, with its token shown once.
901
+ function renderAgents(req, res, extra = {}){
902
+ const q = search.normalizeQuery(req.query.q),
903
+ terms = search.parseTerms(q),
904
+ view = listView(req, res, 'agents', { q }),
905
+ { sort, dir, size } = view.prefs,
906
+ value = AGENT_SORT_VALUE[sort],
907
+ sorted = services.agents.list()
908
+ .filter((a) => search.matches([a.id, a.name, a.kind, a.version, a.revoked_at ? 'revoked' : 'active'], terms))
909
+ .sort((a, b) => {
910
+ const va = value(a),
911
+ vb = value(b);
912
+ if (va == null || vb == null){
913
+ return (va == null) - (vb == null);
914
+ }
915
+ return (dir === 'asc' ? 1 : -1) * compareResourceValues(sort, va, vb) || b.created_at.localeCompare(a.created_at);
916
+ }),
917
+ pagination = listPrefs.paginate(sorted.length, size, req.query.page),
918
+ pageUrl = (changes) => view.urlFor({ page: pagination.page, ...changes });
919
+ res.render('admin/agents', {
920
+ title: 'Agents',
921
+ active: 'agents',
922
+ liveTopics: 'agents',
923
+ agents: sorted.slice(pagination.offset, pagination.offset + pagination.limit),
924
+ q,
925
+ list: { ...view, pagination, pageUrl, q },
926
+ ...extra
927
+ });
928
+ }
929
+
930
+ router.get('/admin/agents', requireAdminRole, (req, res) => renderAgents(req, res));
822
931
 
823
932
  router.post('/admin/agents', requireAdminRole, (req, res) => {
824
- const { agent, token } = services.agents.create({ name: req.body.name, kind: req.body.kind });
825
- res.render('admin/agents', { title: 'Agents', active: 'agents', agents: services.agents.list(), newToken: token });
933
+ const { token } = services.agents.create({ name: req.body.name, kind: req.body.kind });
934
+ renderAgents(req, res, { newToken: token });
826
935
  });
827
936
 
828
937
  router.post('/admin/agents/:id/rename', requireAdminRole, (req, res) => {
@@ -836,12 +945,12 @@ function createWebRouter({ services, config }){
836
945
  catch (err){
837
946
  flash(req, 'danger', err.message);
838
947
  }
839
- res.redirect('/admin/agents');
948
+ res.redirect(returnTo(req, '/admin/agents'));
840
949
  });
841
950
 
842
951
  router.post('/admin/agents/:id/revoke', requireAdminRole, (req, res) => {
843
952
  services.agents.revoke(req.params.id);
844
- res.redirect('/admin/agents');
953
+ res.redirect(returnTo(req, '/admin/agents'));
845
954
  });
846
955
 
847
956
  return router;
@@ -87,3 +87,9 @@ test('jobs.page sorts and pages in SQL, with empty values last', () => {
87
87
  assert.equal(second.pagination.pages, 2);
88
88
  assert.equal(jobs.page({ state: 'PASSED' }).pagination.total, 0);
89
89
  });
90
+
91
+ test('agents list: sortable columns, newest first by default', () => {
92
+ assert.deepEqual(normalize('agents'), { size: 25, sort: 'created', dir: 'desc' });
93
+ assert.deepEqual(normalize('agents', { size: 10, sort: 'used', dir: 'asc' }), { size: 10, sort: 'used', dir: 'asc' });
94
+ assert.equal(normalize('agents', { sort: 'token' }).sort, 'created'); // not a column: default
95
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @file packages/coordinator/test/live.test.js
3
+ * @description Tests: dashboard live updates — change detection per topic, the GET /live stream, and passive re-fetches that leave the session alone
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
+ { openDb } = require('../src/db'),
23
+ { createEventsService } = require('../src/services/events'),
24
+ { createRegistryService } = require('../src/services/registry'),
25
+ { createAgentsService } = require('../src/services/agents'),
26
+ { createLiveService } = require('../src/services/live'),
27
+ { loadConfig } = require('../src/config'),
28
+ { buildServices, createApp } = require('../src/server');
29
+
30
+ test('live: reports exactly the topics whose data changed, only while subscribed', async () => {
31
+ const db = openDb(':memory:'),
32
+ bus = new EventEmitter(),
33
+ events = createEventsService(db),
34
+ registry = createRegistryService(db, { bus, events }),
35
+ agents = createAgentsService(db, { events }),
36
+ live = createLiveService(db, { bus, intervalMs: 60_000, debounceMs: 10 }),
37
+ seen = [];
38
+
39
+ assert.deepEqual(live.check(), []); // nobody listening: nothing computed
40
+ const unsubscribe = live.subscribe((topics) => seen.push(topics));
41
+ assert.equal(live.subscriberCount(), 1);
42
+ assert.deepEqual(live.check(), []); // nothing changed yet
43
+
44
+ registry.registerAuto({ clientId: 'c1', name: 'lab-hw-01', type: 'hw', labels: [] });
45
+ assert.deepEqual(live.check(), ['resources']);
46
+ agents.create({ name: 'ci', kind: 'ci' });
47
+ assert.deepEqual(live.check(), ['agents']);
48
+ assert.deepEqual(live.check(), []);
49
+
50
+ // A bus event triggers a check by itself (debounced), without the timer.
51
+ db.prepare('INSERT INTO jobs (id, agent_id, source, state, spec, priority, attempt, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
52
+ .run('M-00001', agents.list()[0].id, 'cli', 'QUEUED', '{}', 60, 0, new Date().toISOString());
53
+ bus.emit('job.queued', { jobId: 'M-00001' });
54
+ await new Promise((r) => setTimeout(r, 50));
55
+ assert.deepEqual(seen.at(-1), ['jobs']);
56
+
57
+ unsubscribe();
58
+ assert.equal(live.subscriberCount(), 0);
59
+ assert.equal(bus.listenerCount('job.queued'), 0); // stopped listening
60
+ });
61
+
62
+ // --- over HTTP: /live and passive re-fetches ------------------------------
63
+
64
+ function startApp(t){
65
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-live-')),
66
+ file = path.join(dir, 'coordinator.json');
67
+ fs.writeFileSync(file, JSON.stringify({
68
+ dataDir: path.join(dir, 'data'), sessionSecret: 'test-secret-test-secret', updates: { checkIntervalMin: 0 }
69
+ }));
70
+ const config = loadConfig(file),
71
+ services = buildServices(config),
72
+ server = createApp(config, services).listen(0, '127.0.0.1');
73
+ services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
74
+ t.after(() => new Promise((r) => {
75
+ server.closeAllConnections?.();
76
+ server.close(r);
77
+ }));
78
+ return new Promise((r) => server.on('listening', () => r({ base: `http://127.0.0.1:${server.address().port}`, services })));
79
+ }
80
+
81
+ const expiresOf = (res) => {
82
+ const m = /Expires=([^;]+)/.exec(res.headers.get('set-cookie') || '');
83
+ return m ? Date.parse(m[1]) : null;
84
+ };
85
+
86
+ async function login(base){
87
+ const res = await fetch(`${base}/login`, {
88
+ method: 'POST',
89
+ body: new URLSearchParams({ username: 'admin', password: 'pw' }),
90
+ redirect: 'manual'
91
+ });
92
+ return { cookie: res.headers.get('set-cookie').split(';')[0], expires: expiresOf(res) };
93
+ }
94
+
95
+ test('live: /live streams hello and changes; passive re-fetches keep the idle timeout and flash', async (t) => {
96
+ const { base, services } = await startApp(t),
97
+ { cookie, expires } = await login(base);
98
+ assert.ok(expires);
99
+
100
+ // The stream: hello, then `changed` when a resource appears.
101
+ const ctrl = new AbortController(),
102
+ stream = await fetch(`${base}/live`, { headers: { cookie }, signal: ctrl.signal }),
103
+ reader = stream.body.getReader(),
104
+ decoder = new TextDecoder();
105
+ assert.equal(stream.headers.get('content-type'), 'text/event-stream');
106
+ let text = '';
107
+ const until = async (re) => {
108
+ while (!re.test(text)){
109
+ const { value, done } = await reader.read();
110
+ if (done){
111
+ break;
112
+ }
113
+ text += decoder.decode(value);
114
+ }
115
+ return re.exec(text);
116
+ };
117
+ assert.ok(await until(/event: hello/));
118
+ assert.equal(services.live.subscriberCount(), 1);
119
+ services.registry.registerAuto({ clientId: 'c1', name: 'lab-sw-01', type: 'sw', labels: [] });
120
+ services.live.check();
121
+ assert.match((await until(/event: changed\ndata: (.*)\n/))[1], /"resources"/);
122
+ ctrl.abort();
123
+
124
+ // A flash waiting for the next page view (a real request: it sets the
125
+ // expiry the passive one below must leave alone)…
126
+ const flashing = await fetch(`${base}/profile/password`, {
127
+ method: 'POST', headers: { cookie }, body: new URLSearchParams({ currentPassword: 'wrong', newPassword: 'a', confirmPassword: 'b' }),
128
+ redirect: 'manual'
129
+ }),
130
+ lastActive = expiresOf(flashing);
131
+ assert.ok(lastActive >= expires);
132
+ // Wait past a whole second, so a refreshed expiry would differ.
133
+ await new Promise((r) => setTimeout(r, 1100));
134
+
135
+ // …survives a passive re-fetch, which also doesn't push the expiry out.
136
+ const passive = await fetch(`${base}/resources`, { headers: { cookie, 'X-Thub-Live': '1' } });
137
+ assert.equal(passive.status, 200);
138
+ assert.match(passive.headers.get('cache-control'), /no-store/);
139
+ const passiveExpires = expiresOf(passive);
140
+ assert.ok(passiveExpires === null || passiveExpires === lastActive, 'passive request must not extend the session');
141
+ assert.match(await passive.text(), /data-live="rows"/);
142
+
143
+ // A real page view gets the flash, and extends the session as before.
144
+ const real = await fetch(`${base}/profile`, { headers: { cookie } });
145
+ assert.ok(expiresOf(real) > lastActive);
146
+ assert.match(await real.text(), /data-thub-toast="danger"/);
147
+
148
+ // Signed out: the stream is refused (EventSource then stops), and a
149
+ // passive re-fetch is redirected to the login page.
150
+ await fetch(`${base}/logout`, { method: 'POST', headers: { cookie }, redirect: 'manual' });
151
+ assert.equal((await fetch(`${base}/live`, { headers: { cookie, accept: 'text/event-stream' } })).status, 401);
152
+ assert.equal((await fetch(`${base}/resources`, { headers: { cookie, 'X-Thub-Live': '1' }, redirect: 'manual' })).status, 302);
153
+ });