solid_log-ui 0.1.0

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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +295 -0
  4. data/Rakefile +12 -0
  5. data/app/assets/javascripts/application.js +6 -0
  6. data/app/assets/javascripts/solid_log/checkbox_dropdown.js +171 -0
  7. data/app/assets/javascripts/solid_log/filter_state.js +138 -0
  8. data/app/assets/javascripts/solid_log/jump_to_live.js +119 -0
  9. data/app/assets/javascripts/solid_log/live_tail.js +476 -0
  10. data/app/assets/javascripts/solid_log/live_tail.js.bak +270 -0
  11. data/app/assets/javascripts/solid_log/log_filters.js +37 -0
  12. data/app/assets/javascripts/solid_log/stream_scroll.js +195 -0
  13. data/app/assets/javascripts/solid_log/timeline_histogram.js +162 -0
  14. data/app/assets/javascripts/solid_log/toast.js +50 -0
  15. data/app/assets/stylesheets/solid_log/application.css +1329 -0
  16. data/app/assets/stylesheets/solid_log/components.css +1506 -0
  17. data/app/assets/stylesheets/solid_log/mission_control.css +398 -0
  18. data/app/channels/solid_log/ui/application_cable/channel.rb +8 -0
  19. data/app/channels/solid_log/ui/application_cable/connection.rb +10 -0
  20. data/app/channels/solid_log/ui/log_stream_channel.rb +132 -0
  21. data/app/controllers/solid_log/ui/base_controller.rb +122 -0
  22. data/app/controllers/solid_log/ui/dashboard_controller.rb +32 -0
  23. data/app/controllers/solid_log/ui/entries_controller.rb +34 -0
  24. data/app/controllers/solid_log/ui/fields_controller.rb +57 -0
  25. data/app/controllers/solid_log/ui/streams_controller.rb +204 -0
  26. data/app/controllers/solid_log/ui/timelines_controller.rb +29 -0
  27. data/app/controllers/solid_log/ui/tokens_controller.rb +46 -0
  28. data/app/helpers/solid_log/ui/application_helper.rb +99 -0
  29. data/app/helpers/solid_log/ui/dashboard_helper.rb +46 -0
  30. data/app/helpers/solid_log/ui/entries_helper.rb +16 -0
  31. data/app/helpers/solid_log/ui/timeline_helper.rb +39 -0
  32. data/app/services/solid_log/ui/live_tail_broadcaster.rb +81 -0
  33. data/app/views/layouts/solid_log/ui/application.html.erb +53 -0
  34. data/app/views/solid_log/ui/dashboard/index.html.erb +178 -0
  35. data/app/views/solid_log/ui/entries/show.html.erb +132 -0
  36. data/app/views/solid_log/ui/fields/index.html.erb +133 -0
  37. data/app/views/solid_log/ui/shared/_checkbox_dropdown.html.erb +64 -0
  38. data/app/views/solid_log/ui/shared/_multiselect_filter.html.erb +37 -0
  39. data/app/views/solid_log/ui/shared/_toast.html.erb +7 -0
  40. data/app/views/solid_log/ui/shared/_toast_message.html.erb +30 -0
  41. data/app/views/solid_log/ui/streams/_filter_form.html.erb +207 -0
  42. data/app/views/solid_log/ui/streams/_footer.html.erb +37 -0
  43. data/app/views/solid_log/ui/streams/_log_entries.html.erb +5 -0
  44. data/app/views/solid_log/ui/streams/_log_row.html.erb +5 -0
  45. data/app/views/solid_log/ui/streams/_log_row_compact.html.erb +37 -0
  46. data/app/views/solid_log/ui/streams/_log_row_expanded.html.erb +67 -0
  47. data/app/views/solid_log/ui/streams/_log_stream_content.html.erb +8 -0
  48. data/app/views/solid_log/ui/streams/_timeline.html.erb +68 -0
  49. data/app/views/solid_log/ui/streams/index.html.erb +22 -0
  50. data/app/views/solid_log/ui/timelines/show_job.html.erb +78 -0
  51. data/app/views/solid_log/ui/timelines/show_request.html.erb +88 -0
  52. data/app/views/solid_log/ui/tokens/index.html.erb +95 -0
  53. data/app/views/solid_log/ui/tokens/new.html.erb +47 -0
  54. data/config/importmap.rb +15 -0
  55. data/config/routes.rb +27 -0
  56. data/lib/solid_log/ui/api_client.rb +117 -0
  57. data/lib/solid_log/ui/configuration.rb +99 -0
  58. data/lib/solid_log/ui/data_source.rb +146 -0
  59. data/lib/solid_log/ui/engine.rb +76 -0
  60. data/lib/solid_log/ui/version.rb +5 -0
  61. data/lib/solid_log/ui.rb +27 -0
  62. data/lib/solid_log-ui.rb +2 -0
  63. metadata +290 -0
