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.
@@ -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 = 'search';
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.updateLiveModeState();
123
- if (!this.isRunning) {
124
- this.start();
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
- // Prevent unchecking - live mode can only be disabled by applying a filter
128
- this.liveModeTarget.checked = true;
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
- // If filter is applied, disable live mode
136
- if (filterValue && filterValue.trim() !== '') {
137
- this.liveModeTarget.checked = false;
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
- this.stop();
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 the filter input
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
- this.modeValue = 'live';
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
- updateLiveModeState() {
210
- const liveModeLabel = this.liveModeTarget.closest('label');
211
- const hasFilter = this.filterInputTarget.value && this.filterInputTarget.value.trim() !== '';
281
+ #highlightContextLine() {
282
+ const target = Number(new URLSearchParams(window.location.search).get('byte_offset'));
283
+ if (Number.isNaN(target)) return;
212
284
 
213
- if (hasFilter) {
214
- liveModeLabel.classList.add('live-mode-sticky');
215
- this.liveModeTarget.disabled = true;
216
- } else {
217
- liveModeLabel.classList.remove('live-mode-sticky');
218
- this.liveModeTarget.disabled = false;
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 = !this.isLiveMode() && this.subscription && this.isRunning && !this.isSearchFinished;
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
- this.subscription.perform('initialize_watcher', {
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 > 0) {
296
- this.clusterize.append(lines);
297
- this.#updateResultsDisplay();
298
- this.scroll();
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
- // Optional: handle after cluster change
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
- if (value != null) {
425
- params.set(param, value);
426
- } else {
427
- params.delete(param);
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
  }
@@ -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
- # Line numbers are no longer outputted by super_grep/super_ripgrep
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(line.chomp, encoding: Encoding::UTF_8).scrub
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 content
42
+ yield result
30
43
  else
31
- results << content
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, tail: @max_lines, filter: @filter, autoscroll: @autoscroll, regexp_mode: @regexp_mode } %>
39
+ <%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path } %>
40
40
  </div>