@andrian.yablonskyy/thub-coordinator 1.0.37 → 1.0.39

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 (66) hide show
  1. package/bin/thub-admin.js +3 -5
  2. package/config.json +1 -6
  3. package/package.json +2 -2
  4. package/public/css/thub.css +129 -0
  5. package/public/js/config-import.js +37 -33
  6. package/public/js/copy-to-clipboard.js +21 -18
  7. package/public/js/help.js +93 -0
  8. package/public/js/list-search.js +51 -0
  9. package/public/js/live.js +287 -0
  10. package/public/js/log-viewer.js +289 -45
  11. package/public/js/remove-resource.js +5 -3
  12. package/public/js/resource-card.js +14 -13
  13. package/public/js/tooltips.js +3 -1
  14. package/scripts/install-default-config.js +1 -1
  15. package/scripts/install-target.js +2 -2
  16. package/src/api/admin.js +1 -1
  17. package/src/api/agent.js +4 -10
  18. package/src/api/resource.js +6 -11
  19. package/src/api/sse.js +13 -10
  20. package/src/config.js +16 -9
  21. package/src/db/migrations/022_add_sessions.sql +10 -0
  22. package/src/db/migrations/023_drop_artifacts.sql +4 -0
  23. package/src/dev/virtual.js +1 -1
  24. package/src/server.js +55 -21
  25. package/src/services/cleanup.js +2 -2
  26. package/src/services/jobs.js +33 -19
  27. package/src/services/list-prefs.js +4 -0
  28. package/src/services/live.js +124 -0
  29. package/src/services/logs.js +12 -14
  30. package/src/services/retention.js +5 -14
  31. package/src/services/search.js +59 -0
  32. package/src/services/session-store.js +127 -0
  33. package/src/web/routes.js +205 -34
  34. package/test/cleanup.test.js +1 -5
  35. package/test/list-prefs.test.js +6 -0
  36. package/test/live.test.js +153 -0
  37. package/test/logs.test.js +150 -0
  38. package/test/scheduler.test.js +10 -12
  39. package/test/search.test.js +97 -0
  40. package/test/session.test.js +132 -0
  41. package/test/views.test.js +30 -0
  42. package/views/admin/agents.pug +28 -10
  43. package/views/groups/list.pug +14 -1
  44. package/views/help/_agent-cli.pug +103 -0
  45. package/views/help/_agent-setup.pug +72 -0
  46. package/views/help/_ci.pug +44 -0
  47. package/views/help/_client-machines.pug +87 -0
  48. package/views/help/_client-setup.pug +122 -0
  49. package/views/help/_coordinator.pug +137 -0
  50. package/views/help/_docker.pug +124 -0
  51. package/views/help/_env.pug +127 -0
  52. package/views/help/_git.pug +130 -0
  53. package/views/help/_mixins.pug +24 -0
  54. package/views/help/_overview.pug +118 -0
  55. package/views/help/_quick-start.pug +37 -0
  56. package/views/help/_troubleshooting.pug +44 -0
  57. package/views/help/index.pug +67 -0
  58. package/views/index.pug +3 -2
  59. package/views/jobs/list.pug +8 -5
  60. package/views/jobs/show.pug +0 -10
  61. package/views/layout.pug +24 -1
  62. package/views/mixins/list-controls.pug +36 -0
  63. package/views/mixins/log-viewer.pug +39 -3
  64. package/views/mixins/resource.pug +1 -1
  65. package/views/resources/list.pug +7 -4
  66. package/src/services/artifacts.js +0 -149
package/src/web/routes.js CHANGED
@@ -16,6 +16,7 @@
16
16
  const fs = require('node:fs'),
17
17
  os = require('node:os'),
18
18
  path = require('node:path'),
19
+ { once } = require('node:events'),
19
20
  express = require('express'),
20
21
  multer = require('multer'),
21
22
  { requireAdminSession, requireAdminRole } = require('../auth'),
@@ -25,11 +26,16 @@ const fs = require('node:fs'),
25
26
  nextCronRun
26
27
  } = require('@andrian.yablonskyy/thub-common'),
27
28
  { retentionCutoff, JOB_RETENTION } = require('../services/cleanup'),
28
- listPrefs = require('../services/list-prefs');
29
+ listPrefs = require('../services/list-prefs'),
30
+ search = require('../services/search');
29
31
 
