@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.
- package/package.json +2 -2
- package/public/css/thub.css +129 -0
- package/public/js/config-import.js +37 -33
- package/public/js/copy-to-clipboard.js +21 -18
- package/public/js/help.js +93 -0
- package/public/js/list-search.js +51 -0
- package/public/js/live.js +287 -0
- package/public/js/log-viewer.js +123 -11
- package/public/js/remove-resource.js +5 -3
- package/public/js/resource-card.js +14 -13
- package/public/js/tooltips.js +3 -1
- package/src/server.js +20 -2
- package/src/services/jobs.js +29 -12
- package/src/services/list-prefs.js +4 -0
- package/src/services/live.js +124 -0
- package/src/services/search.js +59 -0
- package/src/web/routes.js +130 -21
- package/test/list-prefs.test.js +6 -0
- package/test/live.test.js +153 -0
- package/test/search.test.js +97 -0
- package/test/views.test.js +30 -0
- package/views/admin/agents.pug +28 -10
- package/views/groups/list.pug +14 -1
- package/views/help/_agent-cli.pug +103 -0
- package/views/help/_agent-setup.pug +72 -0
- package/views/help/_ci.pug +44 -0
- package/views/help/_client-machines.pug +87 -0
- package/views/help/_client-setup.pug +122 -0
- package/views/help/_coordinator.pug +134 -0
- package/views/help/_docker.pug +124 -0
- package/views/help/_env.pug +127 -0
- package/views/help/_git.pug +129 -0
- package/views/help/_mixins.pug +24 -0
- package/views/help/_overview.pug +118 -0
- package/views/help/_quick-start.pug +37 -0
- package/views/help/_troubleshooting.pug +42 -0
- package/views/help/index.pug +67 -0
- package/views/index.pug +3 -2
- package/views/jobs/list.pug +6 -3
- package/views/layout.pug +24 -1
- package/views/mixins/list-controls.pug +36 -0
- package/views/mixins/log-viewer.pug +25 -3
- package/views/mixins/resource.pug +1 -1
- 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
|
+
})();
|
package/public/js/log-viewer.js
CHANGED
|
@@ -44,16 +44,128 @@
|
|
|
44
44
|
return `${p.day}/${p.month}/${p.year} ${p.hour}:${p.minute}:${p.second}`;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>' })[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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
})();
|
package/public/js/tooltips.js
CHANGED
|
@@ -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.
|
|
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
|
|
package/src/services/jobs.js
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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.*
|
|
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
|
|