@@ -0,0 +1,119 @@
1
+ // Jump to Live button functionality
2
+ (function() {
3
+ let isSetup = false;
4
+ let countdownInterval = null;
5
+
6
+ function clearCountdown() {
7
+ if (countdownInterval) {
8
+ clearInterval(countdownInterval);
9
+ countdownInterval = null;
10
+
11
+ const jumpButton = document.getElementById('jump-to-live');
12
+ if (jumpButton && jumpButton.disabled) {
13
+ jumpButton.disabled = false;
14
+ jumpButton.textContent = '↓ Jump to Live';
15
+ }
16
+ }
17
+ }
18
+
19
+ function handleJumpToLive(event) {
20
+ const button = event.currentTarget;
21
+
22
+ // Clear any existing countdown first
23
+ clearCountdown();
24
+
25
+ // Disable button for 5 seconds
26
+ button.disabled = true;
27
+ const originalText = button.textContent;
28
+ let countdown = 5;
29
+
30
+ countdownInterval = setInterval(() => {
31
+ countdown--;
32
+ button.textContent = `↓ Jump to Live (${countdown}s)`;
33
+ if (countdown <= 0) {
34
+ clearInterval(countdownInterval);
35
+ countdownInterval = null;
36
+ button.disabled = false;
37
+ button.textContent = originalText;
38
+ }
39
+ }, 1000);
40
+
41
+ // Get current URL and filters
42
+ const url = new URL(window.location.href);
43
+
44
+ // Fetch with turbo_stream format
45
+ fetch(url.pathname + url.search, {
46
+ headers: {
47
+ 'Accept': 'text/vnd.turbo-stream.html',
48
+ 'X-Requested-With': 'XMLHttpRequest'
49
+ }
50
+ })
51
+ .then(response => response.text())
52
+ .then(html => {
53
+ if (html && html.trim()) {
54
+ Turbo.renderStreamMessage(html);
55
+ }
56
+ })
57
+ .catch(error => {
58
+ console.error('Error jumping to live:', error);
59
+ // Re-enable button immediately on error
60
+ clearCountdown();
61
+ });
62
+ }
63
+
64
+ function setupJumpToLive() {
65
+ if (isSetup) return; // Prevent multiple setups
66
+
67
+ const jumpButton = document.getElementById('jump-to-live');
68
+
69
+ if (jumpButton) {
70
+ // Remove any existing listener first
71
+ jumpButton.removeEventListener('click', handleJumpToLive);
72
+ // Add the listener
73
+ jumpButton.addEventListener('click', handleJumpToLive);
74
+ isSetup = true;
75
+ }
76
+ }
77
+
78
+ // Setup on load
79
+ if (document.readyState === 'loading') {
80
+ document.addEventListener('DOMContentLoaded', setupJumpToLive);
81
+ } else {
82
+ setupJumpToLive();
83
+ }
84
+
85
+ // Re-setup on turbo navigation
86
+ document.addEventListener('turbo:load', () => {
87
+ isSetup = false; // Reset flag for new page
88
+ setupJumpToLive();
89
+ });
90
+
91
+ // Scroll to bottom after Turbo Stream renders (but NOT for live tail updates)
92
+ document.addEventListener('turbo:before-stream-render', function(event) {
93
+ // In Turbo 8, check event.target for the stream element
94
+ const streamElement = event.target;
95
+
96
+ if (streamElement && streamElement.tagName === 'TURBO-STREAM') {
97
+ const target = streamElement.getAttribute('target');
98
+ const action = streamElement.getAttribute('action');
99
+
100
+ // Only scroll for 'replace' actions (Jump to Live), not 'append' (live tail)
101
+ // Live tail uses 'append', Jump to Live uses 'replace'
102
+ if (target === 'log-stream-content' && action === 'replace') {
103
+ // Wait for DOM to fully update, then scroll
104
+ requestAnimationFrame(() => {
105
+ requestAnimationFrame(() => {
106
+ if (window.SolidLogStream && window.SolidLogStream.scrollToBottom) {
107
+ window.SolidLogStream.scrollToBottom();
108
+ }
109
+ });
110
+ });
111
+ }
112
+ }
113
+ });
114
+
115
+ // Export for live tail to clear countdown when new entries arrive
116
+ window.SolidLogJumpToLive = {
117
+ clearCountdown: clearCountdown
118
+ };
119
+ })();
@@ -0,0 +1,476 @@
1
+ // Live Tail functionality for SolidLog streams
2
+ // Supports both WebSocket (ActionCable) and HTTP polling modes
3
+ (function() {
4
+ let liveTailActive = false;
5
+ let pollingInterval = null;
6
+ let cableSubscription = null;
7
+ let lastEntryId = null;
8
+ let mode = null;
9
+ let isInitialFetch = true; // Skip highlighting on first fetch after starting live tail
10
+
11
+ function initializeLiveTail() {
12
+ const toggleButton = document.getElementById('live-tail-toggle');
13
+ if (!toggleButton) return;
14
+
15
+ mode = toggleButton.dataset.liveTailMode;
16
+ if (!mode || mode === 'disabled') return;
17
+
18
+ // Check if already initialized to prevent duplicate listeners
19
+ if (toggleButton.dataset.liveTailInitialized === 'true') {
20
+ // Still need to set up scroll listener even if already initialized
21
+ // because .log-stream element might have been replaced
22
+ setupScrollListener();
23
+ return;
24
+ }
25
+
26
+ toggleButton.addEventListener('click', function(e) {
27
+ e.preventDefault();
28
+ toggleLiveTail();
29
+ });
30
+
31
+ // Mark as initialized
32
+ toggleButton.dataset.liveTailInitialized = 'true';
33
+
34
+ // Set up jump-to-live button
35
+ initializeJumpToLive();
36
+
37
+ // Set up scroll listener
38
+ setupScrollListener();
39
+
40
+ // Store last entry ID for tracking
41
+ updateLastEntryId();
42
+ }
43
+
44
+ function setupScrollListener() {
45
+ const logStream = document.querySelector('.log-stream');
46
+ if (logStream) {
47
+ // Remove existing listener if any to prevent duplicates
48
+ logStream.removeEventListener('scroll', checkScrollPosition);
49
+ logStream.addEventListener('scroll', checkScrollPosition, { passive: true });
50
+ }
51
+ }
52
+
53
+ function toggleLiveTail() {
54
+ liveTailActive = !liveTailActive;
55
+ const button = document.getElementById('live-tail-toggle');
56
+ const indicator = document.getElementById('live-tail-indicator');
57
+
58
+ if (liveTailActive) {
59
+ startLiveTail();
60
+ button.textContent = '⏸ Pause';
61
+ button.classList.add('btn-primary');
62
+ button.classList.remove('btn-secondary');
63
+
64
+ // Show indicator
65
+ if (indicator) {
66
+ indicator.style.display = 'inline-flex';
67
+ }
68
+
69
+ // Show toast notification
70
+ if (window.SolidLogToast) {
71
+ window.SolidLogToast.show(`Live tail ${mode === 'websocket' ? 'streaming' : 'polling'} started`, 'info');
72
+ }
73
+ } else {
74
+ stopLiveTail();
75
+ button.textContent = '▶ Live Tail';
76
+ button.classList.remove('btn-primary');
77
+ button.classList.add('btn-secondary');
78
+
79
+ // Hide indicator
80
+ if (indicator) {
81
+ indicator.style.display = 'none';
82
+ }
83
+
84
+ if (window.SolidLogToast) {
85
+ window.SolidLogToast.show('Live tail stopped', 'info');
86
+ }
87
+ }
88
+ }
89
+
90
+ function startLiveTail() {
91
+ // Reset flag - next fetch will be the initial one
92
+ isInitialFetch = true;
93
+
94
+ // Clear any time filters from URL (start_time, end_time, before_id, after_id)
95
+ clearTimeFilters();
96
+
97
+ console.log('startLiveTail - mode:', mode, 'createConsumer available:', typeof createConsumer !== 'undefined');
98
+
99
+ if (mode === 'websocket' && typeof createConsumer !== 'undefined') {
100
+ console.log('Starting WebSocket tail');
101
+ startWebSocketTail();
102
+ } else {
103
+ console.log('Starting polling tail (fallback or polling mode)');
104
+ // Fallback to polling if websocket unavailable or mode is 'polling'
105
+ startPollingTail();
106
+ }
107
+
108
+ // Don't auto-scroll - let user maintain their position
109
+ // Show jump-to-live button if new entries arrive while scrolled up
110
+ }
111
+
112
+ function stopLiveTail() {
113
+ if (cableSubscription) {
114
+ cableSubscription.unsubscribe();
115
+ cableSubscription = null;
116
+ }
117
+
118
+ if (pollingInterval) {
119
+ clearInterval(pollingInterval);
120
+ pollingInterval = null;
121
+ }
122
+ }
123
+
124
+ function startWebSocketTail() {
125
+ // Get current filter params
126
+ const filters = getCurrentFilters();
127
+
128
+ // Create ActionCable subscription
129
+ const consumer = createConsumer();
130
+ cableSubscription = consumer.subscriptions.create(
131
+ {
132
+ channel: "SolidLog::UI::LogStreamChannel",
133
+ filters: filters
134
+ },
135
+ {
136
+ connected() {
137
+ console.log('Connected to log stream');
138
+
139
+ // Send heartbeat every 2 minutes to keep cache entry alive
140
+ this.heartbeatInterval = setInterval(() => {
141
+ this.perform('refresh_subscription');
142
+ }, 2 * 60 * 1000);
143
+ },
144
+
145
+ disconnected() {
146
+ console.log('Disconnected from log stream');
147
+
148
+ // Clear heartbeat interval
149
+ if (this.heartbeatInterval) {
150
+ clearInterval(this.heartbeatInterval);
151
+ this.heartbeatInterval = null;
152
+ }
153
+
154
+ // Reset button state to show stream has stopped
155
+ resetLiveTailButton();
156
+
157
+ // If still active, fallback to polling
158
+ if (liveTailActive) {
159
+ console.log('Falling back to polling mode');
160
+ if (window.SolidLogToast) {
161
+ window.SolidLogToast.show('Connection lost, switching to polling mode', 'warning');
162
+ }
163
+ startPollingTail();
164
+ }
165
+ },
166
+
167
+ received(data) {
168
+ // Received new log entry via websocket (already filtered server-side)
169
+ console.log('[LiveTail] WebSocket received data:', data, 'isInitialFetch:', isInitialFetch, 'has html:', !!data.html);
170
+ if (data.html) {
171
+ console.log('[LiveTail] Appending entry with ID:', data.entry_id);
172
+ appendEntry(data.html);
173
+ updateLastEntryId();
174
+
175
+ // Only highlight if this is NOT the initial message
176
+ if (!isInitialFetch) {
177
+ console.log('[LiveTail] Not initial message - highlighting button');
178
+ highlightJumpToLive();
179
+ // Update timeline to show new data has arrived
180
+ updateTimeline();
181
+ } else {
182
+ console.log('[LiveTail] Initial message - skipping highlight');
183
+ }
184
+
185
+ // Mark that we've received the initial message
186
+ isInitialFetch = false;
187
+ } else {
188
+ console.log('[LiveTail] No HTML in data, ignoring');
189
+ }
190
+ }
191
+ }
192
+ );
193
+ }
194
+
195
+ function startPollingTail() {
196
+ // Poll every 2 seconds
197
+ pollingInterval = setInterval(function() {
198
+ fetchNewEntries();
199
+ }, 2000);
200
+
201
+ // Initial fetch
202
+ fetchNewEntries();
203
+ }
204
+
205
+ function fetchNewEntries() {
206
+ const streamsPath = document.body.dataset.streamsPath || '/streams';
207
+ const url = new URL(window.location.origin + streamsPath);
208
+
209
+ // Copy current filters
210
+ const currentParams = new URLSearchParams(window.location.search);
211
+ currentParams.forEach((value, key) => {
212
+ url.searchParams.append(key, value);
213
+ });
214
+
215
+ // Add after_id parameter if we have a last entry
216
+ if (lastEntryId) {
217
+ url.searchParams.set('after_id', lastEntryId);
218
+ }
219
+
220
+ // Request turbo stream format
221
+ fetch(url.toString(), {
222
+ headers: {
223
+ 'Accept': 'text/vnd.turbo-stream.html',
224
+ 'X-Requested-With': 'XMLHttpRequest'
225
+ }
226
+ })
227
+ .then(response => {
228
+ if (!response.ok) throw new Error('Network response was not ok');
229
+ return response.text();
230
+ })
231
+ .then(html => {
232
+ console.log('Polling received HTML, length:', html ? html.length : 0, 'isInitialFetch:', isInitialFetch);
233
+ if (html && html.trim()) {
234
+ // Turbo will automatically process the stream response
235
+ Turbo.renderStreamMessage(html);
236
+ updateLastEntryId();
237
+
238
+ // Only highlight if this is NOT the initial fetch
239
+ if (!isInitialFetch) {
240
+ console.log('Not initial fetch - highlighting button');
241
+ highlightJumpToLive();
242
+ // Update timeline to show new data has arrived
243
+ updateTimeline();
244
+ } else {
245
+ console.log('Initial fetch - skipping highlight');
246
+ }
247
+
248
+ // Mark that we've completed the initial fetch
249
+ isInitialFetch = false;
250
+ }
251
+ })
252
+ .catch(error => {
253
+ console.error('Error fetching new logs:', error);
254
+ });
255
+ }
256
+
257
+ function appendEntry(html) {
258
+ const logStream = document.getElementById('log-stream-content');
259
+ if (!logStream) return;
260
+
261
+ const temp = document.createElement('div');
262
+ temp.innerHTML = html;
263
+
264
+ // Append new entries
265
+ while (temp.firstChild) {
266
+ logStream.appendChild(temp.firstChild);
267
+ }
268
+ }
269
+
270
+ function updateLastEntryId() {
271
+ const logStream = document.getElementById('log-stream-content');
272
+ if (!logStream) return;
273
+
274
+ const entries = logStream.querySelectorAll('[data-entry-id]');
275
+ if (entries.length > 0) {
276
+ const lastEntry = entries[entries.length - 1];
277
+ lastEntryId = lastEntry.dataset.entryId;
278
+ }
279
+ }
280
+
281
+ function clearTimeFilters() {
282
+ // Remove time-based filters from URL when starting live tail
283
+ const url = new URL(window.location.href);
284
+ const params = url.searchParams;
285
+
286
+ // Remove time filters
287
+ params.delete('filters[start_time]');
288
+ params.delete('filters[end_time]');
289
+ params.delete('before_id');
290
+ params.delete('after_id');
291
+
292
+ // Update URL without reloading the page
293
+ window.history.replaceState({}, '', url);
294
+
295
+ // Clear the timeline selection visually if it exists
296
+ const timelineController = document.querySelector('[data-controller="timeline-histogram"]');
297
+ if (timelineController && window.Stimulus) {
298
+ const controller = window.Stimulus.getControllerForElementAndIdentifier(timelineController, 'timeline-histogram');
299
+ if (controller && controller.clearSelection) {
300
+ controller.clearSelection();
301
+ }
302
+ }
303
+ }
304
+
305
+ function updateTimeline() {
306
+ // Fetch updated timeline data when new entries arrive during live tail
307
+ const url = new URL(window.location.href);
308
+ url.searchParams.set('timeline_only', '1');
309
+
310
+ fetch(url, {
311
+ headers: {
312
+ 'Accept': 'text/vnd.turbo-stream.html',
313
+ 'X-Requested-With': 'XMLHttpRequest'
314
+ }
315
+ })
316
+ .then(response => response.text())
317
+ .then(html => {
318
+ if (html && html.trim()) {
319
+ Turbo.renderStreamMessage(html);
320
+ }
321
+ })
322
+ .catch(error => {
323
+ console.error('Error updating timeline:', error);
324
+ });
325
+ }
326
+
327
+ function getCurrentFilters() {
328
+ const filters = {};
329
+ const params = new URLSearchParams(window.location.search);
330
+
331
+ params.forEach((value, key) => {
332
+ if (key.startsWith('filters[')) {
333
+ const filterKey = key.match(/filters\[(.*?)\]/)[1];
334
+ // Skip time filters - they should be cleared for live tail
335
+ if (filterKey === 'start_time' || filterKey === 'end_time') {
336
+ return;
337
+ }
338
+ if (!filters[filterKey]) {
339
+ filters[filterKey] = [];
340
+ }
341
+ filters[filterKey].push(value);
342
+ }
343
+ });
344
+
345
+ return filters;
346
+ }
347
+
348
+ function resetLiveTailButton() {
349
+ const button = document.getElementById('live-tail-toggle');
350
+ const indicator = document.getElementById('live-tail-indicator');
351
+
352
+ if (button) {
353
+ button.textContent = '▶ Live Tail';
354
+ button.classList.remove('btn-primary');
355
+ button.classList.add('btn-secondary');
356
+ liveTailActive = false;
357
+ }
358
+
359
+ if (indicator) {
360
+ indicator.style.display = 'none';
361
+ }
362
+ }
363
+
364
+ function scrollToBottom() {
365
+ setTimeout(() => {
366
+ if (window.SolidLogStream && window.SolidLogStream.scrollToBottom) {
367
+ window.SolidLogStream.scrollToBottom();
368
+ } else {
369
+ const logStream = document.querySelector('.log-stream');
370
+ if (logStream) {
371
+ logStream.scrollTop = logStream.scrollHeight;
372
+ }
373
+ }
374
+ }, 50);
375
+ }
376
+
377
+ function initializeJumpToLive() {
378
+ const jumpButton = document.getElementById('jump-to-live');
379
+ if (!jumpButton) return;
380
+
381
+ // Check if already initialized
382
+ if (jumpButton.dataset.jumpInitialized === 'true') {
383
+ return;
384
+ }
385
+
386
+ jumpButton.addEventListener('click', function(e) {
387
+ e.preventDefault();
388
+ scrollToBottom();
389
+ clearJumpToLiveHighlight();
390
+ });
391
+
392
+ jumpButton.dataset.jumpInitialized = 'true';
393
+ }
394
+
395
+ function highlightJumpToLive() {
396
+ const jumpButton = document.getElementById('jump-to-live');
397
+ if (!jumpButton) return;
398
+
399
+ console.log('highlightJumpToLive() called');
400
+
401
+ // Clear countdown timer if button is disabled
402
+ if (window.SolidLogJumpToLive && window.SolidLogJumpToLive.clearCountdown) {
403
+ window.SolidLogJumpToLive.clearCountdown();
404
+ }
405
+
406
+ // Always highlight when new entries arrive
407
+ console.log('Adding has-new-entries class to button');
408
+ jumpButton.classList.add('has-new-entries');
409
+ console.log('Button classes now:', jumpButton.className);
410
+ }
411
+
412
+ function clearJumpToLiveHighlight() {
413
+ const jumpButton = document.getElementById('jump-to-live');
414
+ if (jumpButton) {
415
+ jumpButton.classList.remove('has-new-entries');
416
+ }
417
+ }
418
+
419
+ function checkScrollPosition() {
420
+ // Clear highlight when user scrolls to bottom
421
+ const atBottom = isAtBottom();
422
+ console.log('[LiveTail] checkScrollPosition - atBottom:', atBottom);
423
+ if (atBottom) {
424
+ console.log('[LiveTail] Clearing jump-to-live highlight (user scrolled to bottom)');
425
+ clearJumpToLiveHighlight();
426
+ }
427
+ }
428
+
429
+ function isAtBottom() {
430
+ const logStream = document.querySelector('.log-stream');
431
+ if (!logStream) return true;
432
+
433
+ // Consider "at bottom" if within 10px of the bottom
434
+ const scrollDistance = logStream.scrollHeight - logStream.scrollTop - logStream.clientHeight;
435
+ console.log('[LiveTail] isAtBottom check - scrollHeight:', logStream.scrollHeight,
436
+ 'scrollTop:', logStream.scrollTop, 'clientHeight:', logStream.clientHeight,
437
+ 'scrollDistance:', scrollDistance);
438
+ return scrollDistance < 10;
439
+ }
440
+
441
+ // Initialize on page load
442
+ if (document.readyState === 'loading') {
443
+ document.addEventListener('DOMContentLoaded', initializeLiveTail);
444
+ } else {
445
+ initializeLiveTail();
446
+ }
447
+
448
+ // Re-initialize on turbo navigation
449
+ document.addEventListener('turbo:load', function() {
450
+ // Reset state on new page
451
+ liveTailActive = false;
452
+ lastEntryId = null;
453
+
454
+ initializeLiveTail();
455
+ });
456
+
457
+ // Stop live tail when navigating away or changing filters
458
+ document.addEventListener('turbo:before-visit', function() {
459
+ if (liveTailActive) {
460
+ stopLiveTail();
461
+ liveTailActive = false;
462
+ console.log('Stopped live tail due to navigation');
463
+ }
464
+ });
465
+
466
+ // Stop live tail when filter form is submitted
467
+ document.addEventListener('submit', function(e) {
468
+ if (e.target.closest('.filter-form') && liveTailActive) {
469
+ stopLiveTail();
470
+ console.log('Stopped live tail due to filter change');
471
+ }
472
+ });
473
+
474
+ // Clean up on page unload
475
+ window.addEventListener('beforeunload', stopLiveTail);
476
+ })();