@andrian.yablonskyy/thub-coordinator 1.0.36 → 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,287 @@
1
+ /**
2
+ * @file packages/coordinator/public/js/live.js
3
+ * @description Dashboard live updates: listens to GET /live and, when the page's topics change, re-fetches the page and swaps its [data-live] regions
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
+ // README §10 "Live updates". The stream only says which topics changed; the
17
+ // data comes from re-fetching this very URL (same filters, search, paging and
18
+ // templates) with X-Thub-Live: 1, which the server treats as passive — it
19
+ // doesn't extend the session's idle timeout. Only [data-live] regions are
20
+ // replaced, and never while someone is using them.
21
+ (function (){
22
+ const topics = (document.body.dataset.liveTopics || '').split(/\s+/).filter(Boolean),
23
+ indicator = document.getElementById('thub-live');
24
+ if (!topics.length || !window.EventSource || !window.DOMParser){
25
+ return;
26
+ }
27
+
28
+ const MIN_GAP_MS = 2000, // at most one re-fetch per 2 s per tab
29
+ RETRY_BLOCKED_MS = 2000,
30
+ RECONNECT_MS = 5000,
31
+ // One stream per browser, not per tab (browsers allow only 6 connections
32
+ // per site over HTTP/1.1): the tab holding the lock opens it and relays
33
+ // what it hears to the others.
34
+ channel = 'BroadcastChannel' in window ? new BroadcastChannel('thub-live') : null;
35
+ let dirty = false,
36
+ fetching = false,
37
+ streamStatus = null, // set only in the tab holding the stream
38
+ ended = false,
39
+ lastFetch = 0,
40
+ lastUpdate = Date.now(),
41
+ timer = null;
42
+
43
+ // --- status indicator (navbar) -----------------------------------------
44
+ const LABELS = {
45
+ connecting: ['Live', 'Connecting to live updates…'],
46
+ live: ['Live', 'Live updates on'],
47
+ paused: ['Paused', 'Live updates paused while you edit — they resume when you’re done'],
48
+ offline: ['Offline', 'Coordinator unreachable — reconnecting…'],
49
+ ended: ['Session ended', 'Your session ended — reload the page to sign in again']
50
+ };
51
+ let state = 'connecting';
52
+ function setState(next){
53
+ state = next;
54
+ if (!indicator){
55
+ return;
56
+ }
57
+ const [label, title] = LABELS[next];
58
+ indicator.dataset.state = next;
59
+ indicator.querySelector('.thub-live-label').textContent = label;
60
+ indicator.title = next === 'live' ? `${title} · updated ${ago()}` : title;
61
+ }
62
+ function ago(){
63
+ const s = Math.round((Date.now() - lastUpdate) / 1000);
64
+ return s < 5 ? 'just now' : s < 90 ? `${s} s ago` : `${Math.round(s / 60)} min ago`;
65
+ }
66
+ if (indicator){
67
+ indicator.addEventListener('mouseenter', () => setState(state));
68
+ indicator.addEventListener('click', () => {
69
+ if (ended){
70
+ window.location.reload();
71
+ }
72
+ });
73
+ }
74
+
75
+ // --- deciding when a region may be replaced ----------------------------
76
+ // Everything waits while a confirmation is open: it submits a form that
77
+ // may sit in a region, and must not lose it.
78
+ function globallyBlocked(){
79
+ return Boolean(document.querySelector('#thub-confirm-modal.show, #remove-resource-modal.show, .thub-overlay'));
80
+ }
81
+ // A region waits while it's in use: a field or form in it has focus (not
82
+ // just a row — focus returns to the row after its resource card closes),
83
+ // an open collapse with fields (inline rename), a dropdown, or a modal
84
+ // inside it (agent Edit).
85
+ function regionBusy(region){
86
+ const active = document.activeElement,
87
+ editing = active && region.contains(active) &&
88
+ (active.matches('input, select, textarea, [contenteditable]') || active.closest('form'));
89
+ return Boolean(editing) || Boolean(region.querySelector(
90
+ '.collapse.show :is(input, select, textarea), .collapsing, .dropdown-menu.show, .modal.show'
91
+ ));
92
+ }
93
+
94
+ // --- applying a fresh copy of the page ---------------------------------
95
+ function disposeTooltips(root){
96
+ root.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => bootstrap.Tooltip.getInstance(el)?.dispose());
97
+ }
98
+
99
+ function apply(doc){
100
+ // Resources added or removed: their card modals (with the config tabs'
101
+ // own scripts) exist only on a full page load.
102
+ const cards = (d) => [...d.querySelectorAll('.thub-resource-card')].map((m) => m.id).sort().join(','),
103
+ structureChanged = cards(document) !== cards(doc);
104
+ if (structureChanged && !document.querySelector('.modal.show')){
105
+ window.location.reload();
106
+ return true;
107
+ }
108
+ let deferred = false;
109
+ document.querySelectorAll('[data-live]').forEach((region) => {
110
+ const fresh = doc.querySelector(`[data-live="${CSS.escape(region.dataset.live)}"]`);
111
+ if (!fresh || fresh.innerHTML === region.innerHTML){
112
+ return;
113
+ }
114
+ if (regionBusy(region)){
115
+ deferred = true;
116
+ return;
117
+ }
118
+ disposeTooltips(region);
119
+ region.innerHTML = fresh.innerHTML;
120
+ });
121
+ return !deferred;
122
+ }
123
+
124
+ async function refresh(){
125
+ timer = null;
126
+ if (ended || fetching){
127
+ return;
128
+ }
129
+ if (document.hidden){
130
+ dirty = true; // caught up on visibilitychange
131
+ return;
132
+ }
133
+ if (globallyBlocked()){
134
+ dirty = true;
135
+ setState('paused');
136
+ schedule(RETRY_BLOCKED_MS);
137
+ return;
138
+ }
139
+ fetching = true;
140
+ dirty = false;
141
+ lastFetch = Date.now();
142
+ try {
143
+ const res = await fetch(window.location.href, {
144
+ headers: { 'X-Thub-Live': '1' },
145
+ credentials: 'same-origin',
146
+ cache: 'no-store',
147
+ redirect: 'manual'
148
+ });
149
+ if (res.type === 'opaqueredirect' || res.status === 401 || res.status === 403){
150
+ sessionEnded();
151
+ return;
152
+ }
153
+ if (!res.ok){
154
+ throw new Error(`HTTP ${res.status}`);
155
+ }
156
+ const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
157
+ if (apply(doc)){
158
+ lastUpdate = Date.now();
159
+ setState('live');
160
+ }
161
+ else {
162
+ // Something was in use: try again shortly, until it's free.
163
+ dirty = true;
164
+ setState('paused');
165
+ schedule(RETRY_BLOCKED_MS);
166
+ }
167
+ }
168
+ catch {
169
+ dirty = true;
170
+ setState('offline');
171
+ schedule(RECONNECT_MS);
172
+ }
173
+ finally {
174
+ fetching = false;
175
+ }
176
+ }
177
+
178
+ function schedule(delay){
179
+ if (ended || timer){
180
+ return;
181
+ }
182
+ const wait = Math.max(delay ?? 0, lastFetch + MIN_GAP_MS - Date.now());
183
+ timer = setTimeout(refresh, Math.max(0, wait));
184
+ }
185
+
186
+ document.addEventListener('visibilitychange', () => {
187
+ if (!document.hidden && dirty){
188
+ schedule(0);
189
+ }
190
+ });
191
+ // Leaving a field or closing a modal may be what a deferred update waits for.
192
+ document.addEventListener('focusout', () => dirty && schedule(300));
193
+ document.addEventListener('hidden.bs.modal', () => dirty && schedule(300));
194
+ document.addEventListener('hidden.bs.collapse', () => dirty && schedule(300));
195
+
196
+ function sessionEnded(){
197
+ ended = true;
198
+ clearTimeout(timer);
199
+ setState('ended');
200
+ }
201
+
202
+ // --- messages (from our own stream, or relayed from the tab holding it) -
203
+ function onMessage(msg){
204
+ if (msg.type === 'changed' && msg.topics.some((t) => topics.includes(t))){
205
+ schedule(0);
206
+ }
207
+ else if (msg.type === 'resync'){
208
+ schedule(0); // (re)connected: whatever happened meanwhile, catch up
209
+ }
210
+ else if (msg.type === 'status' && !ended && state !== 'paused'){
211
+ setState(msg.status);
212
+ }
213
+ else if (msg.type === 'session-ended'){
214
+ sessionEnded();
215
+ }
216
+ else if (msg.type === 'status-request' && streamStatus){
217
+ channel.postMessage({ type: 'status', status: streamStatus }); // a tab opened later asks
218
+ }
219
+ }
220
+ function broadcast(msg){
221
+ if (msg.type === 'status'){
222
+ streamStatus = msg.status;
223
+ }
224
+ channel?.postMessage(msg);
225
+ onMessage(msg);
226
+ }
227
+ if (channel){
228
+ channel.onmessage = (e) => onMessage(e.data);
229
+ channel.postMessage({ type: 'status-request' });
230
+ }
231
+
232
+ // --- the stream (only in the tab that holds the lock) ------------------
233
+ // Resolves when this tab should give the stream up for good: session ended.
234
+ function runStream(){
235
+ return new Promise((resolve) => {
236
+ let connectedBefore = false,
237
+ source = null;
238
+ function connect(){
239
+ source = new EventSource('/live');
240
+ source.addEventListener('hello', () => {
241
+ broadcast({ type: 'status', status: 'live' });
242
+ if (connectedBefore){
243
+ broadcast({ type: 'resync' });
244
+ }
245
+ connectedBefore = true;
246
+ });
247
+ source.addEventListener('changed', (e) => broadcast({ type: 'changed', topics: JSON.parse(e.data).topics }));
248
+ source.addEventListener('session-ended', () => {
249
+ source.close();
250
+ broadcast({ type: 'session-ended' });
251
+ resolve();
252
+ });
253
+ source.onerror = async () => {
254
+ if (source.readyState !== EventSource.CLOSED){
255
+ broadcast({ type: 'status', status: 'offline' }); // EventSource retries by itself
256
+ return;
257
+ }
258
+ // Closed for good (an HTTP error, e.g. 401 or a proxy's 502 during
259
+ // a restart): signed out, or just down for now?
260
+ broadcast({ type: 'status', status: 'offline' });
261
+ try {
262
+ const res = await fetch('/live', { method: 'HEAD', redirect: 'manual', credentials: 'same-origin', headers: { 'X-Thub-Live': '1' } });
263
+ if (res.type === 'opaqueredirect' || res.status === 401 || res.status === 403){
264
+ broadcast({ type: 'session-ended' });
265
+ resolve();
266
+ return;
267
+ }
268
+ }
269
+ catch {
270
+ // unreachable: retry below
271
+ }
272
+ setTimeout(connect, RECONNECT_MS);
273
+ };
274
+ }
275
+ connect();
276
+ });
277
+ }
278
+
279
+ if (navigator.locks && channel){
280
+ // Held until the stream ends for good; if this tab closes, the browser
281
+ // releases it and the next waiting tab takes over.
282
+ navigator.locks.request('thub-live-stream', runStream);
283
+ }
284
+ else {
285
+ runStream();
286
+ }
287
+ })();
@@ -44,16 +44,128 @@
44
44
  return `${p.day}/${p.month}/${p.year} ${p.hour}:${p.minute}:${p.second}`;
45
45
  }
46
46
 
47
- function render(){
48
- const wanted = filter.value;
49
- pre.textContent = lines
50
- .filter((l) => !wanted || l.stream === wanted)
51
- .map((l) => `[${fmtTime(l.ts)}] [${l.stream}] ${l.line}`)
52
- .join('\n');
53
- pre.scrollTop = pre.scrollHeight;
47
+ // Search (views/mixins/log-viewer.pug): case-insensitive, literal text.
48
+ // Matches are <mark>ed; `current` is the one the arrows step to.
49
+ const search = document.getElementById('log-search'),
50
+ onlyMatching = document.getElementById('log-search-only'),
51
+ count = document.getElementById('log-search-count'),
52
+ prev = document.getElementById('log-search-prev'),
53
+ next = document.getElementById('log-search-next');
54
+ let hits = [],
55
+ current = -1,
56
+ queued = false;
57
+
58
+ function escapeHtml(s){
59
+ return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
60
+ }
61
+
62
+ function showCurrent(scroll){
63
+ hits.forEach((el, i) => el.classList.toggle('current', i === current));
64
+ if (current >= 0){
65
+ count.textContent = `${current + 1} / ${hits.length}`;
66
+ if (scroll){
67
+ const el = hits[current];
68
+ pre.scrollTop = el.offsetTop - pre.clientHeight / 2;
69
+ }
70
+ }
71
+ }
72
+
73
+ function step(delta){
74
+ if (!hits.length){
75
+ return;
76
+ }
77
+ current = (current + delta + hits.length) % hits.length;
78
+ showCurrent(true);
79
+ }
80
+
81
+ // `live`: re-rendered for a newly streamed line — keep the reader's place
82
+ // while searching; without a search, follow the end of the log as before.
83
+ function render({ live = false } = {}){
84
+ const wanted = filter.value,
85
+ query = search.value.trim(),
86
+ shown = lines
87
+ .filter((l) => !wanted || l.stream === wanted)
88
+ .map((l) => `[${fmtTime(l.ts)}] [${l.stream}] ${l.line}`);
89
+
90
+ if (!query){
91
+ pre.textContent = shown.join('\n');
92
+ hits = [];
93
+ current = -1;
94
+ count.hidden = true;
95
+ prev.disabled = next.disabled = true;
96
+ pre.scrollTop = pre.scrollHeight;
97
+ return;
98
+ }
99
+
100
+ const re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'),
101
+ out = [];
102
+ for (const text of shown){
103
+ let last = 0,
104
+ html = '',
105
+ m;
106
+ re.lastIndex = 0;
107
+ while ((m = re.exec(text))){
108
+ html += `${escapeHtml(text.slice(last, m.index))}<mark class="thub-log-hit">${escapeHtml(m[0])}</mark>`;
109
+ last = m.index + m[0].length;
110
+ }
111
+ if (last || !onlyMatching.checked){
112
+ out.push(html + escapeHtml(text.slice(last)));
113
+ }
114
+ }
115
+ const scrollTop = pre.scrollTop;
116
+ pre.innerHTML = out.join('\n');
117
+ hits = [...pre.querySelectorAll('mark.thub-log-hit')];
118
+ count.hidden = false;
119
+ count.classList.toggle('text-danger', !hits.length);
120
+ prev.disabled = next.disabled = hits.length < 2;
121
+ if (!hits.length){
122
+ current = -1;
123
+ count.textContent = 'No matches';
124
+ return;
125
+ }
126
+ if (live){
127
+ current = Math.min(Math.max(current, 0), hits.length - 1);
128
+ showCurrent(false);
129
+ pre.scrollTop = scrollTop;
130
+ }
131
+ else {
132
+ current = 0;
133
+ showCurrent(true);
134
+ }
135
+ }
136
+
137
+ // Streamed lines can arrive in bursts: render at most once per frame.
138
+ function renderLive(){
139
+ if (queued){
140
+ return;
141
+ }
142
+ queued = true;
143
+ requestAnimationFrame(() => {
144
+ queued = false;
145
+ render({ live: true });
146
+ });
54
147
  }
55
148
 
56
- filter.addEventListener('change', render);
149
+ let typing;
150
+ search.addEventListener('input', () => {
151
+ clearTimeout(typing);
152
+ typing = setTimeout(() => render(), 150);
153
+ });
154
+ search.addEventListener('keydown', (e) => {
155
+ if (e.key === 'Enter'){
156
+ e.preventDefault();
157
+ step(e.shiftKey ? -1 : 1);
158
+ }
159
+ else if (e.key === 'Escape' && search.value){
160
+ e.preventDefault();
161
+ search.value = '';
162
+ render();
163
+ }
164
+ });
165
+ prev.addEventListener('click', () => step(-1));
166
+ next.addEventListener('click', () => step(1));
167
+ onlyMatching.addEventListener('change', () => render());
168
+ filter.addEventListener('change', () => render());
57
169
 
58
170
  // Already finished when the page loaded: the output will never change
59
171
  // again, so just fetch it once — no EventSource, no live connection, no
@@ -75,17 +187,17 @@
75
187
  const source = new EventSource(`/jobs/${jobId}/stream`);
76
188
  source.addEventListener('log', (e) => {
77
189
  lines.push(JSON.parse(e.data));
78
- render();
190
+ renderLive();
79
191
  });
80
192
  source.addEventListener('state', (e) => {
81
193
  const { state, resource } = JSON.parse(e.data);
82
194
  lines.push({ ts: new Date().toISOString(), stream: 'state', line: `-> ${state}${resource ? ' on ' + resource : ''}` });
83
- render();
195
+ renderLive();
84
196
  });
85
197
  source.addEventListener('end', (e) => {
86
198
  const { state } = JSON.parse(e.data);
87
199
  lines.push({ ts: new Date().toISOString(), stream: 'state', line: `job finished: ${state}` });
88
- render();
200
+ renderLive();
89
201
  source.close();
90
202
  // The job was active when we opened this page and just finished —
91
203
  // reload once to pick up the now-final badge/artifacts list.
@@ -20,8 +20,10 @@
20
20
  }
21
21
  const field = (name) => modalEl.querySelector(`[data-field="${name}"]`);
22
22
 
23
- document.querySelectorAll('[data-remove-resource]').forEach((btn) => {
24
- btn.addEventListener('click', () => {
23
+ // Delegated, so buttons swapped in by live updates (live.js) work.
24
+ document.addEventListener('click', (e) => {
25
+ const btn = e.target.closest('[data-remove-resource]');
26
+ if (btn){
25
27
  const { removeResource: id, removeName: name, removeJob: job, removeReturn: returnTo } = btn.dataset;
26
28
  field('name').textContent = name;
27
29
  field('job').textContent = job;
@@ -40,6 +42,6 @@
40
42
  else {
41
43
  bootstrap.Modal.getOrCreateInstance(modalEl).show();
42
44
  }
43
- });
45
+ }
44
46
  });
45
47
  })();
@@ -21,18 +21,19 @@
21
21
  }
22
22
  }
23
23
 
24
- document.querySelectorAll('[data-resource-card]').forEach((el) => {
25
- el.addEventListener('click', (e) => {
26
- // The Resources table's own action buttons stay plain actions.
27
- if (!e.target.closest('form, button, a, input')){
28
- open(el);
29
- }
30
- });
31
- el.addEventListener('keydown', (e) => {
32
- if ((e.key === 'Enter' || e.key === ' ') && e.target === el){
33
- e.preventDefault();
34
- open(el);
35
- }
36
- });
24
+ // Delegated, so rows and cards swapped in by live updates (live.js) work.
25
+ document.addEventListener('click', (e) => {
26
+ const el = e.target.closest('[data-resource-card]');
27
+ // The Resources table's own action buttons stay plain actions.
28
+ if (el && !e.target.closest('form, button, a, input')){
29
+ open(el);
30
+ }
31
+ });
32
+ document.addEventListener('keydown', (e) => {
33
+ const el = e.target.closest?.('[data-resource-card]');
34
+ if (el && (e.key === 'Enter' || e.key === ' ') && e.target === el){
35
+ e.preventDefault();
36
+ open(el);
37
+ }
37
38
  });
38
39
  })();
@@ -13,6 +13,8 @@
13
13
 
14
14
  'use strict';
15
15
 
16
+ // Delegated (one instance per element, created on first hover/focus), so
17
+ // content swapped in by live updates (public/js/live.js) gets tooltips too.
16
18
  (function (){
17
- document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => new bootstrap.Tooltip(el));
19
+ new bootstrap.Tooltip(document.body, { selector: '[data-bs-toggle="tooltip"]' });
18
20
  })();
package/src/server.js CHANGED
@@ -37,6 +37,7 @@ const path = require('node:path'),
37
37
  { createCleanupService } = require('./services/cleanup'),
38
38
  { startDevMode } = require('./dev/virtual'),
39
39
  { createUpdatesService } = require('./services/updates'),
40
+ { createLiveService } = require('./services/live'),
40
41
 
41
42
  { createAgentRouter } = require('./api/agent'),
42
43
  { createResourceRouter } = require('./api/resource'),
@@ -58,7 +59,8 @@ function buildServices(config){
58
59
  heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config }),
59
60
  retention = createRetentionService(db, { bus, logs, artifacts, config }),
60
61
  cleanup = createCleanupService(db, { jobs, config }),
61
- updates = createUpdatesService({ config });
62
+ updates = createUpdatesService({ config }),
63
+ live = createLiveService(db, { bus, updates });
62
64
 
63
65
  jobs.reconcileOnStartup();
64
66
  updates.start();
@@ -86,7 +88,8 @@ function buildServices(config){
86
88
  heartbeatMonitor,
87
89
  retention,
88
90
  cleanup,
89
- updates
91
+ updates,
92
+ live
90
93
  };
91
94
  }
92
95
 
@@ -127,6 +130,21 @@ function createApp(config, services){
127
130
  cookie: { httpOnly: true, sameSite: 'lax' }
128
131
  })
129
132
  );
133
+ // Background requests of the dashboard's live updates (public/js/live.js):
134
+ // the GET /live stream and the page re-fetches it triggers (header
135
+ // X-Thub-Live: 1). They must not count as activity — otherwise an open
136
+ // tab would keep the session alive forever and the idle timeout (§10.1)
137
+ // would never fire — so they don't extend it (`touch` is what `rolling`
138
+ // uses to push the expiry out) and web/routes.js leaves the session alone.
139
+ app.use((req, res, next) => {
140
+ if (req.session && (req.path === '/live' || req.get('X-Thub-Live') === '1')){
141
+ req.thubPassive = true;
142
+ req.session.touch = function (){
143
+ return this;
144
+ };
145
+ }
146
+ next();
147
+ });
130
148
  app.use(express.static(path.join(__dirname, '..', 'public')));
131
149
  app.use('/avatars', express.static(config.avatarsDir));
132
150
 
@@ -15,6 +15,7 @@
15
15
 
16
16
  const { validateJobSpec, maskEnv, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
17
17
  { paginate } = require('./list-prefs'),
18
+ { parseTerms, likeClause } = require('./search'),
18
19
  // Named in spec errors: a field the Agent sends but this thub-common
19
20
  // doesn't know means the Coordinator needs an update.
20
21
  VERSIONS = `Coordinator v${require('../../package.json').version}, ` +
@@ -87,16 +88,26 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
87
88
  // Dashboard /jobs (§10): one sorted page plus the total, both in SQL so a
88
89
  // long history never has to be loaded whole. `sort` is a list-prefs key.
89
90
  const PAGE_SORT_SQL = {
90
- id: 'jobs.id',
91
- source: 'jobs.source',
92
- user: 'json_extract(jobs.spec, \'$.user\')',
93
- state: 'jobs.state',
94
- resource: 'COALESCE(r.name, jobs.resource_name)',
95
- created: 'jobs.created_at',
96
- duration: 'jobs.duration_sec'
97
- };
98
-
99
- function page({ state, source, sort = 'created', dir = 'desc', size = 25, page: pageNo = 1 } = {}){
91
+ id: 'jobs.id',
92
+ source: 'jobs.source',
93
+ user: 'json_extract(jobs.spec, \'$.user\')',
94
+ state: 'jobs.state',
95
+ resource: 'COALESCE(r.name, jobs.resource_name)',
96
+ created: 'jobs.created_at',
97
+ duration: 'jobs.duration_sec'
98
+ },
99
+
100
+ // What the Jobs page's search box (?q=) looks in. Named spec fields
101
+ // only — never the whole spec: while a job is active its `env` still
102
+ // holds the real --env values (§7.2), and a LIKE over them would let
103
+ // anyone who can see the list guess a secret one character at a time.
104
+ SEARCH_SQL = [
105
+ 'jobs.id', 'jobs.state', 'jobs.source', 'jobs.message', 'r.name', 'jobs.resource_name', 'a.name',
106
+ ...['user', 'command', 'suite', 'image', 'git.url', 'git.ref', 'target.group', 'target.labels', 'downloads', 'args', 'meta']
107
+ .map((path) => `json_extract(jobs.spec, '$.${path}')`)
108
+ ];
109
+
110
+ function page({ state, source, q, sort = 'created', dir = 'desc', size = 25, page: pageNo = 1 } = {}){
100
111
  const where = [],
101
112
  params = [];
102
113
  if (state){
@@ -107,15 +118,21 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
107
118
  where.push('jobs.source = ?');
108
119
  params.push(source);
109
120
  }
121
+ const search = likeClause(SEARCH_SQL, parseTerms(q));
122
+ if (search.sql){
123
+ where.push(search.sql);
124
+ params.push(...search.params);
125
+ }
110
126
  const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '',
127
+ fromSql = 'FROM jobs LEFT JOIN resources r ON r.id = jobs.resource_id LEFT JOIN agents a ON a.id = jobs.agent_id',
111
128
  column = PAGE_SORT_SQL[sort] || PAGE_SORT_SQL.created,
112
129
  order = dir === 'asc' ? 'ASC' : 'DESC',
113
- total = db.prepare(`SELECT COUNT(*) AS n FROM jobs ${whereSql}`).get(...params).n,
130
+ total = db.prepare(`SELECT COUNT(*) AS n ${fromSql} ${whereSql}`).get(...params).n,
114
131
  pagination = paginate(total, size, pageNo),
115
132
  // Empty values (no user, never ran, …) last either way; created_at
116
133
  // breaks ties so paging is stable.
117
134
  rows = db.prepare(
118
- `SELECT jobs.* FROM jobs LEFT JOIN resources r ON r.id = jobs.resource_id ${whereSql}
135
+ `SELECT jobs.* ${fromSql} ${whereSql}
119
136
  ORDER BY ${column} IS NULL, ${column} ${order}, jobs.created_at DESC
120
137
  LIMIT ? OFFSET ?`
121
138
  ).all(...params, pagination.limit, pagination.offset);
@@ -30,6 +30,10 @@ const PAGE_SIZES = [10, 25, 50, 100, 'all'],
30
30
  resources: {
31
31
  sorts: ['name', 'type', 'version', 'status', 'heartbeat'],
32
32
  defaults: { size: DEFAULT_PAGE_SIZE, sort: 'name', dir: 'asc' }
33
+ },
34
+ agents: {
35
+ sorts: ['name', 'kind', 'version', 'created', 'used', 'status'],
36
+ defaults: { size: DEFAULT_PAGE_SIZE, sort: 'created', dir: 'desc' }
33
37
  }
34
38
  };
35
39