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,270 @@
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
+
10
+ function initializeLiveTail() {
11
+ const toggleButton = document.getElementById('live-tail-toggle');
12
+ if (!toggleButton) return;
13
+
14
+ mode = toggleButton.dataset.liveTailMode;
15
+ if (!mode || mode === 'disabled') return;
16
+
17
+ toggleButton.addEventListener('click', function(e) {
18
+ e.preventDefault();
19
+ toggleLiveTail();
20
+ });
21
+
22
+ // Store last entry ID for tracking
23
+ updateLastEntryId();
24
+ }
25
+
26
+ function toggleLiveTail() {
27
+ liveTailActive = !liveTailActive;
28
+ const button = document.getElementById('live-tail-toggle');
29
+
30
+ if (liveTailActive) {
31
+ startLiveTail();
32
+ button.textContent = '⏸ Pause';
33
+ button.classList.add('btn-primary');
34
+ button.classList.remove('btn-secondary');
35
+
36
+ // Show toast notification
37
+ if (window.SolidLogToast) {
38
+ window.SolidLogToast.show(`Live tail ${mode === 'websocket' ? 'streaming' : 'polling'} started`, 'info');
39
+ }
40
+ } else {
41
+ stopLiveTail();
42
+ button.textContent = '▶ Live Tail';
43
+ button.classList.remove('btn-primary');
44
+ button.classList.add('btn-secondary');
45
+
46
+ if (window.SolidLogToast) {
47
+ window.SolidLogToast.show('Live tail stopped', 'info');
48
+ }
49
+ }
50
+ }
51
+
52
+ function startLiveTail() {
53
+ if (mode === 'websocket' && typeof createConsumer !== 'undefined') {
54
+ startWebSocketTail();
55
+ } else {
56
+ // Fallback to polling if websocket unavailable or mode is 'polling'
57
+ startPollingTail();
58
+ }
59
+
60
+ // Auto-scroll to bottom
61
+ scrollToBottom();
62
+ }
63
+
64
+ function stopLiveTail() {
65
+ if (cableSubscription) {
66
+ cableSubscription.unsubscribe();
67
+ cableSubscription = null;
68
+ }
69
+
70
+ if (pollingInterval) {
71
+ clearInterval(pollingInterval);
72
+ pollingInterval = null;
73
+ }
74
+ }
75
+
76
+ function startWebSocketTail() {
77
+ // Get current filter params
78
+ const filters = getCurrentFilters();
79
+
80
+ // Create ActionCable subscription
81
+ const consumer = createConsumer();
82
+ cableSubscription = consumer.subscriptions.create(
83
+ {
84
+ channel: "SolidLog::LogStreamChannel",
85
+ filters: filters
86
+ },
87
+ {
88
+ connected() {
89
+ console.log('Connected to log stream');
90
+ },
91
+
92
+ disconnected() {
93
+ console.log('Disconnected from log stream');
94
+
95
+ // Reset button state to show stream has stopped
96
+ resetLiveTailButton();
97
+
98
+ // If still active, fallback to polling
99
+ if (liveTailActive) {
100
+ console.log('Falling back to polling mode');
101
+ if (window.SolidLogToast) {
102
+ window.SolidLogToast.show('Connection lost, switching to polling mode', 'warning');
103
+ }
104
+ startPollingTail();
105
+ }
106
+ },
107
+
108
+ received(data) {
109
+ // Received new log entry via websocket (already filtered server-side)
110
+ if (data.html) {
111
+ appendEntry(data.html);
112
+ updateLastEntryId();
113
+ scrollToBottom();
114
+ }
115
+ }
116
+ }
117
+ );
118
+ }
119
+
120
+ function startPollingTail() {
121
+ // Poll every 2 seconds
122
+ pollingInterval = setInterval(function() {
123
+ fetchNewEntries();
124
+ }, 2000);
125
+
126
+ // Initial fetch
127
+ fetchNewEntries();
128
+ }
129
+
130
+ function fetchNewEntries() {
131
+ const streamsPath = document.body.dataset.streamsPath || '/streams';
132
+ const url = new URL(window.location.origin + streamsPath);
133
+
134
+ // Copy current filters
135
+ const currentParams = new URLSearchParams(window.location.search);
136
+ currentParams.forEach((value, key) => {
137
+ url.searchParams.append(key, value);
138
+ });
139
+
140
+ // Add after_id parameter if we have a last entry
141
+ if (lastEntryId) {
142
+ url.searchParams.set('after_id', lastEntryId);
143
+ }
144
+
145
+ // Request turbo stream format
146
+ fetch(url.toString(), {
147
+ headers: {
148
+ 'Accept': 'text/vnd.turbo-stream.html',
149
+ 'X-Requested-With': 'XMLHttpRequest'
150
+ }
151
+ })
152
+ .then(response => {
153
+ if (!response.ok) throw new Error('Network response was not ok');
154
+ return response.text();
155
+ })
156
+ .then(html => {
157
+ if (html && html.trim()) {
158
+ // Turbo will automatically process the stream response
159
+ Turbo.renderStreamMessage(html);
160
+ updateLastEntryId();
161
+ scrollToBottom();
162
+ }
163
+ })
164
+ .catch(error => {
165
+ console.error('Error fetching new logs:', error);
166
+ });
167
+ }
168
+
169
+ function appendEntry(html) {
170
+ const logStream = document.getElementById('log-stream-content');
171
+ if (!logStream) return;
172
+
173
+ const temp = document.createElement('div');
174
+ temp.innerHTML = html;
175
+
176
+ // Append new entries
177
+ while (temp.firstChild) {
178
+ logStream.appendChild(temp.firstChild);
179
+ }
180
+ }
181
+
182
+ function updateLastEntryId() {
183
+ const logStream = document.getElementById('log-stream-content');
184
+ if (!logStream) return;
185
+
186
+ const entries = logStream.querySelectorAll('[data-entry-id]');
187
+ if (entries.length > 0) {
188
+ const lastEntry = entries[entries.length - 1];
189
+ lastEntryId = lastEntry.dataset.entryId;
190
+ }
191
+ }
192
+
193
+ function getCurrentFilters() {
194
+ const filters = {};
195
+ const params = new URLSearchParams(window.location.search);
196
+
197
+ params.forEach((value, key) => {
198
+ if (key.startsWith('filters[')) {
199
+ const filterKey = key.match(/filters\[(.*?)\]/)[1];
200
+ if (!filters[filterKey]) {
201
+ filters[filterKey] = [];
202
+ }
203
+ filters[filterKey].push(value);
204
+ }
205
+ });
206
+
207
+ return filters;
208
+ }
209
+
210
+ function entryMatchesFilters(entry) {
211
+ const filters = getCurrentFilters();
212
+
213
+ // If no filters, show all entries
214
+ if (Object.keys(filters).length === 0) {
215
+ return true;
216
+ }
217
+
218
+ // Check each filter
219
+ for (const [key, values] of Object.entries(filters)) {
220
+ if (!values || values.length === 0) continue;
221
+
222
+ // Get the entry's value for this filter
223
+ const entryValue = entry[key];
224
+ if (!entryValue) {
225
+ // If filter is set but entry doesn't have this field, exclude it
226
+ return false;
227
+ }
228
+
229
+ // For array filters (levels, app, etc.), check if entry value is in the filter array
230
+ if (Array.isArray(values)) {
231
+ if (!values.includes(String(entryValue))) {
232
+ return false;
233
+ }
234
+ } else {
235
+ // For single value filters
236
+ if (String(entryValue) !== String(values)) {
237
+ return false;
238
+ }
239
+ }
240
+ }
241
+
242
+ return true;
243
+ }
244
+
245
+ function scrollToBottom() {
246
+ setTimeout(() => {
247
+ if (window.SolidLogStream && window.SolidLogStream.scrollToBottom) {
248
+ window.SolidLogStream.scrollToBottom();
249
+ } else {
250
+ const streamsMain = document.querySelector('.streams-main');
251
+ if (streamsMain) {
252
+ streamsMain.scrollTop = streamsMain.scrollHeight;
253
+ }
254
+ }
255
+ }, 50);
256
+ }
257
+
258
+ // Initialize on page load
259
+ if (document.readyState === 'loading') {
260
+ document.addEventListener('DOMContentLoaded', initializeLiveTail);
261
+ } else {
262
+ initializeLiveTail();
263
+ }
264
+
265
+ // Re-initialize on turbo navigation
266
+ document.addEventListener('turbo:load', initializeLiveTail);
267
+
268
+ // Clean up on page unload
269
+ window.addEventListener('beforeunload', stopLiveTail);
270
+ })();
@@ -0,0 +1,37 @@
1
+ // Log Filter Buttons
2
+ // Handles click events on log filter buttons to apply filters
3
+
4
+ document.addEventListener('DOMContentLoaded', function() {
5
+ // Handle filter button clicks
6
+ document.addEventListener('click', function(e) {
7
+ if (e.target.matches('.log-filter-btn') || e.target.closest('.log-filter-btn')) {
8
+ e.preventDefault();
9
+ e.stopPropagation();
10
+
11
+ const button = e.target.matches('.log-filter-btn') ? e.target : e.target.closest('.log-filter-btn');
12
+ const filterType = button.dataset.filterType;
13
+ const filterValue = button.dataset.filterValue;
14
+
15
+ if (!filterType || !filterValue) return;
16
+
17
+ // Always redirect to streams index when filtering
18
+ const streamsPath = document.body.dataset.streamsPath || '/streams';
19
+ const url = new URL(window.location.origin + streamsPath);
20
+
21
+ // Map filter types to URL parameters
22
+ const paramMapping = {
23
+ 'request_id': 'filters[request_id]',
24
+ 'ip': 'filters[ip]',
25
+ 'user_id': 'filters[user_id]',
26
+ 'method': 'filters[method][]',
27
+ 'app': 'filters[app][]'
28
+ };
29
+
30
+ const paramName = paramMapping[filterType];
31
+ if (paramName) {
32
+ url.searchParams.set(paramName, filterValue);
33
+ window.location.href = url.toString();
34
+ }
35
+ }
36
+ });
37
+ });
@@ -0,0 +1,195 @@
1
+ // Stream scroll behavior: newest at bottom, infinite scroll up, auto-scroll
2
+ (function() {
3
+ let isScrollingProgrammatically = false;
4
+ let oldestEntryId = null;
5
+ let isLoadingMore = false;
6
+ let hasMoreEntries = true;
7
+ let hasInitialized = false;
8
+
9
+ function initializeStreamScroll() {
10
+ const logStreamContent = document.getElementById('log-stream-content');
11
+ if (!logStreamContent) return;
12
+
13
+ // Get the oldest entry ID for pagination (first entry in the list)
14
+ updateOldestEntryId();
15
+
16
+ // Auto-scroll to bottom ONLY on initial page load (not on Turbo updates)
17
+ // Check if live tail is active - don't auto-scroll if it is
18
+ const liveTailButton = document.getElementById('live-tail-toggle');
19
+ const liveTailActive = liveTailButton && liveTailButton.textContent.includes('Pause');
20
+
21
+ if (!hasInitialized && !liveTailActive) {
22
+ // Use requestAnimationFrame to ensure DOM is fully rendered and laid out
23
+ requestAnimationFrame(() => {
24
+ requestAnimationFrame(() => {
25
+ scrollToBottom();
26
+ });
27
+ });
28
+ hasInitialized = true;
29
+ }
30
+
31
+ // Infinite scroll: load more when scrolling up
32
+ const logStream = document.querySelector('.log-stream');
33
+ if (logStream) {
34
+ console.log('Attaching scroll listener to .log-stream');
35
+ logStream.addEventListener('scroll', function() {
36
+ console.log('Scroll event - scrollTop:', this.scrollTop, 'isScrollingProgrammatically:', isScrollingProgrammatically, 'isLoadingMore:', isLoadingMore);
37
+
38
+ if (isScrollingProgrammatically || isLoadingMore) return;
39
+
40
+ // Check if scrolled to top (or near top)
41
+ if (this.scrollTop < 100) {
42
+ console.log('Near top, triggering loadMoreLogs');
43
+ loadMoreLogs();
44
+ }
45
+ });
46
+ } else {
47
+ console.log('WARNING: .log-stream not found!');
48
+ }
49
+ }
50
+
51
+ function updateOldestEntryId() {
52
+ const logStreamContent = document.getElementById('log-stream-content');
53
+ if (!logStreamContent) {
54
+ console.log('updateOldestEntryId: log-stream-content not found');
55
+ return;
56
+ }
57
+
58
+ const entries = logStreamContent.querySelectorAll('.log-row-compact-wrapper, .log-row');
59
+ console.log('updateOldestEntryId: found', entries.length, 'entries');
60
+
61
+ if (entries.length > 0) {
62
+ // Get the FIRST entry (oldest, since DOM is in ASC order after reversing)
63
+ const firstEntry = entries[0];
64
+ const newOldestId = firstEntry.dataset.entryId;
65
+ console.log('updateOldestEntryId: newOldestId =', newOldestId, ', oldestEntryId =', oldestEntryId);
66
+
67
+ // If the oldest ID hasn't changed after a fetch, we've reached the end
68
+ if (oldestEntryId !== null && newOldestId === oldestEntryId) {
69
+ console.log('updateOldestEntryId: reached the end (ID unchanged)');
70
+ hasMoreEntries = false;
71
+ } else {
72
+ console.log('updateOldestEntryId: updating to', newOldestId);
73
+ oldestEntryId = newOldestId;
74
+ hasMoreEntries = true;
75
+ }
76
+ }
77
+ }
78
+
79
+ function scrollToBottom() {
80
+ isScrollingProgrammatically = true;
81
+ const logStream = document.querySelector('.log-stream');
82
+ if (logStream) {
83
+ // Scroll to bottom
84
+ logStream.scrollTop = logStream.scrollHeight;
85
+ }
86
+ setTimeout(() => {
87
+ isScrollingProgrammatically = false;
88
+ }, 100);
89
+ }
90
+
91
+ function loadMoreLogs() {
92
+ console.log('loadMoreLogs called - isLoadingMore:', isLoadingMore, 'oldestEntryId:', oldestEntryId, 'hasMoreEntries:', hasMoreEntries);
93
+
94
+ if (isLoadingMore || !oldestEntryId || !hasMoreEntries) {
95
+ console.log('Cannot load more - bailing out');
96
+ return;
97
+ }
98
+
99
+ console.log('Loading more logs before ID:', oldestEntryId);
100
+ isLoadingMore = true;
101
+
102
+ // Store scroll position before loading
103
+ const logStream = document.querySelector('.log-stream');
104
+ const scrollHeightBefore = logStream.scrollHeight;
105
+ const scrollTopBefore = logStream.scrollTop;
106
+
107
+ // Get current filters from the URL
108
+ const url = new URL(window.location.href);
109
+ const params = new URLSearchParams(url.search);
110
+
111
+ // Add pagination parameter (load logs before the oldest ID)
112
+ params.set('before_id', oldestEntryId);
113
+ params.set('limit', '50'); // Load 50 more entries
114
+
115
+ // Show loading indicator
116
+ showLoadingIndicator();
117
+
118
+ // Fetch more logs via Turbo Stream
119
+ fetch(`${url.pathname}?${params.toString()}`, {
120
+ headers: {
121
+ 'Accept': 'text/vnd.turbo-stream.html',
122
+ 'X-Requested-With': 'XMLHttpRequest'
123
+ }
124
+ })
125
+ .then(response => {
126
+ console.log('Fetch response status:', response.status, 'ok:', response.ok);
127
+ if (response.ok) {
128
+ return response.text();
129
+ }
130
+ throw new Error('No more logs');
131
+ })
132
+ .then(html => {
133
+ console.log('Received HTML, length:', html.length);
134
+ // Turbo will handle prepending via turbo_stream.prepend
135
+ // We just need to update the oldest entry ID and restore scroll position
136
+ if (window.Turbo) {
137
+ console.log('Rendering Turbo stream');
138
+ window.Turbo.renderStreamMessage(html);
139
+ }
140
+
141
+ // Update oldest entry ID after Turbo renders
142
+ setTimeout(() => {
143
+ console.log('Updating oldest entry ID after render');
144
+ updateOldestEntryId();
145
+
146
+ // Maintain scroll position (so the view doesn't jump)
147
+ const scrollHeightAfter = logStream.scrollHeight;
148
+ const scrollDifference = scrollHeightAfter - scrollHeightBefore;
149
+ console.log('Scroll adjustment - before:', scrollHeightBefore, 'after:', scrollHeightAfter, 'diff:', scrollDifference);
150
+ logStream.scrollTop = scrollTopBefore + scrollDifference;
151
+
152
+ hideLoadingIndicator();
153
+ isLoadingMore = false;
154
+ console.log('Load complete');
155
+ }, 50);
156
+ })
157
+ .catch(error => {
158
+ console.log('Fetch error:', error.message);
159
+ hideLoadingIndicator();
160
+ isLoadingMore = false;
161
+ hasMoreEntries = false;
162
+ });
163
+ }
164
+
165
+ function showLoadingIndicator() {
166
+ const logStream = document.querySelector('.log-stream');
167
+ if (!logStream) return;
168
+
169
+ const indicator = document.createElement('div');
170
+ indicator.className = 'loading-indicator';
171
+ indicator.textContent = 'Loading more logs...';
172
+ logStream.insertBefore(indicator, logStream.firstChild);
173
+ }
174
+
175
+ function hideLoadingIndicator() {
176
+ const indicator = document.querySelector('.loading-indicator');
177
+ if (indicator) {
178
+ indicator.remove();
179
+ }
180
+ }
181
+
182
+ // For Turbo apps, turbo:load handles both initial load and navigations
183
+ document.addEventListener('turbo:load', initializeStreamScroll);
184
+
185
+ // Reset initialization flag on actual navigation
186
+ document.addEventListener('turbo:before-visit', function() {
187
+ hasInitialized = false;
188
+ });
189
+
190
+ // Export for live tail usage
191
+ window.SolidLogStream = {
192
+ scrollToBottom: scrollToBottom,
193
+ updateOldestEntryId: updateOldestEntryId
194
+ };
195
+ })();
@@ -0,0 +1,162 @@
1
+ // Timeline Histogram functionality for log stream filtering
2
+ (function() {
3
+ function initializeTimelineHistograms() {
4
+ document.querySelectorAll('[data-controller="timeline-histogram"]').forEach(histogram => {
5
+ const chart = histogram.querySelector('[data-timeline-histogram-target="chart"]');
6
+ const barsContainer = histogram.querySelector('[data-timeline-histogram-target="barsContainer"]');
7
+ const bars = histogram.querySelectorAll('[data-timeline-histogram-target="bar"]');
8
+ const tooltip = histogram.querySelector('[data-timeline-histogram-target="tooltip"]');
9
+ const selection = histogram.querySelector('[data-timeline-histogram-target="selection"]');
10
+ const form = histogram.querySelector('[data-timeline-histogram-target="form"]');
11
+ const startTimeField = histogram.querySelector('[data-timeline-histogram-target="startTimeField"]');
12
+ const endTimeField = histogram.querySelector('[data-timeline-histogram-target="endTimeField"]');
13
+
14
+ if (!chart || !bars.length) return;
15
+
16
+ let selectionStart = null;
17
+ let selectionEnd = null;
18
+ let isDragging = false;
19
+
20
+ // Show tooltip on hover
21
+ bars.forEach(bar => {
22
+ bar.addEventListener('mouseenter', function() {
23
+ const count = this.dataset.count;
24
+ const startTime = new Date(this.dataset.startTime);
25
+ const endTime = new Date(this.dataset.endTime);
26
+
27
+ tooltip.innerHTML = `
28
+ <div class="tooltip-time">${formatTime(startTime)} - ${formatTime(endTime)}</div>
29
+ <div class="tooltip-count">${count} log${count == 1 ? '' : 's'}</div>
30
+ `;
31
+
32
+ const rect = this.getBoundingClientRect();
33
+ const chartRect = chart.getBoundingClientRect();
34
+
35
+ tooltip.style.display = 'block';
36
+ tooltip.style.left = (rect.left - chartRect.left + rect.width / 2) + 'px';
37
+ tooltip.style.top = (rect.top - chartRect.top - 10) + 'px';
38
+ });
39
+
40
+ bar.addEventListener('mouseleave', function() {
41
+ if (!isDragging) {
42
+ tooltip.style.display = 'none';
43
+ }
44
+ });
45
+
46
+ // Start selection on mousedown
47
+ bar.addEventListener('mousedown', function(e) {
48
+ e.preventDefault();
49
+ isDragging = true;
50
+ const index = parseInt(this.dataset.index);
51
+ selectionStart = index;
52
+ selectionEnd = index;
53
+ updateSelection();
54
+ });
55
+ });
56
+
57
+ // Handle dragging
58
+ document.addEventListener('mousemove', function(e) {
59
+ if (!isDragging) return;
60
+
61
+ const chartRect = barsContainer.getBoundingClientRect();
62
+ const x = e.clientX - chartRect.left;
63
+
64
+ // Find the bar at this x position
65
+ bars.forEach(bar => {
66
+ const rect = bar.getBoundingClientRect();
67
+ const barX = rect.left - chartRect.left;
68
+
69
+ if (x >= barX && x <= barX + rect.width) {
70
+ const index = parseInt(bar.dataset.index);
71
+ if (index !== selectionEnd) {
72
+ selectionEnd = index;
73
+ updateSelection();
74
+ }
75
+ }
76
+ });
77
+ });
78
+
79
+ // End selection on mouseup
80
+ document.addEventListener('mouseup', function() {
81
+ if (isDragging) {
82
+ isDragging = false;
83
+ applySelection();
84
+ }
85
+ });
86
+
87
+ function updateSelection() {
88
+ if (selectionStart === null || selectionEnd === null) {
89
+ selection.style.display = 'none';
90
+ return;
91
+ }
92
+
93
+ const start = Math.min(selectionStart, selectionEnd);
94
+ const end = Math.max(selectionStart, selectionEnd);
95
+
96
+ const startBar = bars[start];
97
+ const endBar = bars[end];
98
+
99
+ const startRect = startBar.getBoundingClientRect();
100
+ const endRect = endBar.getBoundingClientRect();
101
+ const chartRect = barsContainer.getBoundingClientRect();
102
+
103
+ const left = startRect.left - chartRect.left;
104
+ const width = (endRect.left + endRect.width) - startRect.left;
105
+
106
+ selection.style.display = 'block';
107
+ selection.style.left = left + 'px';
108
+ selection.style.width = width + 'px';
109
+
110
+ // Highlight selected bars
111
+ bars.forEach((bar, index) => {
112
+ if (index >= start && index <= end) {
113
+ bar.classList.add('selected');
114
+ } else {
115
+ bar.classList.remove('selected');
116
+ }
117
+ });
118
+ }
119
+
120
+ function applySelection() {
121
+ if (selectionStart === null || selectionEnd === null) return;
122
+
123
+ const start = Math.min(selectionStart, selectionEnd);
124
+ const end = Math.max(selectionStart, selectionEnd);
125
+
126
+ const startTime = bars[start].dataset.startTime;
127
+ const endTime = bars[end].dataset.endTime;
128
+
129
+ startTimeField.value = startTime;
130
+ endTimeField.value = endTime;
131
+
132
+ form.requestSubmit();
133
+ }
134
+
135
+ function formatTime(date) {
136
+ const hours = String(date.getHours()).padStart(2, '0');
137
+ const minutes = String(date.getMinutes()).padStart(2, '0');
138
+ return `${hours}:${minutes}`;
139
+ }
140
+
141
+ // Clear selection button
142
+ const clearButton = histogram.querySelector('[data-action*="clearSelection"]');
143
+ if (clearButton) {
144
+ clearButton.addEventListener('click', function() {
145
+ startTimeField.value = '';
146
+ endTimeField.value = '';
147
+ form.requestSubmit();
148
+ });
149
+ }
150
+ });
151
+ }
152
+
153
+ // Initialize on page load
154
+ if (document.readyState === 'loading') {
155
+ document.addEventListener('DOMContentLoaded', initializeTimelineHistograms);
156
+ } else {
157
+ initializeTimelineHistograms();
158
+ }
159
+
160
+ // Re-initialize on Turbo load (if using Turbo)
161
+ document.addEventListener('turbo:load', initializeTimelineHistograms);
162
+ })();