30
- // Resources page sort keys (list-prefs.js) -> comparators. Empty values
31
- // (never heartbeated, no version reported) sort last in both directions.
32
- const RESOURCE_SORT_VALUE = {
32
+ // Job log viewer: lines per page it loads (and the most one request may ask for).
33
+ const LOG_PAGE = 1000,
34
+ LOG_PAGE_MAX = 5000,
35
+
36
+ // Resources page sort keys (list-prefs.js) -> comparators. Empty values
37
+ // (never heartbeated, no version reported) sort last in both directions.
38
+ RESOURCE_SORT_VALUE = {
33
39
  name: (r) => r.name,
34
40
  type: (r) => r.type,
35
41
  version: (r) => r.client_version,
@@ -38,6 +44,15 @@ const RESOURCE_SORT_VALUE = {
38
44
  },
39
45
  compareResourceValues = (sort, a, b) =>
40
46
  sort === 'version' ? compareVersions(a, b) : String(a).localeCompare(String(b), undefined, { numeric: true }),
47
+ // Agents page (list-prefs `agents`): never-used / never-reported last.
48
+ AGENT_SORT_VALUE = {
49
+ name: (a) => a.name,
50
+ kind: (a) => a.kind,
51
+ version: (a) => a.version,
52
+ created: (a) => a.created_at,
53
+ used: (a) => a.last_used_at,
54
+ status: (a) => (a.revoked_at ? 'revoked' : 'active')
55
+ },
41
56
 
42
57
  // §10.1: avatar uploads are small, single images — a hard size cap and an
43
58
  // allow-list of image mimetypes, same spirit as the join-key/token checks
@@ -144,7 +159,7 @@ function createWebRouter({ services, config }){
144
159
  // error toast (never auto-closes) — on a page view, not the polling
145
160
  // endpoint or a POST whose redirect would drop it.
146
161
  const updateError = res.locals.updates.coordinatorUpdateError;
147
- if (updateError && user?.role === 'admin' && req.method === 'GET' && req.path !== '/updates/status' &&
162
+ if (updateError && user?.role === 'admin' && req.method === 'GET' && req.path !== '/updates/status' && !req.thubPassive &&
148
163
  req.session.seenUpdateError !== updateError.at){
149
164
  req.session.seenUpdateError = updateError.at;
150
165
  res.locals.messages = [
@@ -156,7 +171,9 @@ function createWebRouter({ services, config }){
156
171
  res.locals.nextCronRun = nextCronRun;
157
172
  res.locals.formatDateTime = formatDateTime;
158
173
  res.locals.currentPath = req.originalUrl;
159
- if (req.session){
174
+ // A live-update re-fetch (server.js, req.thubPassive) renders the page
175
+ // only for its data: flash messages stay for the next real page view.
176
+ if (req.session && !req.thubPassive){
160
177
  req.session.flash = [];
161
178
  }
162
179
 
@@ -181,21 +198,45 @@ function createWebRouter({ services, config }){
181
198
  // so a mid-session profile change takes effect immediately, and so
182
199
  // `rolling: true` (server.js) actually extends by *this* user's chosen
183
200
  // duration each time, not whatever the session happened to start with.
184
- if (req.session && user){
201
+ if (req.session && user && !req.thubPassive){
185
202
  req.session.cookie.maxAge = user.sessionTimeoutMin * 60 * 1000;
186
203
  }
204
+ if (req.thubPassive){
205
+ res.set('Cache-Control', 'no-store');
206
+ }
187
207
 
188
208
  next();
189
209
  });
190
210
 
211
+ // A Secure-only session cookie (server.js cookieSecure, §12) is never
212
+ // sent back over plain HTTP, so signing in would just loop back here.
213
+ // Say why instead — usually a proxy not passing X-Forwarded-Proto.
214
+ function httpsProblem(req){
215
+ if (req.session.cookie.secure !== true || req.secure){
216
+ return null;
217
+ }
218
+ return {
219
+ type: 'danger',
220
+ sticky: true,
221
+ text: `Signing in needs HTTPS: this Coordinator's session cookie is sent only over HTTPS (publicUrl is ${config.publicUrl}), ` +
222
+ 'but this request reached it as plain HTTP. Open the dashboard through that https:// address; behind a reverse proxy, ' +
223
+ 'make it send "X-Forwarded-Proto: https" and check trustProxy. To allow plain HTTP, set session.secureCookie to false.'
224
+ };
225
+ }
226
+
191
227
  router.get('/login', (req, res) => {
192
228
  if (req.session.user){
193
229
  return res.redirect('/');
194
230
  }
195
- res.render('login', { title: 'Sign in' });
231
+ const problem = httpsProblem(req);
232
+ res.render('login', { title: 'Sign in', ...(problem ? { messages: [problem] } : {}) });
196
233
  });
197
234
 
198
235
  router.post('/login', (req, res) => {
236
+ const problem = httpsProblem(req);
237
+ if (problem){
238
+ return res.status(400).render('login', { title: 'Sign in', messages: [problem] });
239
+ }
199
240
  const { username, password } = req.body,
200
241
  user = services.adminUsers.verify(username, password);
201
242
  if (!user){
@@ -308,6 +349,54 @@ function createWebRouter({ services, config }){
308
349
  res.redirect('/profile');
309
350
  });
310
351
 
352
+ // Live updates (services/live.js, public/js/live.js): which topics
353
+ // changed — `resources`, `agents`, `jobs` — never the data itself; the
354
+ // page re-fetches itself for that, with the same session and templates.
355
+ // A passive request (server.js): it doesn't extend the idle timeout, and
356
+ // it ends with `session-ended` once the session expires or logs out.
357
+ const LIVE_PING_MS = 25_000,
358
+ LIVE_SESSION_CHECK_MS = 15_000;
359
+ router.get('/live', (req, res) => {
360
+ res.writeHead(200, {
361
+ 'Content-Type': 'text/event-stream',
362
+ 'Cache-Control': 'no-cache, no-transform',
363
+ Connection: 'keep-alive',
364
+ 'X-Accel-Buffering': 'no'
365
+ });
366
+ res.flushHeaders?.();
367
+ const write = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
368
+ // retry: how soon EventSource reconnects after the connection drops.
369
+ res.write('retry: 5000\n\n');
370
+ write('hello', { topics: services.live.TOPICS });
371
+
372
+ const unsubscribe = services.live.subscribe((topics) => write('changed', { topics })),
373
+ // Comment lines keep proxies from timing the idle stream out.
374
+ ping = setInterval(() => res.write(': ping\n\n'), LIVE_PING_MS),
375
+ sessionCheck = setInterval(() => {
376
+ req.sessionStore.get(req.sessionID, (err, sess) => {
377
+ if (!err && !sess?.user){
378
+ write('session-ended', {});
379
+ res.end();
380
+ }
381
+ });
382
+ }, LIVE_SESSION_CHECK_MS);
383
+ req.on('close', () => {
384
+ unsubscribe();
385
+ clearInterval(ping);
386
+ clearInterval(sessionCheck);
387
+ });
388
+ });
389
+
390
+ // Help (views/help/): product guide and setup reference for every
391
+ // signed-in user. Examples use this Coordinator's URL — the configured
392
+ // publicUrl, unless that's still the localhost default and the page was
393
+ // opened from elsewhere.
394
+ router.get('/help', (req, res) => {
395
+ const origin = `${req.protocol}://${req.get('host')}`,
396
+ coordinatorUrl = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/?$/.test(config.publicUrl || '') ? origin : config.publicUrl;
397
+ res.render('help/index', { title: 'Help', active: 'help', coordinatorUrl: String(coordinatorUrl).replace(/\/+$/, '') });
398
+ });
399
+
311
400
  router.get('/', (req, res) => {
312
401
  const resources = withActiveJobs(services.registry.list()),
313
402
  queueLength = services.jobs.list({ state: JOB_STATES.QUEUED, limit: 1000 }).length,
@@ -317,15 +406,21 @@ function createWebRouter({ services, config }){
317
406
  .filter((j) => j.created_at >= dayAgo).length,
318
407
  onlineCount = resources.filter((r) => r.status !== RESOURCE_STATES.OUT_OF_SERVICE && r.status !== RESOURCE_STATES.REGISTERED).length,
319
408
  groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g]));
320
- res.render('index', { title: 'Overview', active: 'overview', resources, queueLength, jobsLast24h, onlineCount, groupsById });
409
+ res.render('index', { title: 'Overview', active: 'overview', liveTopics: 'resources jobs', resources, queueLength, jobsLast24h, onlineCount, groupsById });
321
410
  });
322
411
 
323
412
  router.get('/resources', (req, res) => {
324
413
  const groupsById = Object.fromEntries(services.groups.list().map((g) => [g.id, g])),
325
- view = listView(req, res, 'resources'),
414
+ q = search.normalizeQuery(req.query.q),
415
+ terms = search.parseTerms(q),
416
+ view = listView(req, res, 'resources', { q }),
326
417
  { sort, dir, size } = view.prefs,
327
418
  value = RESOURCE_SORT_VALUE[sort],
328
- sorted = services.registry.list().sort((a, b) => {
419
+ sorted = services.registry.list().filter((r) => search.matches([
420
+ r.id, r.name, r.reported_name, r.type, r.status, r.busy_source, r.busy_reason, r.client_version, r.remote_addr,
421
+ r.labels, r.group_ids, r.group_ids.map((id) => groupsById[id]?.name),
422
+ r.host_info?.hostname, (r.host_info?.addresses || []).map((a) => a.address)
423
+ ], terms)).sort((a, b) => {
329
424
  const va = value(a),
330
425
  vb = value(b);
331
426
  if (va == null || vb == null){
@@ -338,9 +433,10 @@ function createWebRouter({ services, config }){
338
433
  res.render('resources/list', {
339
434
  title: 'Resources',
340
435
  active: 'resources',
436
+ liveTopics: 'resources',
341
437
  resources: withActiveJobs(sorted.slice(pagination.offset, pagination.offset + pagination.limit)),
342
438
  groupsById,
343
- list: { ...view, pagination, pageUrl }
439
+ list: { ...view, pagination, pageUrl, q }
344
440
  });
345
441
  });
346
442
 
@@ -677,7 +773,11 @@ function createWebRouter({ services, config }){
677
773
  }));
678
774
 
679
775
  router.get('/jobs', (req, res) => {
680
- const filters = { state: req.query.state || undefined, source: req.query.source || undefined },
776
+ const filters = {
777
+ state: req.query.state || undefined,
778
+ source: req.query.source || undefined,
779
+ q: search.normalizeQuery(req.query.q) || undefined
780
+ },
681
781
  view = listView(req, res, 'jobs', filters),
682
782
  { rows, pagination } = services.jobs.page({ ...filters, ...view.prefs, page: req.query.page }),
683
783
  jobs = rows.map((j) => ({
@@ -689,9 +789,10 @@ function createWebRouter({ services, config }){
689
789
  res.render('jobs/list', {
690
790
  title: 'Jobs',
691
791
  active: 'jobs',
792
+ liveTopics: 'jobs',
692
793
  jobs,
693
794
  filters,
694
- list: { ...view, pagination, pageUrl },
795
+ list: { ...view, pagination, pageUrl, q: filters.q },
695
796
  // A row's Cancel comes back to this exact view (filters, sort, page).
696
797
  returnTo: pageUrl(),
697
798
  canManage: req.session.user.role === 'admin',
@@ -711,7 +812,7 @@ function createWebRouter({ services, config }){
711
812
  });
712
813
 
713
814
  // "Clean up database" (§9): everything before the given time — finished
714
- // jobs with their logs/artifacts, and older history — then VACUUM. The
815
+ // jobs with their logs, and older history — then VACUUM. The
715
816
  // time is dd/mm/yyyy HH:MM:SS in the user's own time zone.
716
817
  router.post('/jobs/clean-history', requireAdminRole, (req, res) => {
717
818
  const tz = req.session.user.timezone || 'UTC',
@@ -743,8 +844,7 @@ function createWebRouter({ services, config }){
743
844
  return res.status(404).send('Not found');
744
845
  }
745
846
  job.resource = job.resource_id ? services.registry.get(job.resource_id) : null;
746
- const artifacts = services.artifacts.listForJob(job.id).map((a) => ({ ...a, url: services.artifacts.signedUrl(a) })),
747
- jobActive = ACTIVE_JOB_STATES.has(job.state);
847
+ const jobActive = ACTIVE_JOB_STATES.has(job.state);
748
848
  res.render('jobs/show', {
749
849
  title: job.id,
750
850
  active: 'jobs',
@@ -754,7 +854,6 @@ function createWebRouter({ services, config }){
754
854
  ? services.registry.waitingReason(job.spec.target.type, job.spec.target.labels || [], job.spec.target.group,
755
855
  job.spec.target.client)
756
856
  : null,
757
- artifacts,
758
857
  jobActive,
759
858
  canCancel: req.session.user.role === 'admin' && jobActive
760
859
  });
@@ -772,15 +871,53 @@ function createWebRouter({ services, config }){
772
871
  res.redirect(returnTo(req, `/jobs/${req.params.id}`));
773
872
  });
774
873
 
775
- // Plain, one-shot fetch — used by the log viewer for a job that's
776
- // already finished, so it doesn't open a live connection (§SSE) for
777
- // output that will never change again.
874
+ // The log viewer's pages (public/js/log-viewer.js): the last `limit`
875
+ // lines first, then earlier ones (`before` = the oldest seq it has) as the
876
+ // reader scrolls up. `total` is how many lines the job has in all.
778
877
  router.get('/jobs/:id/logs', (req, res) => {
779
- res.json({ lines: services.logs.listSince(req.params.id, 0) });
878
+ const before = Number.parseInt(req.query.before, 10),
879
+ limit = Math.min(Math.max(Number.parseInt(req.query.limit, 10) || LOG_PAGE, 1), LOG_PAGE_MAX),
880
+ page = services.logs.pageBefore(req.params.id, before > 0 ? before : undefined, limit);
881
+ res.json({ ...page, total: services.logs.count(req.params.id) });
882
+ });
883
+
884
+ // RAW (log viewer): the whole log as plain text, for a new tab, a
885
+ // download or grep. Streamed in pages, waiting for the socket to drain,
886
+ // so a huge log never sits in memory. Log lines live as long as their job
887
+ // (§9), so this is always the complete log.
888
+ router.get('/jobs/:id/log.txt', async (req, res, next) => {
889
+ try {
890
+ const job = services.jobs.get(req.params.id);
891
+ if (!job){
892
+ return res.status(404).type('text/plain').send('Unknown job');
893
+ }
894
+ res.set({
895
+ 'Content-Type': 'text/plain; charset=utf-8',
896
+ // The log is whatever the job printed: never let a browser sniff it
897
+ // into HTML.
898
+ 'X-Content-Type-Options': 'nosniff',
899
+ 'Content-Disposition': `inline; filename="${job.id}.log"`,
900
+ 'Cache-Control': 'no-store'
901
+ });
902
+ for (let seq = 0, page; (page = services.logs.listSince(job.id, seq, LOG_PAGE_MAX)).length;){
903
+ const chunk = page.map((l) => `[${l.ts}] [${l.stream}] ${l.line}\n`).join('');
904
+ seq = page.at(-1).seq;
905
+ if (!res.write(chunk)){
906
+ await once(res, 'drain');
907
+ }
908
+ if (res.destroyed){
909
+ return;
910
+ }
911
+ }
912
+ res.end();
913
+ }
914
+ catch (err){
915
+ next(err);
916
+ }
780
917
  });
781
918
 
782
919
  router.get('/jobs/:id/stream', (req, res) => {
783
- attachJobStream(req, res, { jobId: req.params.id, services, config });
920
+ attachJobStream(req, res, { jobId: req.params.id, services });
784
921
  });
785
922
 
786
923
  // Resource groups (§13.1) — their own page since they're a distinct
@@ -788,14 +925,19 @@ function createWebRouter({ services, config }){
788
925
  // Client config) and Agents. Viewable by anyone logged in; only admins
789
926
  // can create/rename/delete.
790
927
  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
- }));
928
+ const q = search.normalizeQuery(req.query.q),
929
+ terms = search.parseTerms(q),
930
+ resources = services.registry.list(),
931
+ all = services.groups.list(),
932
+ groups = all
933
+ .filter((g) => search.matches([g.id, g.name, g.comment], terms))
934
+ .map((g) => ({ ...g, resourceCount: resources.filter((r) => r.group_ids.includes(g.id)).length }));
795
935
  res.render('groups/list', {
796
936
  title: 'Groups',
797
937
  active: 'groups',
798
938
  groups,
939
+ q,
940
+ totalCount: all.length,
799
941
  canManage: req.session.user.role === 'admin'
800
942
  });
801
943
  });
@@ -816,13 +958,42 @@ function createWebRouter({ services, config }){
816
958
  res.redirect('/groups');
817
959
  });
818
960
 
819
- router.get('/admin/agents', requireAdminRole, (req, res) => {
820
- res.render('admin/agents', { title: 'Agents', active: 'agents', agents: services.agents.list() });
821
- });
961
+ // Agents page: searched (?q=), sorted and paged like Resources (§10.1).
962
+ // Also what registering one renders, with its token shown once.
963
+ function renderAgents(req, res, extra = {}){
964
+ const q = search.normalizeQuery(req.query.q),
965
+ terms = search.parseTerms(q),
966
+ view = listView(req, res, 'agents', { q }),
967
+ { sort, dir, size } = view.prefs,
968
+ value = AGENT_SORT_VALUE[sort],
969
+ sorted = services.agents.list()
970
+ .filter((a) => search.matches([a.id, a.name, a.kind, a.version, a.revoked_at ? 'revoked' : 'active'], terms))
971
+ .sort((a, b) => {
972
+ const va = value(a),
973
+ vb = value(b);
974
+ if (va == null || vb == null){
975
+ return (va == null) - (vb == null);
976
+ }
977
+ return (dir === 'asc' ? 1 : -1) * compareResourceValues(sort, va, vb) || b.created_at.localeCompare(a.created_at);
978
+ }),
979
+ pagination = listPrefs.paginate(sorted.length, size, req.query.page),
980
+ pageUrl = (changes) => view.urlFor({ page: pagination.page, ...changes });
981
+ res.render('admin/agents', {
982
+ title: 'Agents',
983
+ active: 'agents',
984
+ liveTopics: 'agents',
985
+ agents: sorted.slice(pagination.offset, pagination.offset + pagination.limit),
986
+ q,
987
+ list: { ...view, pagination, pageUrl, q },
988
+ ...extra
989
+ });
990
+ }
991
+
992
+ router.get('/admin/agents', requireAdminRole, (req, res) => renderAgents(req, res));
822
993
 
823
994
  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 });
995
+ const { token } = services.agents.create({ name: req.body.name, kind: req.body.kind });
996
+ renderAgents(req, res, { newToken: token });
826
997
  });
827
998
 
828
999
  router.post('/admin/agents/:id/rename', requireAdminRole, (req, res) => {
@@ -836,12 +1007,12 @@ function createWebRouter({ services, config }){
836
1007
  catch (err){
837
1008
  flash(req, 'danger', err.message);
838
1009
  }
839
- res.redirect('/admin/agents');
1010
+ res.redirect(returnTo(req, '/admin/agents'));
840
1011
  });
841
1012
 
842
1013
  router.post('/admin/agents/:id/revoke', requireAdminRole, (req, res) => {
843
1014
  services.agents.revoke(req.params.id);
844
- res.redirect('/admin/agents');
1015
+ res.redirect(returnTo(req, '/admin/agents'));
845
1016
  });
846
1017
 
847
1018
  return router;
@@ -23,7 +23,6 @@ const test = require('node:test'),
23
23
  { createEventsService } = require('../src/services/events'),
24
24
  { createRegistryService } = require('../src/services/registry'),
25
25
  { createAgentsService } = require('../src/services/agents'),
26
- { createArtifactsService } = require('../src/services/artifacts'),
27
26
  { createJobsService } = require('../src/services/jobs'),
28
27
  { createCleanupService, retentionCutoff } = require('../src/services/cleanup'),
29
28
  { JOB_STATES } = require('@andrian.yablonskyy/thub-common');
@@ -35,8 +34,6 @@ function setup(jobRetention = 'forever'){
35
34
  config = {
36
35
  dataDir,
37
36
  dbPath: path.join(dataDir, 'thub.db'),
38
- artifactsDir: path.join(dataDir, 'artifacts'),
39
- artifacts: { linkTtlHours: 1 },
40
37
  sessionSecret: 's',
41
38
  publicUrl: 'http://x',
42
39
  scheduler: { maxQueuedPerAgent: 1000 },
@@ -48,8 +45,7 @@ function setup(jobRetention = 'forever'){
48
45
  events = createEventsService(db),
49
46
  registry = createRegistryService(db, { bus, events }),
50
47
  { agent } = createAgentsService(db, { events }).create({ name: 'ci', kind: 'ci' }),
51
- artifacts = createArtifactsService(db, { config }),
52
- jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
48
+ jobs = createJobsService(db, { bus, events, registry, config }),
53
49
  cleanup = createCleanupService(db, { jobs, config, now: () => NOW });
54
50
  registry.registerAuto({ clientId: 'c', name: 'lab', type: 'sw', labels: [] });
55
51
 
@@ -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
+ });