onlylogs 0.5.2 → 0.7.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 +4 -4
- data/app/assets/config/onlylogs_manifest.js +2 -0
- data/app/assets/javascripts/onlylogs/clusterize.js +342 -0
- data/app/assets/stylesheets/onlylogs/clusterize.css +35 -0
- data/app/channels/onlylogs/logs_channel.rb +110 -32
- data/app/controllers/onlylogs/logs_controller.rb +0 -7
- data/app/javascript/onlylogs/controllers/log_streamer_controller.js +431 -53
- data/app/models/onlylogs/grep.rb +31 -4
- data/app/views/onlylogs/logs/index.html.erb +1 -1
- data/app/views/onlylogs/shared/_log_container.html.erb +44 -21
- data/app/views/onlylogs/shared/_log_container_styles.html.erb +193 -13
- data/app/views/onlylogs/shared/_range_slider.html.erb +47 -0
- data/bin/super_grep +4 -1
- data/bin/super_ripgrep +10 -2
- data/lib/onlylogs/engine.rb +1 -1
- data/lib/onlylogs/http_device.rb +317 -0
- data/lib/onlylogs/http_logger.rb +21 -189
- data/lib/onlylogs/multi_device.rb +30 -0
- data/lib/onlylogs/socket_device.rb +60 -0
- data/lib/onlylogs/socket_logger.rb +17 -63
- data/lib/onlylogs/spool.rb +105 -0
- data/lib/onlylogs/version.rb +1 -1
- data/lib/onlylogs.rb +4 -0
- metadata +8 -1
|
@@ -4,15 +4,17 @@ import { createConsumer } from "@rails/actioncable";
|
|
|
4
4
|
export default class LogStreamerController extends Controller {
|
|
5
5
|
static values = {
|
|
6
6
|
filePath: { type: String },
|
|
7
|
-
cursorPosition: { type: Number, default: 0 },
|
|
8
7
|
autoScroll: { type: Boolean, default: true },
|
|
9
8
|
autoStart: { type: Boolean, default: true },
|
|
10
9
|
filter: { type: String, default: '' },
|
|
11
10
|
mode: { type: String, default: 'live' },
|
|
12
|
-
regexpMode: { type: Boolean, default: false }
|
|
11
|
+
regexpMode: { type: Boolean, default: false },
|
|
12
|
+
fileSize: { type: Number, default: 0 },
|
|
13
|
+
startPosition: { type: Number, default: 0 },
|
|
14
|
+
endPosition: { type: Number, default: 0 }
|
|
13
15
|
};
|
|
14
16
|
|
|
15
|
-
static targets = ["logLines", "filterInput", "results", "liveMode", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton", "autoscroll"];
|
|
17
|
+
static targets = ["logLines", "filterInput", "results", "liveMode", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton", "autoscroll", "rangeSliderContainer", "startSlider", "endSlider", "startOutput", "endOutput"];
|
|
16
18
|
|
|
17
19
|
connect() {
|
|
18
20
|
this.consumer = createConsumer();
|
|
@@ -21,6 +23,9 @@ export default class LogStreamerController extends Controller {
|
|
|
21
23
|
this.isRunning = false;
|
|
22
24
|
this.reconnectTimeout = null;
|
|
23
25
|
this.isSearchFinished = true;
|
|
26
|
+
this.lastRangeStep = null;
|
|
27
|
+
this.historyUpdateTimeout = null;
|
|
28
|
+
this.contextLineHighlighted = false;
|
|
24
29
|
|
|
25
30
|
// Initialize clusterize
|
|
26
31
|
this.clusterize = null;
|
|
@@ -28,8 +33,23 @@ export default class LogStreamerController extends Controller {
|
|
|
28
33
|
|
|
29
34
|
this.#updateWebsocketStatus('disconnected');
|
|
30
35
|
|
|
36
|
+
// Listen for range-slider updates
|
|
37
|
+
if (this.hasRangeSliderContainerTarget) {
|
|
38
|
+
this.rangeSliderContainerTarget.addEventListener('range:update', (e) => {
|
|
39
|
+
this.#handleRangeUpdate(e);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Restore state from URL params if present
|
|
44
|
+
this.#restoreStateFromUrl();
|
|
45
|
+
|
|
46
|
+
// Listen for browser back/forward button clicks
|
|
47
|
+
window.addEventListener('popstate', () => {
|
|
48
|
+
this.#restoreStateFromUrl();
|
|
49
|
+
this.reconnectWithNewMode();
|
|
50
|
+
});
|
|
51
|
+
|
|
31
52
|
this.start();
|
|
32
|
-
this.updateLiveModeState();
|
|
33
53
|
this.scroll();
|
|
34
54
|
}
|
|
35
55
|
|
|
@@ -100,8 +120,7 @@ export default class LogStreamerController extends Controller {
|
|
|
100
120
|
|
|
101
121
|
if (this.isLiveMode()) {
|
|
102
122
|
this.liveModeTarget.checked = false;
|
|
103
|
-
this.modeValue = '
|
|
104
|
-
this.updateLiveModeState();
|
|
123
|
+
this.modeValue = 'static';
|
|
105
124
|
this.stop();
|
|
106
125
|
}
|
|
107
126
|
}
|
|
@@ -116,36 +135,44 @@ export default class LogStreamerController extends Controller {
|
|
|
116
135
|
}
|
|
117
136
|
|
|
118
137
|
toggleLiveMode() {
|
|
119
|
-
// this condition looks revered, but the value here has been changed already. so the live mode has been enabled.
|
|
120
138
|
if (this.isLiveMode()) {
|
|
139
|
+
// User checked - enable live mode, keep filter and start fresh tail
|
|
140
|
+
this.#clearHighlighting();
|
|
121
141
|
this.modeValue = 'live';
|
|
122
|
-
this.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
142
|
+
this.clear();
|
|
143
|
+
this.#setRange(0, this.fileSizeValue);
|
|
144
|
+
this.#updateUrlParam('start_position', null);
|
|
145
|
+
this.#updateUrlParam('end_position', null);
|
|
146
|
+
this.#updateUrlParam('byte_offset', null);
|
|
147
|
+
this.#updateUrlParam('mode', null);
|
|
148
|
+
this.reconnectWithNewMode();
|
|
126
149
|
} else {
|
|
127
|
-
//
|
|
128
|
-
this.
|
|
150
|
+
// User unchecked - disable live mode
|
|
151
|
+
this.modeValue = 'static';
|
|
152
|
+
this.#updateUrlParam('mode', 'static');
|
|
153
|
+
|
|
154
|
+
const hasFilter = this.filterInputTarget.value && this.filterInputTarget.value.trim() !== '';
|
|
155
|
+
if (hasFilter) {
|
|
156
|
+
// Search with the current filter in static mode
|
|
157
|
+
this.reconnectWithNewMode();
|
|
158
|
+
} else {
|
|
159
|
+
// No filter, just stop
|
|
160
|
+
this.stop();
|
|
161
|
+
}
|
|
129
162
|
}
|
|
130
163
|
}
|
|
131
164
|
|
|
132
165
|
applyFilter() {
|
|
133
166
|
const filterValue = this.filterInputTarget.value;
|
|
134
167
|
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
this.modeValue = 'search';
|
|
139
|
-
} else {
|
|
140
|
-
// If no filter, enable live mode
|
|
141
|
-
this.liveModeTarget.checked = true;
|
|
142
|
-
this.modeValue = 'live';
|
|
143
|
-
}
|
|
168
|
+
// Clear byte_offset and highlighting when applying a new filter
|
|
169
|
+
this.#clearHighlighting();
|
|
170
|
+
this.#updateUrlParam('byte_offset', null);
|
|
144
171
|
|
|
145
172
|
// Update visual state
|
|
146
|
-
this.updateLiveModeState();
|
|
147
173
|
this.updateStopButtonVisibility();
|
|
148
174
|
this.#updateUrlParam('filter', filterValue || null);
|
|
175
|
+
this.#updateUrlParam('mode', this.modeValue === 'live' ? null : 'static');
|
|
149
176
|
|
|
150
177
|
// Use the global debounced reconnection (300ms delay)
|
|
151
178
|
this.reconnectWithNewMode();
|
|
@@ -169,7 +196,13 @@ export default class LogStreamerController extends Controller {
|
|
|
169
196
|
|
|
170
197
|
// Debounce reconnection to avoid multiple rapid reconnections
|
|
171
198
|
this.reconnectTimeout = setTimeout(() => {
|
|
172
|
-
|
|
199
|
+
// Soft disconnect: unsubscribe without sending stop_watcher message.
|
|
200
|
+
// In live mode, we don't want "Search stopped" on every filter change.
|
|
201
|
+
if (this.subscription) {
|
|
202
|
+
this.subscription.unsubscribe();
|
|
203
|
+
this.subscription = null;
|
|
204
|
+
}
|
|
205
|
+
this.isRunning = false;
|
|
173
206
|
this.clear();
|
|
174
207
|
this.#reinitializeClusterize();
|
|
175
208
|
this.start();
|
|
@@ -178,19 +211,30 @@ export default class LogStreamerController extends Controller {
|
|
|
178
211
|
}
|
|
179
212
|
|
|
180
213
|
clearFilter() {
|
|
181
|
-
// Clear
|
|
214
|
+
// Clear filter and explore window to go back to pure live mode
|
|
182
215
|
this.filterInputTarget.value = '';
|
|
216
|
+
this.modeValue = 'live';
|
|
217
|
+
this.startPositionValue = 0;
|
|
218
|
+
this.endPositionValue = this.fileSizeValue;
|
|
219
|
+
|
|
220
|
+
// Clear highlighting
|
|
221
|
+
this.#clearHighlighting();
|
|
183
222
|
|
|
184
|
-
// Re-enable live mode
|
|
223
|
+
// Re-enable live mode checkbox
|
|
185
224
|
this.liveModeTarget.checked = true;
|
|
186
|
-
|
|
225
|
+
|
|
226
|
+
// Reset range to default for live mode
|
|
227
|
+
this.#setRange(0, this.fileSizeValue);
|
|
187
228
|
|
|
188
229
|
// Update visual state
|
|
189
|
-
this.updateLiveModeState();
|
|
190
230
|
this.updateStopButtonVisibility();
|
|
191
231
|
|
|
192
232
|
// Update URL with cleared filter
|
|
193
|
-
this.#updateUrlParam('filter');
|
|
233
|
+
this.#updateUrlParam('filter', null);
|
|
234
|
+
this.#updateUrlParam('byte_offset', null);
|
|
235
|
+
this.#updateUrlParam('mode', null);
|
|
236
|
+
this.#updateUrlParam('start_position', null);
|
|
237
|
+
this.#updateUrlParam('end_position', null);
|
|
194
238
|
|
|
195
239
|
// Reconnect with cleared filter and live mode
|
|
196
240
|
this.reconnectWithNewMode();
|
|
@@ -201,29 +245,222 @@ export default class LogStreamerController extends Controller {
|
|
|
201
245
|
this.subscription.perform('stop_watcher');
|
|
202
246
|
}
|
|
203
247
|
|
|
248
|
+
|
|
249
|
+
handleExpandClick(e) {
|
|
250
|
+
const btn = e.target.closest('.onlylogs-expand-btn');
|
|
251
|
+
if (!btn) return;
|
|
252
|
+
|
|
253
|
+
const byteOffset = parseInt(btn.getAttribute('data-byte-offset'));
|
|
254
|
+
if (!byteOffset) return;
|
|
255
|
+
|
|
256
|
+
const contextBytes = 10000;
|
|
257
|
+
const start = Math.max(0, byteOffset - contextBytes);
|
|
258
|
+
const end = Math.min(this.fileSizeValue, byteOffset + contextBytes);
|
|
259
|
+
|
|
260
|
+
// Clear filter and switch to static mode
|
|
261
|
+
this.filterInputTarget.value = '';
|
|
262
|
+
this.modeValue = 'static';
|
|
263
|
+
this.liveModeTarget.checked = false;
|
|
264
|
+
|
|
265
|
+
// Update URL with byte_offset and range
|
|
266
|
+
this.#updateUrlParam('byte_offset', byteOffset);
|
|
267
|
+
this.#updateUrlParam('filter', null);
|
|
268
|
+
this.#updateUrlParam('start_position', start);
|
|
269
|
+
this.#updateUrlParam('end_position', end);
|
|
270
|
+
|
|
271
|
+
this.contextLineHighlighted = false;
|
|
272
|
+
this.#setRange(start, end);
|
|
273
|
+
this.reconnectWithNewMode();
|
|
274
|
+
}
|
|
275
|
+
|
|
204
276
|
clearLogs() {
|
|
205
277
|
this.clear();
|
|
206
278
|
this.#hideMessage();
|
|
207
279
|
}
|
|
208
280
|
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
281
|
+
#highlightContextLine() {
|
|
282
|
+
const target = Number(new URLSearchParams(window.location.search).get('byte_offset'));
|
|
283
|
+
if (Number.isNaN(target)) return;
|
|
212
284
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
this
|
|
285
|
+
this.#applyContextLineHighlight(target);
|
|
286
|
+
|
|
287
|
+
const closestPre = this.#closestPreByByteOffset(target);
|
|
288
|
+
|
|
289
|
+
if (closestPre) {
|
|
290
|
+
this.#scrollVerticallyToCenter(closestPre);
|
|
219
291
|
}
|
|
220
292
|
}
|
|
221
293
|
|
|
294
|
+
#scrollVerticallyToCenter(element) {
|
|
295
|
+
// Find the row wrapper that's a direct child of clusterize-content
|
|
296
|
+
let row = element;
|
|
297
|
+
while (row.parentElement && !row.parentElement.classList.contains('clusterize-content')) {
|
|
298
|
+
row = row.parentElement;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!row) return;
|
|
302
|
+
|
|
303
|
+
// Scroll into view first to ensure element is rendered
|
|
304
|
+
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#clearHighlighting() {
|
|
308
|
+
this.contextLineHighlighted = false;
|
|
309
|
+
this.logLinesTarget.querySelectorAll('.highlighted-context-line').forEach(el => {
|
|
310
|
+
el.classList.remove('highlighted-context-line');
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#closestPreByByteOffset(target) {
|
|
315
|
+
return [...this.logLinesTarget.querySelectorAll('pre[data-byte-offset]')]
|
|
316
|
+
.reduce((closest, pre) => {
|
|
317
|
+
const byteOffset = Number(pre.dataset.byteOffset);
|
|
318
|
+
if (Number.isNaN(byteOffset)) return closest;
|
|
319
|
+
|
|
320
|
+
const distance = Math.abs(byteOffset - target);
|
|
321
|
+
return !closest || distance < closest.distance ? { pre, distance } : closest;
|
|
322
|
+
}, null)?.pre;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
#applyContextLineHighlight(target) {
|
|
326
|
+
const closestPre = this.#closestPreByByteOffset(target);
|
|
327
|
+
|
|
328
|
+
if (!closestPre) return;
|
|
329
|
+
|
|
330
|
+
const row = this.#rowElement(closestPre);
|
|
331
|
+
|
|
332
|
+
// Highlight target line ± 3 lines (7 lines total)
|
|
333
|
+
const linesToHighlight = [];
|
|
334
|
+
let current = row;
|
|
335
|
+
|
|
336
|
+
// Add 3 previous lines
|
|
337
|
+
for (let i = 0; i < 3; i++) {
|
|
338
|
+
if (current.previousElementSibling) {
|
|
339
|
+
current = current.previousElementSibling;
|
|
340
|
+
linesToHighlight.unshift(current);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Add target line
|
|
345
|
+
linesToHighlight.push(row);
|
|
346
|
+
|
|
347
|
+
// Add 3 next lines
|
|
348
|
+
current = row;
|
|
349
|
+
for (let i = 0; i < 3; i++) {
|
|
350
|
+
if (current.nextElementSibling) {
|
|
351
|
+
current = current.nextElementSibling;
|
|
352
|
+
linesToHighlight.push(current);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
linesToHighlight.forEach(line => line.classList.add('highlighted-context-line'));
|
|
357
|
+
this.contextLineHighlighted = true;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// A row is either a bare <pre> or an expand-button wrapper <div> directly
|
|
361
|
+
// under the clusterize content area. Walk up to that top-level element so the
|
|
362
|
+
// highlight covers the whole line, including the "+" toggle.
|
|
363
|
+
#rowElement(element) {
|
|
364
|
+
let node = element;
|
|
365
|
+
while (node.parentElement && !node.parentElement.classList.contains('clusterize-content')) {
|
|
366
|
+
node = node.parentElement;
|
|
367
|
+
}
|
|
368
|
+
return node;
|
|
369
|
+
}
|
|
370
|
+
|
|
222
371
|
updateStopButtonVisibility() {
|
|
223
|
-
const shouldShow =
|
|
372
|
+
const shouldShow = this.modeValue === 'static' && this.subscription && this.isRunning && !this.isSearchFinished;
|
|
224
373
|
this.stopButtonTarget.style.display = shouldShow ? 'inline-block' : 'none';
|
|
225
374
|
}
|
|
226
375
|
|
|
376
|
+
#setRange(start, end) {
|
|
377
|
+
this.startSliderTarget.value = start;
|
|
378
|
+
this.endSliderTarget.value = end;
|
|
379
|
+
|
|
380
|
+
// Trigger range-slider controller to update visuals
|
|
381
|
+
if (this.hasRangeSliderContainerTarget) {
|
|
382
|
+
this.rangeSliderContainerTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Also update visuals for log-streamer
|
|
386
|
+
this.updateRangeVisuals();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#handleRangeUpdate() {
|
|
390
|
+
const start = parseInt(this.startSliderTarget.value);
|
|
391
|
+
const end = parseInt(this.endSliderTarget.value);
|
|
392
|
+
const isDefaultRange = start === 0 && end === this.fileSizeValue;
|
|
393
|
+
|
|
394
|
+
this.liveModeTarget.checked = isDefaultRange;
|
|
395
|
+
this.modeValue = isDefaultRange ? 'live' : 'static';
|
|
396
|
+
this.#updateUrlParams({
|
|
397
|
+
start_position: isDefaultRange ? null : start,
|
|
398
|
+
end_position: isDefaultRange ? null : end,
|
|
399
|
+
mode: isDefaultRange ? null : 'static'
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Clear byte_offset and highlighting if it falls outside the new range
|
|
403
|
+
const params = new URLSearchParams(window.location.search);
|
|
404
|
+
const byteOffset = parseInt(params.get('byte_offset'));
|
|
405
|
+
if (!Number.isNaN(byteOffset) && (byteOffset < start || byteOffset > end)) {
|
|
406
|
+
this.#updateUrlParam('byte_offset', null);
|
|
407
|
+
this.#clearHighlighting();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
this.reconnectWithNewMode();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
resetRange() {
|
|
414
|
+
this.#setRange(0, this.fileSizeValue);
|
|
415
|
+
this.#handleRangeUpdate();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
#restoreStateFromUrl() {
|
|
419
|
+
const params = new URLSearchParams(window.location.search);
|
|
420
|
+
|
|
421
|
+
// Restore filter (clear if not present in URL)
|
|
422
|
+
const filter = params.get('filter') || '';
|
|
423
|
+
this.filterInputTarget.value = filter;
|
|
424
|
+
|
|
425
|
+
// Restore autoscroll
|
|
426
|
+
const autoscroll = params.get('autoscroll');
|
|
427
|
+
if (autoscroll === 'false') {
|
|
428
|
+
this.autoScrollValue = false;
|
|
429
|
+
this.autoscrollTarget.checked = false;
|
|
430
|
+
} else {
|
|
431
|
+
this.autoScrollValue = true;
|
|
432
|
+
this.autoscrollTarget.checked = true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Restore regexp mode
|
|
436
|
+
const regexpMode = params.get('regexp_mode');
|
|
437
|
+
if (regexpMode === 'true') {
|
|
438
|
+
this.regexpModeValue = true;
|
|
439
|
+
this.regexpModeTarget.checked = true;
|
|
440
|
+
} else {
|
|
441
|
+
this.regexpModeValue = false;
|
|
442
|
+
this.regexpModeTarget.checked = false;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Restore range
|
|
446
|
+
const startParam = params.get('start_position');
|
|
447
|
+
const endParam = params.get('end_position');
|
|
448
|
+
|
|
449
|
+
const start = startParam ? parseInt(startParam) : 0;
|
|
450
|
+
const end = endParam ? parseInt(endParam) : this.fileSizeValue;
|
|
451
|
+
this.#setRange(start, end);
|
|
452
|
+
|
|
453
|
+
// Calculate mode: check mode param, default to live
|
|
454
|
+
const modeParam = params.get('mode');
|
|
455
|
+
if (modeParam === 'static') {
|
|
456
|
+
this.modeValue = 'static';
|
|
457
|
+
this.liveModeTarget.checked = false;
|
|
458
|
+
} else {
|
|
459
|
+
// Default to live mode
|
|
460
|
+
this.modeValue = 'live';
|
|
461
|
+
this.liveModeTarget.checked = true;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
227
464
|
|
|
228
465
|
/**
|
|
229
466
|
* Create ActionCable subscription
|
|
@@ -261,13 +498,27 @@ export default class LogStreamerController extends Controller {
|
|
|
261
498
|
* Handle successful connection
|
|
262
499
|
*/
|
|
263
500
|
#handleConnected() {
|
|
264
|
-
|
|
265
|
-
cursor_position: this.cursorPositionValue,
|
|
501
|
+
const data = {
|
|
266
502
|
file_path: this.filePathValue,
|
|
267
503
|
filter: this.filterInputTarget.value,
|
|
268
504
|
mode: this.modeValue,
|
|
269
505
|
regexp_mode: this.regexpModeValue
|
|
270
|
-
}
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
// Use range slider values if available and not at defaults
|
|
509
|
+
const startSliderValue = parseInt(this.startSliderTarget.value);
|
|
510
|
+
const endSliderValue = parseInt(this.endSliderTarget.value);
|
|
511
|
+
|
|
512
|
+
if (startSliderValue > 0 || endSliderValue < this.fileSizeValue) {
|
|
513
|
+
data.start_position = startSliderValue;
|
|
514
|
+
data.end_position = endSliderValue;
|
|
515
|
+
} else if (this.modeValue === 'static' && this.endPositionValue > 0) {
|
|
516
|
+
// Byte-offset explore window - reads a bounded range
|
|
517
|
+
data.start_position = this.startPositionValue;
|
|
518
|
+
data.end_position = this.endPositionValue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
this.subscription.perform('initialize_watcher', data);
|
|
271
522
|
|
|
272
523
|
this.element.classList.add("log-streamer--connected");
|
|
273
524
|
this.element.classList.remove("log-streamer--disconnected", "log-streamer--rejected");
|
|
@@ -290,14 +541,34 @@ export default class LogStreamerController extends Controller {
|
|
|
290
541
|
}
|
|
291
542
|
|
|
292
543
|
#handleLogLines(lines) {
|
|
544
|
+
const MAX_ROWS_LIVE_MODE = 150_000;
|
|
545
|
+
const BATCH_REMOVE_SIZE = 50_000;
|
|
546
|
+
|
|
293
547
|
try {
|
|
294
548
|
// Append new lines to clusterize
|
|
295
|
-
if (lines.length
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
549
|
+
if (!lines || lines.length === 0) return;
|
|
550
|
+
|
|
551
|
+
// Render JSON log lines into HTML strings
|
|
552
|
+
const renderedLines = lines.map(line => this.#renderLogLineHtml(line));
|
|
553
|
+
this.clusterize.append(renderedLines);
|
|
554
|
+
|
|
555
|
+
// In live mode, prune old rows if we exceed the maximum
|
|
556
|
+
if (this.isLiveMode() && this.clusterize.getRowsAmount() > MAX_ROWS_LIVE_MODE) {
|
|
557
|
+
this.clusterize.prune(BATCH_REMOVE_SIZE);
|
|
299
558
|
}
|
|
300
559
|
|
|
560
|
+
// Highlight context line around byte offset if present
|
|
561
|
+
const params = new URLSearchParams(window.location.search);
|
|
562
|
+
if (params.has('byte_offset') && !this.contextLineHighlighted) {
|
|
563
|
+
setTimeout(() => {
|
|
564
|
+
this.#highlightContextLine();
|
|
565
|
+
this.contextLineHighlighted = true;
|
|
566
|
+
}, 100);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
this.#updateResultsDisplay();
|
|
570
|
+
this.scroll();
|
|
571
|
+
|
|
301
572
|
// Update stop button visibility after processing lines
|
|
302
573
|
this.updateStopButtonVisibility();
|
|
303
574
|
|
|
@@ -306,6 +577,19 @@ export default class LogStreamerController extends Controller {
|
|
|
306
577
|
}
|
|
307
578
|
}
|
|
308
579
|
|
|
580
|
+
#renderLogLineHtml(logLine) {
|
|
581
|
+
// logLine is a JSON object: {content, byte_offset, show_expand_button}
|
|
582
|
+
const { content, byte_offset, show_expand_button } = logLine;
|
|
583
|
+
|
|
584
|
+
if (byte_offset && show_expand_button) {
|
|
585
|
+
return `<div style="display: flex; align-items: center;"><button class="onlylogs-expand-btn" data-byte-offset="${byte_offset}" data-action="click->log-streamer#handleExpandClick">+</button><pre data-byte-offset="${byte_offset}">${content}</pre></div>`;
|
|
586
|
+
} else if (byte_offset) {
|
|
587
|
+
return `<pre data-byte-offset="${byte_offset}">${content}</pre>`;
|
|
588
|
+
} else {
|
|
589
|
+
return `<pre>${content}</pre>`;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
309
593
|
#handleMessage(message) {
|
|
310
594
|
this.#hideMessage();
|
|
311
595
|
if (message === '') {
|
|
@@ -380,7 +664,6 @@ export default class LogStreamerController extends Controller {
|
|
|
380
664
|
return {
|
|
381
665
|
isRunning: this.isRunning,
|
|
382
666
|
filePath: this.filePathValue,
|
|
383
|
-
cursorPosition: this.cursorPositionValue,
|
|
384
667
|
lineCount: this.clusterize.getRowsAmount(),
|
|
385
668
|
connected: this.subscription && this.subscription.identifier
|
|
386
669
|
};
|
|
@@ -403,7 +686,18 @@ export default class LogStreamerController extends Controller {
|
|
|
403
686
|
// Optional: handle cluster change
|
|
404
687
|
},
|
|
405
688
|
clusterChanged: () => {
|
|
406
|
-
//
|
|
689
|
+
// Re-apply highlighting when cluster changes (for virtual scrolling).
|
|
690
|
+
// The byte_offset URL param is the highlight anchor for an explore window.
|
|
691
|
+
// Only re-highlight if we've already done initial highlight.
|
|
692
|
+
if (this.contextLineHighlighted) {
|
|
693
|
+
const params = new URLSearchParams(window.location.search);
|
|
694
|
+
if (params.has('byte_offset')) {
|
|
695
|
+
const target = Number(params.get('byte_offset'));
|
|
696
|
+
if (!Number.isNaN(target)) {
|
|
697
|
+
this.#applyContextLineHighlight(target);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
407
701
|
},
|
|
408
702
|
scrollingProgress: (progress) => {
|
|
409
703
|
// Optional: handle scrolling progress
|
|
@@ -419,15 +713,99 @@ export default class LogStreamerController extends Controller {
|
|
|
419
713
|
}
|
|
420
714
|
|
|
421
715
|
#updateUrlParam(param, value = null) {
|
|
716
|
+
this.#updateUrlParams({ [param]: value });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
#updateUrlParams(updates = {}) {
|
|
422
720
|
const params = new URLSearchParams(window.location.search);
|
|
423
721
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
722
|
+
// Update all params in one go
|
|
723
|
+
Object.entries(updates).forEach(([param, value]) => {
|
|
724
|
+
if (value != null) {
|
|
725
|
+
params.set(param, value);
|
|
726
|
+
} else {
|
|
727
|
+
params.delete(param);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
429
730
|
|
|
430
731
|
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
|
732
|
+
|
|
733
|
+
// Update URL immediately so subsequent #updateUrlParams calls read the latest state.
|
|
734
|
+
// Without this, each call reads the same stale window.location.search and params don't accumulate.
|
|
431
735
|
window.history.replaceState(null, '', newUrl);
|
|
736
|
+
|
|
737
|
+
// Debounce pushState to create a history entry (enables back button) without
|
|
738
|
+
// spamming history on rapid updates like slider drag.
|
|
739
|
+
if (this.historyUpdateTimeout) {
|
|
740
|
+
clearTimeout(this.historyUpdateTimeout);
|
|
741
|
+
}
|
|
742
|
+
this.historyUpdateTimeout = setTimeout(() => {
|
|
743
|
+
window.history.pushState(null, '', newUrl);
|
|
744
|
+
this.historyUpdateTimeout = null;
|
|
745
|
+
}, 1000);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Range slider methods
|
|
749
|
+
updateRangeVisuals(event) {
|
|
750
|
+
let start = Number(this.startSliderTarget.value);
|
|
751
|
+
let end = Number(this.endSliderTarget.value);
|
|
752
|
+
const sliderMax = Number(this.startSliderTarget.max);
|
|
753
|
+
const step = Number(this.startSliderTarget.step);
|
|
754
|
+
|
|
755
|
+
// Snap to 100% if close to max (within 2% or one step)
|
|
756
|
+
const threshold = Math.max(sliderMax * 0.02, step);
|
|
757
|
+
if (end > sliderMax - threshold) {
|
|
758
|
+
end = sliderMax;
|
|
759
|
+
this.endSliderTarget.value = end;
|
|
760
|
+
}
|
|
761
|
+
if (start > sliderMax - threshold) {
|
|
762
|
+
start = sliderMax;
|
|
763
|
+
this.startSliderTarget.value = start;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Enforce start <= end
|
|
767
|
+
if (start > end) {
|
|
768
|
+
[start, end] = [end, start];
|
|
769
|
+
this.startSliderTarget.value = start;
|
|
770
|
+
this.endSliderTarget.value = end;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
this.#updateRangeDisplay(start, end);
|
|
774
|
+
|
|
775
|
+
if (event?.type === 'change') {
|
|
776
|
+
const step = this.#stepForRange(start, end);
|
|
777
|
+
if (step !== this.lastRangeStep) {
|
|
778
|
+
this.lastRangeStep = step;
|
|
779
|
+
this.startSliderTarget.step = step;
|
|
780
|
+
this.endSliderTarget.step = step;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
this.rangeSliderContainerTarget.dispatchEvent(new CustomEvent("range:update", { detail: { start, end } }));
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
#stepForRange(start, end) {
|
|
788
|
+
const selectedBytes = Math.max(end - start, 1);
|
|
789
|
+
const step = 10 ** Math.ceil(Math.log10(selectedBytes / 200));
|
|
790
|
+
|
|
791
|
+
return Math.max(1, step);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
#updateRangeDisplay(start, end) {
|
|
795
|
+
const sliderMax = Number(this.startSliderTarget.max);
|
|
796
|
+
|
|
797
|
+
this.rangeSliderContainerTarget.style.setProperty("--range-start-percent", `${(start / sliderMax) * 100}%`);
|
|
798
|
+
this.rangeSliderContainerTarget.style.setProperty("--range-end-percent", `${(end / sliderMax) * 100}%`);
|
|
799
|
+
|
|
800
|
+
this.startOutputTarget.textContent = `${this.#formatPercent(start)}%`;
|
|
801
|
+
this.endOutputTarget.textContent = `${this.#formatPercent(end)}%`;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
#formatPercent(value) {
|
|
805
|
+
// Show 100% if within 1% of file size
|
|
806
|
+
if (value >= this.fileSizeValue * 0.99) return '100.0';
|
|
807
|
+
if (value <= 0) return '0.0';
|
|
808
|
+
const percent = (value / this.fileSizeValue) * 100;
|
|
809
|
+
return percent.toFixed(1);
|
|
432
810
|
}
|
|
433
811
|
}
|
data/app/models/onlylogs/grep.rb
CHANGED
|
@@ -19,23 +19,50 @@ module Onlylogs
|
|
|
19
19
|
|
|
20
20
|
results = []
|
|
21
21
|
|
|
22
|
+
# Set up parsing logic based on whether ripgrep includes byte offsets
|
|
23
|
+
parse_line = if Onlylogs.ripgrep_enabled?
|
|
24
|
+
->(line) {
|
|
25
|
+
parts = line.split(":", 2)
|
|
26
|
+
[parts[0].to_i, parts[1] || ""]
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
->(line) { [nil, line] }
|
|
30
|
+
end
|
|
31
|
+
|
|
22
32
|
IO.popen(command_args, err: "/dev/null") do |io|
|
|
23
33
|
io.each_line do |line|
|
|
24
|
-
|
|
34
|
+
byte_offset, content = parse_line.call(line.chomp)
|
|
35
|
+
|
|
25
36
|
# Use String.new to create a copy and prevent memory retention from IO buffers
|
|
26
|
-
content = String.new(
|
|
37
|
+
content = String.new(content, encoding: Encoding::UTF_8).scrub
|
|
38
|
+
|
|
39
|
+
result = {byte_offset: byte_offset, content: content}
|
|
27
40
|
|
|
28
41
|
if block_given?
|
|
29
|
-
yield
|
|
42
|
+
yield result
|
|
30
43
|
else
|
|
31
|
-
results <<
|
|
44
|
+
results << result
|
|
32
45
|
end
|
|
33
46
|
end
|
|
47
|
+
ensure
|
|
48
|
+
drop_page_cache(file_path)
|
|
34
49
|
end
|
|
35
50
|
|
|
36
51
|
block_given? ? nil : results
|
|
37
52
|
end
|
|
38
53
|
|
|
54
|
+
# Searching a large log file pulls the whole file into the OS page cache.
|
|
55
|
+
# In a container the kernel charges that cache to the cgroup, so a few
|
|
56
|
+
# searches over multi-GB logs can exhaust the memory limit and trigger an
|
|
57
|
+
# OOM kill even though no Ruby memory leaked. Hint the kernel to drop the
|
|
58
|
+
# pages we just read. Best-effort: advise is only a hint and is unsupported
|
|
59
|
+
# on some platforms, so never let it break a search.
|
|
60
|
+
def self.drop_page_cache(file_path)
|
|
61
|
+
::File.open(file_path) { |file| file.advise(:dontneed) }
|
|
62
|
+
rescue
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
39
66
|
def self.match_line?(line, string, regexp_mode: false)
|
|
40
67
|
# Strip ANSI color codes from the line before matching
|
|
41
68
|
stripped_line = line.gsub(/\e\[[0-9;]*m/, "")
|
|
@@ -36,5 +36,5 @@
|
|
|
36
36
|
<% end %>
|
|
37
37
|
<% end %>
|
|
38
38
|
</div>
|
|
39
|
-
<%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path
|
|
39
|
+
<%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path } %>
|
|
40
40
|
</div>
|