@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,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
+ })();
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file packages/coordinator/public/js/log-viewer.js
3
- * @description Dashboard: streams or fetches a job's log output into the log viewer pane
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
- lines = [],
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
- 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;
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
- filter.addEventListener('change', render);
57
-
58
- // Already finished when the page loaded: the output will never change
59
- // again, so just fetch it once — no EventSource, no live connection, no
60
- // reload. (Opening a stream here used to trigger an auto-reload once it
61
- // immediately got the job's "end" event, which reloaded the page, which
62
- // re-opened the stream, which reloaded again — an infinite loop for
63
- // anyone viewing a finished job.)
64
- if (!jobActive){
65
- fetch(`/jobs/${jobId}/logs`)
66
- .then((r) => r.json())
67
- .then(({ lines: fetched }) => {
68
- lines.push(...fetched);
69
- render();
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
73
94
  }
74
95
 
75
- const source = new EventSource(`/jobs/${jobId}/stream`);
76
- source.addEventListener('log', (e) => {
77
- lines.push(JSON.parse(e.data));
78
- render();
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
- source.addEventListener('state', (e) => {
81
- const { state, resource } = JSON.parse(e.data);
82
- lines.push({ ts: new Date().toISOString(), stream: 'state', line: `-> ${state}${resource ? ' on ' + resource : ''}` });
83
- render();
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
- source.addEventListener('end', (e) => {
86
- const { state } = JSON.parse(e.data);
87
- lines.push({ ts: new Date().toISOString(), stream: 'state', line: `job finished: ${state}` });
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
- source.close();
90
- // The job was active when we opened this page and just finished —
91
- // reload once to pick up the now-final badge/artifacts list.
92
- setTimeout(() => window.location.reload(), 1500);
93
- });
94
- source.onerror = () => {
95
- // EventSource auto-reconnects; nothing to do here.
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
- 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
  })();