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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +295 -0
- data/Rakefile +12 -0
- data/app/assets/javascripts/application.js +6 -0
- data/app/assets/javascripts/solid_log/checkbox_dropdown.js +171 -0
- data/app/assets/javascripts/solid_log/filter_state.js +138 -0
- data/app/assets/javascripts/solid_log/jump_to_live.js +119 -0
- data/app/assets/javascripts/solid_log/live_tail.js +476 -0
- data/app/assets/javascripts/solid_log/live_tail.js.bak +270 -0
- data/app/assets/javascripts/solid_log/log_filters.js +37 -0
- data/app/assets/javascripts/solid_log/stream_scroll.js +195 -0
- data/app/assets/javascripts/solid_log/timeline_histogram.js +162 -0
- data/app/assets/javascripts/solid_log/toast.js +50 -0
- data/app/assets/stylesheets/solid_log/application.css +1329 -0
- data/app/assets/stylesheets/solid_log/components.css +1506 -0
- data/app/assets/stylesheets/solid_log/mission_control.css +398 -0
- data/app/channels/solid_log/ui/application_cable/channel.rb +8 -0
- data/app/channels/solid_log/ui/application_cable/connection.rb +10 -0
- data/app/channels/solid_log/ui/log_stream_channel.rb +132 -0
- data/app/controllers/solid_log/ui/base_controller.rb +122 -0
- data/app/controllers/solid_log/ui/dashboard_controller.rb +32 -0
- data/app/controllers/solid_log/ui/entries_controller.rb +34 -0
- data/app/controllers/solid_log/ui/fields_controller.rb +57 -0
- data/app/controllers/solid_log/ui/streams_controller.rb +204 -0
- data/app/controllers/solid_log/ui/timelines_controller.rb +29 -0
- data/app/controllers/solid_log/ui/tokens_controller.rb +46 -0
- data/app/helpers/solid_log/ui/application_helper.rb +99 -0
- data/app/helpers/solid_log/ui/dashboard_helper.rb +46 -0
- data/app/helpers/solid_log/ui/entries_helper.rb +16 -0
- data/app/helpers/solid_log/ui/timeline_helper.rb +39 -0
- data/app/services/solid_log/ui/live_tail_broadcaster.rb +81 -0
- data/app/views/layouts/solid_log/ui/application.html.erb +53 -0
- data/app/views/solid_log/ui/dashboard/index.html.erb +178 -0
- data/app/views/solid_log/ui/entries/show.html.erb +132 -0
- data/app/views/solid_log/ui/fields/index.html.erb +133 -0
- data/app/views/solid_log/ui/shared/_checkbox_dropdown.html.erb +64 -0
- data/app/views/solid_log/ui/shared/_multiselect_filter.html.erb +37 -0
- data/app/views/solid_log/ui/shared/_toast.html.erb +7 -0
- data/app/views/solid_log/ui/shared/_toast_message.html.erb +30 -0
- data/app/views/solid_log/ui/streams/_filter_form.html.erb +207 -0
- data/app/views/solid_log/ui/streams/_footer.html.erb +37 -0
- data/app/views/solid_log/ui/streams/_log_entries.html.erb +5 -0
- data/app/views/solid_log/ui/streams/_log_row.html.erb +5 -0
- data/app/views/solid_log/ui/streams/_log_row_compact.html.erb +37 -0
- data/app/views/solid_log/ui/streams/_log_row_expanded.html.erb +67 -0
- data/app/views/solid_log/ui/streams/_log_stream_content.html.erb +8 -0
- data/app/views/solid_log/ui/streams/_timeline.html.erb +68 -0
- data/app/views/solid_log/ui/streams/index.html.erb +22 -0
- data/app/views/solid_log/ui/timelines/show_job.html.erb +78 -0
- data/app/views/solid_log/ui/timelines/show_request.html.erb +88 -0
- data/app/views/solid_log/ui/tokens/index.html.erb +95 -0
- data/app/views/solid_log/ui/tokens/new.html.erb +47 -0
- data/config/importmap.rb +15 -0
- data/config/routes.rb +27 -0
- data/lib/solid_log/ui/api_client.rb +117 -0
- data/lib/solid_log/ui/configuration.rb +99 -0
- data/lib/solid_log/ui/data_source.rb +146 -0
- data/lib/solid_log/ui/engine.rb +76 -0
- data/lib/solid_log/ui/version.rb +5 -0
- data/lib/solid_log/ui.rb +27 -0
- data/lib/solid_log-ui.rb +2 -0
- 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
|
+
})();
|