@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
@@ -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 };
@@ -15,7 +15,7 @@
15
15
 
16
16
  // §3.1 Log service: accepts batched log lines, assigns a monotonically
17
17
  // increasing seq per job, stores them, and fans them out over the bus for
18
- // SSE subscribers (§6.4). Retention is handled separately (§9).
18
+ // SSE subscribers (§6.4). Lines are deleted with their job (§9).
19
19
  function createLogsService(db, { bus }){
20
20
  const insert = db.prepare(
21
21
  'INSERT INTO job_logs (job_id, seq, ts, stream, line) VALUES (?, ?, ?, ?, ?)'
@@ -47,24 +47,22 @@ function createLogsService(db, { bus }){
47
47
  .all(jobId, afterSeq, limit);
48
48
  }
49
49
 
50
- // Used when a job finishes: fold job_logs into a flat console.log artifact.
51
- function renderConsoleLog(jobId){
50
+ // The dashboard log viewer's pages (views/mixins/log-viewer.pug): the
51
+ // `limit` lines just before `beforeSeq` (the end of the log when omitted),
52
+ // oldest first, and whether there are earlier ones to scroll up to.
53
+ function pageBefore(jobId, beforeSeq, limit){
52
54
  const rows = db
53
- .prepare('SELECT * FROM job_logs WHERE job_id = ? ORDER BY seq ASC')
54
- .all(jobId);
55
- return rows.map((r) => `[${r.ts}] [${r.stream}] ${r.line}`).join('\n') + (rows.length ? '\n' : '');
55
+ .prepare('SELECT seq, ts, stream, line FROM job_logs WHERE job_id = ? AND seq < ? ORDER BY seq DESC LIMIT ?')
56
+ .all(jobId, beforeSeq ?? Number.MAX_SAFE_INTEGER, limit + 1),
57
+ hasMore = rows.length > limit;
58
+ return { lines: rows.slice(0, limit).reverse(), hasMore };
56
59
  }
57
60
 
58
- function purgeOlderThan(days){
59
- const cutoff = new Date(Date.now() - days * 86400 * 1000).toISOString();
60
- db.prepare(
61
- `DELETE FROM job_logs WHERE job_id IN (
62
- SELECT id FROM jobs WHERE finished_at IS NOT NULL AND finished_at <= ?
63
- )`
64
- ).run(cutoff);
61
+ function count(jobId){
62
+ return db.prepare('SELECT COUNT(*) AS n FROM job_logs WHERE job_id = ?').get(jobId).n;
65
63
  }
66
64
 
67
- return { appendBatch, listSince, renderConsoleLog, purgeOlderThan };
65
+ return { appendBatch, listSince, pageBefore, count };
68
66
  }
69
67
 
70
68
  module.exports = { createLogsService };
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file packages/coordinator/src/services/retention.js
3
- * @description Deletes finished jobs' logs/artifacts past their retention window (README §9)
3
+ * @description Nightly consistent database backup (VACUUM INTO, README §9)
4
4
  *
5
5
  * @author Andrian Yablonskyy
6
6
  * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
@@ -17,20 +17,11 @@ const path = require('node:path');
17
17
 
18
18
  const DAY_MS = 86400 * 1000;
19
19
 
20
- // §9: fold job_logs into a console.log artifact when a job finishes, purge
21
- // old logs/artifacts on the configured retention windows, and take a nightly
22
- // consistent backup via VACUUM INTO.
23
- function createRetentionService(db, { bus, logs, artifacts, config }){
24
- bus.on('job.finished', ({ jobId }) => {
25
- const text = logs.renderConsoleLog(jobId);
26
- if (text){
27
- artifacts.storeGenerated(jobId, 'console.log', Buffer.from(text, 'utf8'), 'text/plain');
28
- }
29
- });
30
-
20
+ // §9: a nightly consistent backup via VACUUM INTO. (A job's log lines live
21
+ // as long as the job itself — retention.jobRetention / Clean up database,
22
+ // services/cleanup.js — and there are no artifacts to purge any more.)
23
+ function createRetentionService(db, { config }){
31
24
  function runDaily(){
32
- logs.purgeOlderThan(config.retention.logRetentionDays);
33
- artifacts.purgeOlderThan(config.retention.artifactRetentionDays);
34
25
  const backupPath = path.join(config.dataDir, `backup-${new Date().toISOString().slice(0, 10)}.db`);
35
26
  db.exec(`VACUUM INTO '${backupPath.replace(/'/g, '\'\'')}'`);
36
27
  }
@@ -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 };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/session-store.js
3
+ * @description Dashboard session store in the Coordinator's SQLite database: survives restarts, prunes expired sessions
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 session = require('express-session');
17
+
18
+ // express-session's default MemoryStore loses every session on a restart,
19
+ // never drops expired ones (memory only grows) and warns against itself in
20
+ // production. This one keeps them in the `sessions` table (migration 022),
21
+ // so signing in survives a Coordinator restart or self-update, and an
22
+ // expired session is deleted when read and by a periodic sweep.
23
+ const PRUNE_INTERVAL_MS = 15 * 60 * 1000,
24
+ // A session without a cookie expiry (never the case here: routes.js sets
25
+ // each user's idle timeout) is kept this long.
26
+ FALLBACK_TTL_MS = 24 * 60 * 60 * 1000;
27
+
28
+ class SqliteSessionStore extends session.Store{
29
+ constructor(db, { pruneIntervalMs = PRUNE_INTERVAL_MS, now = Date.now } = {}){
30
+ super();
31
+ this.now = now;
32
+ this.stmts = {
33
+ get: db.prepare('SELECT sess, expires FROM sessions WHERE sid = ?'),
34
+ set: db.prepare(
35
+ `INSERT INTO sessions (sid, sess, expires) VALUES (?, ?, ?)
36
+ ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expires = excluded.expires`
37
+ ),
38
+ touch: db.prepare('UPDATE sessions SET sess = ?, expires = ? WHERE sid = ?'),
39
+ destroy: db.prepare('DELETE FROM sessions WHERE sid = ?'),
40
+ prune: db.prepare('DELETE FROM sessions WHERE expires <= ?'),
41
+ length: db.prepare('SELECT COUNT(*) AS n FROM sessions WHERE expires > ?'),
42
+ clear: db.prepare('DELETE FROM sessions')
43
+ };
44
+ if (pruneIntervalMs > 0){
45
+ this.pruneTimer = setInterval(() => this.prune(), pruneIntervalMs);
46
+ this.pruneTimer.unref?.();
47
+ }
48
+ }
49
+
50
+ expiresOf(sess){
51
+ const at = sess?.cookie?.expires ? new Date(sess.cookie.expires).getTime() : NaN;
52
+ return Number.isFinite(at) ? at : this.now() + FALLBACK_TTL_MS;
53
+ }
54
+
55
+ // Each method answers through `cb`, as express-session expects; errors
56
+ // (a locked or broken database) go there too instead of throwing.
57
+ run(cb, fn){
58
+ let result;
59
+ try {
60
+ result = fn();
61
+ }
62
+ catch (err){
63
+ return cb?.(err);
64
+ }
65
+ cb?.(null, result);
66
+ }
67
+
68
+ get(sid, cb){
69
+ this.run(cb, () => {
70
+ const row = this.stmts.get.get(sid);
71
+ if (!row){
72
+ return null;
73
+ }
74
+ if (row.expires <= this.now()){
75
+ this.stmts.destroy.run(sid);
76
+ return null;
77
+ }
78
+ return JSON.parse(row.sess);
79
+ });
80
+ }
81
+
82
+ set(sid, sess, cb){
83
+ this.run(cb, () => {
84
+ this.stmts.set.run(sid, JSON.stringify(sess), this.expiresOf(sess));
85
+ });
86
+ }
87
+
88
+ // Called for a request that didn't change the session: moves its expiry
89
+ // (rolling idle timeout, §10.1). A passive live-update request leaves the
90
+ // cookie's expiry as it was (server.js), so this writes the same value.
91
+ touch(sid, sess, cb){
92
+ this.run(cb, () => {
93
+ this.stmts.touch.run(JSON.stringify(sess), this.expiresOf(sess), sid);
94
+ });
95
+ }
96
+
97
+ destroy(sid, cb){
98
+ this.run(cb, () => {
99
+ this.stmts.destroy.run(sid);
100
+ });
101
+ }
102
+
103
+ length(cb){
104
+ this.run(cb, () => this.stmts.length.get(this.now()).n);
105
+ }
106
+
107
+ clear(cb){
108
+ this.run(cb, () => {
109
+ this.stmts.clear.run();
110
+ });
111
+ }
112
+
113
+ prune(){
114
+ try {
115
+ return this.stmts.prune.run(this.now()).changes;
116
+ }
117
+ catch {
118
+ return 0; // the next sweep tries again
119
+ }
120
+ }
121
+
122
+ close(){
123
+ clearInterval(this.pruneTimer);
124
+ }
125
+ }
126
+
127
+ module.exports = { SqliteSessionStore };