@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.
- package/bin/thub-admin.js +3 -5
- package/config.json +1 -6
- 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 +289 -45
- package/public/js/remove-resource.js +5 -3
- package/public/js/resource-card.js +14 -13
- package/public/js/tooltips.js +3 -1
- package/scripts/install-default-config.js +1 -1
- package/scripts/install-target.js +2 -2
- package/src/api/admin.js +1 -1
- package/src/api/agent.js +4 -10
- package/src/api/resource.js +6 -11
- package/src/api/sse.js +13 -10
- package/src/config.js +16 -9
- package/src/db/migrations/022_add_sessions.sql +10 -0
- package/src/db/migrations/023_drop_artifacts.sql +4 -0
- package/src/dev/virtual.js +1 -1
- package/src/server.js +55 -21
- package/src/services/cleanup.js +2 -2
- package/src/services/jobs.js +33 -19
- package/src/services/list-prefs.js +4 -0
- package/src/services/live.js +124 -0
- package/src/services/logs.js +12 -14
- package/src/services/retention.js +5 -14
- package/src/services/search.js +59 -0
- package/src/services/session-store.js +127 -0
- package/src/web/routes.js +205 -34
- package/test/cleanup.test.js +1 -5
- package/test/list-prefs.test.js +6 -0
- package/test/live.test.js +153 -0
- package/test/logs.test.js +150 -0
- package/test/scheduler.test.js +10 -12
- package/test/search.test.js +97 -0
- package/test/session.test.js +132 -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 +137 -0
- package/views/help/_docker.pug +124 -0
- package/views/help/_env.pug +127 -0
- package/views/help/_git.pug +130 -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 +44 -0
- package/views/help/index.pug +67 -0
- package/views/index.pug +3 -2
- package/views/jobs/list.pug +8 -5
- package/views/jobs/show.pug +0 -10
- package/views/layout.pug +24 -1
- package/views/mixins/list-controls.pug +36 -0
- package/views/mixins/log-viewer.pug +39 -3
- package/views/mixins/resource.pug +1 -1
- package/views/resources/list.pug +7 -4
- package/src/services/artifacts.js +0 -149
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file packages/coordinator/public/js/log-viewer.js
|
|
3
|
-
* @description Dashboard:
|
|
3
|
+
* @description Dashboard: a job's log in the log viewer pane — loaded from its end, earlier lines as you scroll up, live lines streamed
|
|
4
4
|
*
|
|
5
5
|
* @author Andrian Yablonskyy
|
|
6
6
|
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
@@ -21,7 +21,11 @@
|
|
|
21
21
|
const jobId = pre.dataset.jobId,
|
|
22
22
|
jobActive = pre.dataset.jobActive === 'true',
|
|
23
23
|
filter = document.getElementById('stream-filter'),
|
|
24
|
-
|
|
24
|
+
info = document.getElementById('log-info'),
|
|
25
|
+
loadAllButton = document.getElementById('log-load-all'),
|
|
26
|
+
PAGE = 1000, // lines per page (routes.js LOG_PAGE)
|
|
27
|
+
PAGE_ALL = 5000, // per request when loading everything
|
|
28
|
+
NEAR_TOP_PX = 150,
|
|
25
29
|
// Same dd/mm/yyyy HH:MM:SS, 24-hour format and profile time zone as
|
|
26
30
|
// every server-rendered date (thub-common formatDateTime).
|
|
27
31
|
timeFormat = new Intl.DateTimeFormat('en-GB', {
|
|
@@ -35,6 +39,16 @@
|
|
|
35
39
|
hourCycle: 'h23'
|
|
36
40
|
});
|
|
37
41
|
|
|
42
|
+
// What's loaded, oldest first: a window ending at the log's end. `firstSeq`
|
|
43
|
+
// is where the next earlier page ends; `total` counts every line the job
|
|
44
|
+
// has (loaded or not), live lines included.
|
|
45
|
+
let lines = [],
|
|
46
|
+
firstSeq = null,
|
|
47
|
+
hasMore = false,
|
|
48
|
+
total = 0,
|
|
49
|
+
lastSeq = 0, // newest log line we have (for the live stream to follow on)
|
|
50
|
+
loading = false;
|
|
51
|
+
|
|
38
52
|
function fmtTime(iso){
|
|
39
53
|
const d = new Date(iso);
|
|
40
54
|
if (Number.isNaN(d.getTime())){
|
|
@@ -44,54 +58,284 @@
|
|
|
44
58
|
return `${p.day}/${p.month}/${p.year} ${p.hour}:${p.minute}:${p.second}`;
|
|
45
59
|
}
|
|
46
60
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
.
|
|
53
|
-
|
|
61
|
+
const number = (n) => n.toLocaleString('en-GB');
|
|
62
|
+
|
|
63
|
+
function updateInfo(){
|
|
64
|
+
const logLines = lines.filter((l) => l.seq !== undefined).length;
|
|
65
|
+
if (loading){
|
|
66
|
+
info.textContent = 'Loading earlier lines…';
|
|
67
|
+
}
|
|
68
|
+
else if (!total){
|
|
69
|
+
info.textContent = jobActive ? 'No output yet.' : 'This job produced no output.';
|
|
70
|
+
}
|
|
71
|
+
else if (hasMore){
|
|
72
|
+
info.textContent = `Showing the last ${number(logLines)} of ${number(total)} lines — scroll up for earlier ones.`;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
info.textContent = `All ${number(total)} lines.`;
|
|
76
|
+
}
|
|
77
|
+
loadAllButton.hidden = !hasMore || loading;
|
|
54
78
|
}
|
|
55
79
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
})
|
|
71
|
-
.catch(() => {});
|
|
72
|
-
return;
|
|
80
|
+
// Search (views/mixins/log-viewer.pug): case-insensitive, literal text,
|
|
81
|
+
// over the loaded lines. Matches are <mark>ed; `current` is the one the
|
|
82
|
+
// arrows step to.
|
|
83
|
+
const search = document.getElementById('log-search'),
|
|
84
|
+
onlyMatching = document.getElementById('log-search-only'),
|
|
85
|
+
count = document.getElementById('log-search-count'),
|
|
86
|
+
prev = document.getElementById('log-search-prev'),
|
|
87
|
+
next = document.getElementById('log-search-next');
|
|
88
|
+
let hits = [],
|
|
89
|
+
current = -1,
|
|
90
|
+
queued = false;
|
|
91
|
+
|
|
92
|
+
function escapeHtml(s){
|
|
93
|
+
return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]);
|
|
73
94
|
}
|
|
74
95
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
96
|
+
function showCurrent(scroll){
|
|
97
|
+
hits.forEach((el, i) => el.classList.toggle('current', i === current));
|
|
98
|
+
if (current >= 0){
|
|
99
|
+
count.textContent = `${current + 1} / ${hits.length}${hasMore ? ' loaded' : ''}`;
|
|
100
|
+
if (scroll){
|
|
101
|
+
const el = hits[current];
|
|
102
|
+
pre.scrollTop = el.offsetTop - pre.clientHeight / 2;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function step(delta){
|
|
108
|
+
if (!hits.length){
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
current = (current + delta + hits.length) % hits.length;
|
|
112
|
+
showCurrent(true);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// `mode` says what changed, and so where the view should stay:
|
|
116
|
+
// reset — first load, a new search or filter: the end (or first match)
|
|
117
|
+
// live — a streamed line: follow the end only if the reader is there
|
|
118
|
+
// prepend — earlier lines loaded above: keep the same lines in view
|
|
119
|
+
function render(mode = 'reset'){
|
|
120
|
+
const wanted = filter.value,
|
|
121
|
+
query = search.value.trim(),
|
|
122
|
+
atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 30,
|
|
123
|
+
fromBottom = pre.scrollHeight - pre.scrollTop,
|
|
124
|
+
scrollTop = pre.scrollTop,
|
|
125
|
+
hitsBefore = hits.length,
|
|
126
|
+
shown = lines
|
|
127
|
+
.filter((l) => !wanted || l.stream === wanted)
|
|
128
|
+
.map((l) => `[${fmtTime(l.ts)}] [${l.stream}] ${l.line}`);
|
|
129
|
+
|
|
130
|
+
function place(){
|
|
131
|
+
if (mode === 'prepend'){
|
|
132
|
+
pre.scrollTop = pre.scrollHeight - fromBottom;
|
|
133
|
+
}
|
|
134
|
+
else if (mode === 'reset' || atBottom){
|
|
135
|
+
pre.scrollTop = pre.scrollHeight;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
pre.scrollTop = scrollTop;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
updateInfo();
|
|
143
|
+
if (!query){
|
|
144
|
+
pre.textContent = shown.join('\n');
|
|
145
|
+
hits = [];
|
|
146
|
+
current = -1;
|
|
147
|
+
count.hidden = true;
|
|
148
|
+
prev.disabled = next.disabled = true;
|
|
149
|
+
place();
|
|
150
|
+
fillIfShort();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'),
|
|
155
|
+
out = [];
|
|
156
|
+
for (const text of shown){
|
|
157
|
+
let last = 0,
|
|
158
|
+
html = '',
|
|
159
|
+
m;
|
|
160
|
+
re.lastIndex = 0;
|
|
161
|
+
while ((m = re.exec(text))){
|
|
162
|
+
html += `${escapeHtml(text.slice(last, m.index))}<mark class="thub-log-hit">${escapeHtml(m[0])}</mark>`;
|
|
163
|
+
last = m.index + m[0].length;
|
|
164
|
+
}
|
|
165
|
+
if (last || !onlyMatching.checked){
|
|
166
|
+
out.push(html + escapeHtml(text.slice(last)));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
pre.innerHTML = out.join('\n');
|
|
170
|
+
hits = [...pre.querySelectorAll('mark.thub-log-hit')];
|
|
171
|
+
count.hidden = false;
|
|
172
|
+
count.classList.toggle('text-danger', !hits.length);
|
|
173
|
+
prev.disabled = next.disabled = hits.length < 2;
|
|
174
|
+
if (!hits.length){
|
|
175
|
+
current = -1;
|
|
176
|
+
count.textContent = hasMore ? 'No matches in loaded lines' : 'No matches';
|
|
177
|
+
place();
|
|
178
|
+
fillIfShort();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (mode === 'reset'){
|
|
182
|
+
current = 0;
|
|
183
|
+
showCurrent(true);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
// Earlier lines add their matches in front: the same match moves down.
|
|
187
|
+
current = mode === 'prepend' ? current + (hits.length - hitsBefore) : current;
|
|
188
|
+
current = Math.min(Math.max(current, 0), hits.length - 1);
|
|
189
|
+
showCurrent(false);
|
|
190
|
+
place();
|
|
191
|
+
}
|
|
192
|
+
fillIfShort();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Streamed lines can arrive in bursts: render at most once per frame.
|
|
196
|
+
function renderLive(){
|
|
197
|
+
if (queued){
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
queued = true;
|
|
201
|
+
requestAnimationFrame(() => {
|
|
202
|
+
queued = false;
|
|
203
|
+
render('live');
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- loading pages ------------------------------------------------------
|
|
208
|
+
async function fetchPage(before, limit){
|
|
209
|
+
const qs = new URLSearchParams({ limit: String(limit) });
|
|
210
|
+
if (before){
|
|
211
|
+
qs.set('before', String(before));
|
|
212
|
+
}
|
|
213
|
+
const res = await fetch(`/jobs/${encodeURIComponent(jobId)}/logs?${qs}`, { cache: 'no-store' });
|
|
214
|
+
if (!res.ok){
|
|
215
|
+
throw new Error(`HTTP ${res.status}`);
|
|
216
|
+
}
|
|
217
|
+
return res.json();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function absorb(page, where){
|
|
221
|
+
const fetched = page.lines;
|
|
222
|
+
if (fetched.length){
|
|
223
|
+
firstSeq = fetched[0].seq;
|
|
224
|
+
}
|
|
225
|
+
lines = where === 'prepend' ? [...fetched, ...lines] : fetched;
|
|
226
|
+
if (where !== 'prepend' && fetched.length){
|
|
227
|
+
lastSeq = fetched.at(-1).seq;
|
|
228
|
+
}
|
|
229
|
+
hasMore = page.hasMore;
|
|
230
|
+
total = Math.max(total, page.total);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function loadEarlier(limit = PAGE){
|
|
234
|
+
if (loading || !hasMore){
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
loading = true;
|
|
238
|
+
updateInfo();
|
|
239
|
+
try {
|
|
240
|
+
absorb(await fetchPage(firstSeq, limit), 'prepend');
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
info.textContent = 'Couldn’t load earlier lines — scroll up to try again.';
|
|
244
|
+
loading = false;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
loading = false;
|
|
248
|
+
render('prepend');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function loadAll(){
|
|
252
|
+
while (hasMore && !loading){
|
|
253
|
+
await loadEarlier(PAGE_ALL);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// A filter or "only matching lines" can leave too little to scroll: keep
|
|
258
|
+
// loading earlier pages until there's something to scroll, or nothing left.
|
|
259
|
+
function fillIfShort(){
|
|
260
|
+
if (hasMore && !loading && pre.scrollHeight <= pre.clientHeight + NEAR_TOP_PX){
|
|
261
|
+
loadEarlier();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
pre.addEventListener('scroll', () => {
|
|
266
|
+
if (pre.scrollTop < NEAR_TOP_PX){
|
|
267
|
+
loadEarlier();
|
|
268
|
+
}
|
|
269
|
+
}, { passive: true });
|
|
270
|
+
loadAllButton.addEventListener('click', loadAll);
|
|
271
|
+
|
|
272
|
+
let typing;
|
|
273
|
+
search.addEventListener('input', () => {
|
|
274
|
+
clearTimeout(typing);
|
|
275
|
+
typing = setTimeout(() => render(), 150);
|
|
79
276
|
});
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
277
|
+
search.addEventListener('keydown', (e) => {
|
|
278
|
+
if (e.key === 'Enter'){
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
step(e.shiftKey ? -1 : 1);
|
|
281
|
+
}
|
|
282
|
+
else if (e.key === 'Escape' && search.value){
|
|
283
|
+
e.preventDefault();
|
|
284
|
+
search.value = '';
|
|
285
|
+
render();
|
|
286
|
+
}
|
|
84
287
|
});
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
288
|
+
prev.addEventListener('click', () => step(-1));
|
|
289
|
+
next.addEventListener('click', () => step(1));
|
|
290
|
+
onlyMatching.addEventListener('change', () => render());
|
|
291
|
+
filter.addEventListener('change', () => render());
|
|
292
|
+
|
|
293
|
+
// --- start: the end of the log, then (active job) live lines after it ----
|
|
294
|
+
(async () => {
|
|
295
|
+
try {
|
|
296
|
+
absorb(await fetchPage(null, PAGE), 'replace');
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
info.textContent = 'Couldn’t load the log — reload the page to try again.';
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
88
302
|
render();
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
303
|
+
|
|
304
|
+
// Already finished when the page loaded: the output will never change
|
|
305
|
+
// again — no EventSource, no live connection. (Opening a stream here used
|
|
306
|
+
// to reload the page on its immediate "end" event, forever.)
|
|
307
|
+
if (!jobActive){
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// Live lines start right after what's loaded; on a reconnect the
|
|
311
|
+
// browser resumes from the last one it got (Last-Event-ID).
|
|
312
|
+
const source = new EventSource(`/jobs/${encodeURIComponent(jobId)}/stream?after=${lastSeq}`);
|
|
313
|
+
source.addEventListener('log', (e) => {
|
|
314
|
+
const seq = Number(e.lastEventId) || undefined;
|
|
315
|
+
if (seq && seq <= lastSeq){
|
|
316
|
+
return; // already have it
|
|
317
|
+
}
|
|
318
|
+
lastSeq = seq ?? lastSeq;
|
|
319
|
+
lines.push({ ...JSON.parse(e.data), seq });
|
|
320
|
+
total += 1;
|
|
321
|
+
renderLive();
|
|
322
|
+
});
|
|
323
|
+
source.addEventListener('state', (e) => {
|
|
324
|
+
const { state, resource } = JSON.parse(e.data);
|
|
325
|
+
lines.push({ ts: new Date().toISOString(), stream: 'state', line: `-> ${state}${resource ? ' on ' + resource : ''}` });
|
|
326
|
+
renderLive();
|
|
327
|
+
});
|
|
328
|
+
source.addEventListener('end', (e) => {
|
|
329
|
+
const { state } = JSON.parse(e.data);
|
|
330
|
+
lines.push({ ts: new Date().toISOString(), stream: 'state', line: `job finished: ${state}` });
|
|
331
|
+
renderLive();
|
|
332
|
+
source.close();
|
|
333
|
+
// The job was active when we opened this page and just finished —
|
|
334
|
+
// reload once to pick up the now-final badge and details.
|
|
335
|
+
setTimeout(() => window.location.reload(), 1500);
|
|
336
|
+
});
|
|
337
|
+
source.onerror = () => {
|
|
338
|
+
// EventSource auto-reconnects; nothing to do here.
|
|
339
|
+
};
|
|
340
|
+
})();
|
|
97
341
|
})();
|
|
@@ -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
|
})();
|