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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43e963f2acccbac4061747abd2606eb298b7ea138404f6e726e03c7a5ffacb4e
4
- data.tar.gz: c9ee6fb1e854dffddb634be9153d285c8db88836b8a2f7046b63f0a8f0734cfa
3
+ metadata.gz: 19813b5a5884acae634a3516e4cd23dc67fb5cb359062491a15d331f35de2448
4
+ data.tar.gz: 158e9b56b481744b67a6d92b0835f1443f769b0f264149fb71fe1b69fc86e250
5
5
  SHA512:
6
- metadata.gz: 0cf479d9e32a82adb41090a4e4525c4f237e75f7ab52485ac29ce8db5cfaf77fada2434dc675078afabf660ee4d3bf1470f916c13976a3cc11ca5823eb041bbe
7
- data.tar.gz: 955aa49574feaa833e135a853145858961514ebe4c4a093a3758410b1ad91c5aaa6218f09c4944b29360c0419f9bddd738d4c25b48e99e70b882e95b56b2be60
6
+ metadata.gz: 9b2c8cd637017af2c310b06a406d75c0a70659244f6b93dfcb12b0f470337d87c8254e257fa4e4810faea6b12e0f2ad27a60b40d2c3423cc98deb21cf56df39d
7
+ data.tar.gz: d2b9af11af98db9c2d92142553a1a7cc3eb915ddc398ed91b34a04d201e4cbda3dbc926bd007fef335071cbfc77e91ce22b3ff272c3eb7ffa8b607b032841cc6
@@ -1,2 +1,4 @@
1
+ //= link onlylogs/clusterize.js
2
+ //= link onlylogs/clusterize.css
1
3
  //= link_directory ../../javascript/onlylogs .js
2
4
  //= link_directory ../../javascript/onlylogs/controllers .js
@@ -0,0 +1,342 @@
1
+ /* Clusterize.js - v1.0.0 - 2023-01-22
2
+ http://NeXTs.github.com/Clusterize.js/
3
+ Copyright (c) 2015 Denis Lukov; Licensed MIT */
4
+
5
+ ;(function(name, definition) {
6
+ if (typeof module != 'undefined') module.exports = definition();
7
+ else if (typeof define == 'function' && typeof define.amd == 'object') define(definition);
8
+ else this[name] = definition();
9
+ }('Clusterize', function() {
10
+ "use strict"
11
+
12
+ // detect ie9 and lower
13
+ // https://gist.github.com/padolsey/527683#comment-786682
14
+ var ie = (function(){
15
+ for( var v = 3,
16
+ el = document.createElement('b'),
17
+ all = el.all || [];
18
+ el.innerHTML = '<!--[if gt IE ' + (++v) + ']><i><![endif]-->',
19
+ all[0];
20
+ ){}
21
+ return v > 4 ? v : document.documentMode;
22
+ }()),
23
+ is_mac = navigator.platform.toLowerCase().indexOf('mac') + 1;
24
+ var Clusterize = function(data) {
25
+ if( ! (this instanceof Clusterize))
26
+ return new Clusterize(data);
27
+ var self = this;
28
+
29
+ var defaults = {
30
+ rows_in_block: 50,
31
+ blocks_in_cluster: 4,
32
+ tag: null,
33
+ show_no_data_row: true,
34
+ no_data_class: 'clusterize-no-data',
35
+ no_data_text: 'No data',
36
+ keep_parity: true,
37
+ callbacks: {}
38
+ }
39
+
40
+ // public parameters
41
+ self.options = {};
42
+ var options = ['rows_in_block', 'blocks_in_cluster', 'show_no_data_row', 'no_data_class', 'no_data_text', 'keep_parity', 'tag', 'callbacks'];
43
+ for(var i = 0, option; option = options[i]; i++) {
44
+ self.options[option] = typeof data[option] != 'undefined' && data[option] != null
45
+ ? data[option]
46
+ : defaults[option];
47
+ }
48
+
49
+ var elems = ['scroll', 'content'];
50
+ for(var i = 0, elem; elem = elems[i]; i++) {
51
+ self[elem + '_elem'] = data[elem + 'Id']
52
+ ? document.getElementById(data[elem + 'Id'])
53
+ : data[elem + 'Elem'];
54
+ if( ! self[elem + '_elem'])
55
+ throw new Error("Error! Could not find " + elem + " element");
56
+ }
57
+
58
+ // tabindex forces the browser to keep focus on the scrolling list, fixes #11
59
+ if( ! self.content_elem.hasAttribute('tabindex'))
60
+ self.content_elem.setAttribute('tabindex', 0);
61
+
62
+ // private parameters
63
+ var rows = isArray(data.rows)
64
+ ? data.rows
65
+ : self.fetchMarkup(),
66
+ cache = {},
67
+ scroll_top = self.scroll_elem.scrollTop;
68
+
69
+ // append initial data
70
+ self.insertToDOM(rows, cache);
71
+
72
+ // restore the scroll position
73
+ self.scroll_elem.scrollTop = scroll_top;
74
+
75
+ // adding scroll handler
76
+ var last_cluster = false,
77
+ scroll_debounce = 0,
78
+ pointer_events_set = false,
79
+ scrollEv = function() {
80
+ // fixes scrolling issue on Mac #3
81
+ if (is_mac) {
82
+ if( ! pointer_events_set) self.content_elem.style.pointerEvents = 'none';
83
+ pointer_events_set = true;
84
+ clearTimeout(scroll_debounce);
85
+ scroll_debounce = setTimeout(function () {
86
+ self.content_elem.style.pointerEvents = 'auto';
87
+ pointer_events_set = false;
88
+ }, 50);
89
+ }
90
+ if (last_cluster != (last_cluster = self.getClusterNum(rows)))
91
+ self.insertToDOM(rows, cache);
92
+ if (self.options.callbacks.scrollingProgress)
93
+ self.options.callbacks.scrollingProgress(self.getScrollProgress());
94
+ },
95
+ resize_debounce = 0,
96
+ resizeEv = function() {
97
+ clearTimeout(resize_debounce);
98
+ resize_debounce = setTimeout(self.refresh, 100);
99
+ }
100
+ on('scroll', self.scroll_elem, scrollEv);
101
+ on('resize', window, resizeEv);
102
+
103
+ // public methods
104
+ self.destroy = function(clean) {
105
+ off('scroll', self.scroll_elem, scrollEv);
106
+ off('resize', window, resizeEv);
107
+ self.html((clean ? self.generateEmptyRow() : rows).join(''));
108
+ }
109
+ self.refresh = function(force) {
110
+ if(self.getRowsHeight(rows) || force) self.update(rows);
111
+ }
112
+ self.update = function(new_rows) {
113
+ rows = isArray(new_rows)
114
+ ? new_rows
115
+ : [];
116
+ var scroll_top = self.scroll_elem.scrollTop;
117
+ // fixes #39
118
+ if(rows.length * self.options.item_height < scroll_top) {
119
+ self.scroll_elem.scrollTop = 0;
120
+ last_cluster = 0;
121
+ }
122
+ self.insertToDOM(rows, cache);
123
+ self.scroll_elem.scrollTop = scroll_top;
124
+ }
125
+ self.clear = function() {
126
+ self.update([]);
127
+ }
128
+ self.getRowsAmount = function() {
129
+ return rows.length;
130
+ }
131
+ self.prune = function(count) {
132
+ if(count <= 0 || rows.length <= count) return;
133
+ rows = rows.slice(count);
134
+ var scroll_top = self.scroll_elem.scrollTop;
135
+ self.insertToDOM(rows, cache);
136
+ self.scroll_elem.scrollTop = Math.max(0, scroll_top - (count * self.options.item_height));
137
+ }
138
+ self.getScrollProgress = function() {
139
+ return this.options.scroll_top / (rows.length * this.options.item_height) * 100 || 0;
140
+ }
141
+
142
+ var add = function(where, _new_rows) {
143
+ var new_rows = isArray(_new_rows)
144
+ ? _new_rows
145
+ : [];
146
+ if( ! new_rows.length) return;
147
+ rows = where == 'append'
148
+ ? rows.concat(new_rows)
149
+ : new_rows.concat(rows);
150
+ self.insertToDOM(rows, cache);
151
+ }
152
+ self.append = function(rows) {
153
+ add('append', rows);
154
+ }
155
+ self.prepend = function(rows) {
156
+ add('prepend', rows);
157
+ }
158
+ }
159
+
160
+ Clusterize.prototype = {
161
+ constructor: Clusterize,
162
+ // fetch existing markup
163
+ fetchMarkup: function() {
164
+ var rows = [], rows_nodes = this.getChildNodes(this.content_elem);
165
+ while (rows_nodes.length) {
166
+ rows.push(rows_nodes.shift().outerHTML);
167
+ }
168
+ return rows;
169
+ },
170
+ // get tag name, content tag name, tag height, calc cluster height
171
+ exploreEnvironment: function(rows, cache) {
172
+ var opts = this.options;
173
+ opts.content_tag = this.content_elem.tagName.toLowerCase();
174
+ if( ! rows.length) return;
175
+ if(ie && ie <= 9 && ! opts.tag) opts.tag = rows[0].match(/<([^>\s/]*)/)[1].toLowerCase();
176
+ if(this.content_elem.children.length <= 1) cache.data = this.html(rows[0] + rows[0] + rows[0]);
177
+ if( ! opts.tag) opts.tag = this.content_elem.children[0].tagName.toLowerCase();
178
+ this.getRowsHeight(rows);
179
+ },
180
+ getRowsHeight: function(rows) {
181
+ var opts = this.options,
182
+ prev_item_height = opts.item_height;
183
+ opts.cluster_height = 0;
184
+ if( ! rows.length) return;
185
+ var nodes = this.content_elem.children;
186
+ if( ! nodes.length) return;
187
+ var node = nodes[Math.floor(nodes.length / 2)];
188
+ opts.item_height = node.offsetHeight;
189
+ // consider table's border-spacing
190
+ if(opts.tag == 'tr' && getStyle('borderCollapse', this.content_elem) != 'collapse')
191
+ opts.item_height += parseInt(getStyle('borderSpacing', this.content_elem), 10) || 0;
192
+ // consider margins (and margins collapsing)
193
+ if(opts.tag != 'tr') {
194
+ var marginTop = parseInt(getStyle('marginTop', node), 10) || 0;
195
+ var marginBottom = parseInt(getStyle('marginBottom', node), 10) || 0;
196
+ opts.item_height += Math.max(marginTop, marginBottom);
197
+ }
198
+ opts.block_height = opts.item_height * opts.rows_in_block;
199
+ opts.rows_in_cluster = opts.blocks_in_cluster * opts.rows_in_block;
200
+ opts.cluster_height = opts.blocks_in_cluster * opts.block_height;
201
+ return prev_item_height != opts.item_height;
202
+ },
203
+ // get current cluster number
204
+ getClusterNum: function (rows) {
205
+ var opts = this.options;
206
+ opts.scroll_top = this.scroll_elem.scrollTop;
207
+ var cluster_divider = opts.cluster_height - opts.block_height;
208
+ var current_cluster = Math.floor(opts.scroll_top / cluster_divider);
209
+ var max_cluster = Math.floor((rows.length * opts.item_height) / cluster_divider);
210
+ return Math.min(current_cluster, max_cluster);
211
+ },
212
+ // generate empty row if no data provided
213
+ generateEmptyRow: function() {
214
+ var opts = this.options;
215
+ if( ! opts.tag || ! opts.show_no_data_row) return [];
216
+ var empty_row = document.createElement(opts.tag),
217
+ no_data_content = document.createTextNode(opts.no_data_text), td;
218
+ empty_row.className = opts.no_data_class;
219
+ if(opts.tag == 'tr') {
220
+ td = document.createElement('td');
221
+ // fixes #53
222
+ td.colSpan = 100;
223
+ td.appendChild(no_data_content);
224
+ }
225
+ empty_row.appendChild(td || no_data_content);
226
+ return [empty_row.outerHTML];
227
+ },
228
+ // generate cluster for current scroll position
229
+ generate: function (rows) {
230
+ var opts = this.options,
231
+ rows_len = rows.length;
232
+ if (rows_len < opts.rows_in_block) {
233
+ return {
234
+ top_offset: 0,
235
+ bottom_offset: 0,
236
+ rows_above: 0,
237
+ rows: rows_len ? rows : this.generateEmptyRow()
238
+ }
239
+ }
240
+ var items_start = Math.max((opts.rows_in_cluster - opts.rows_in_block) * this.getClusterNum(rows), 0),
241
+ items_end = items_start + opts.rows_in_cluster,
242
+ top_offset = Math.max(items_start * opts.item_height, 0),
243
+ bottom_offset = Math.max((rows_len - items_end) * opts.item_height, 0),
244
+ this_cluster_rows = [],
245
+ rows_above = items_start;
246
+ if(top_offset < 1) {
247
+ rows_above++;
248
+ }
249
+ for (var i = items_start; i < items_end; i++) {
250
+ rows[i] && this_cluster_rows.push(rows[i]);
251
+ }
252
+ return {
253
+ top_offset: top_offset,
254
+ bottom_offset: bottom_offset,
255
+ rows_above: rows_above,
256
+ rows: this_cluster_rows
257
+ }
258
+ },
259
+ renderExtraTag: function(class_name, height) {
260
+ var tag = document.createElement(this.options.tag),
261
+ clusterize_prefix = 'clusterize-';
262
+ tag.className = [clusterize_prefix + 'extra-row', clusterize_prefix + class_name].join(' ');
263
+ height && (tag.style.height = height + 'px');
264
+ return tag.outerHTML;
265
+ },
266
+ // if necessary verify data changed and insert to DOM
267
+ insertToDOM: function(rows, cache) {
268
+ // explore row's height
269
+ if( ! this.options.cluster_height) {
270
+ this.exploreEnvironment(rows, cache);
271
+ }
272
+ var data = this.generate(rows),
273
+ this_cluster_rows = data.rows.join(''),
274
+ this_cluster_content_changed = this.checkChanges('data', this_cluster_rows, cache),
275
+ top_offset_changed = this.checkChanges('top', data.top_offset, cache),
276
+ only_bottom_offset_changed = this.checkChanges('bottom', data.bottom_offset, cache),
277
+ callbacks = this.options.callbacks,
278
+ layout = [];
279
+
280
+ if(this_cluster_content_changed || top_offset_changed) {
281
+ if(data.top_offset) {
282
+ this.options.keep_parity && layout.push(this.renderExtraTag('keep-parity'));
283
+ layout.push(this.renderExtraTag('top-space', data.top_offset));
284
+ }
285
+ layout.push(this_cluster_rows);
286
+ data.bottom_offset && layout.push(this.renderExtraTag('bottom-space', data.bottom_offset));
287
+ callbacks.clusterWillChange && callbacks.clusterWillChange();
288
+ this.html(layout.join(''));
289
+ this.options.content_tag == 'ol' && this.content_elem.setAttribute('start', data.rows_above);
290
+ this.content_elem.style['counter-increment'] = 'clusterize-counter ' + (data.rows_above-1);
291
+ callbacks.clusterChanged && callbacks.clusterChanged();
292
+ } else if(only_bottom_offset_changed) {
293
+ this.content_elem.lastChild.style.height = data.bottom_offset + 'px';
294
+ }
295
+ },
296
+ // unfortunately ie <= 9 does not allow to use innerHTML for table elements, so make a workaround
297
+ html: function(data) {
298
+ var content_elem = this.content_elem;
299
+ if(ie && ie <= 9 && this.options.tag == 'tr') {
300
+ var div = document.createElement('div'), last;
301
+ div.innerHTML = '<table><tbody>' + data + '</tbody></table>';
302
+ while((last = content_elem.lastChild)) {
303
+ content_elem.removeChild(last);
304
+ }
305
+ var rows_nodes = this.getChildNodes(div.firstChild.firstChild);
306
+ while (rows_nodes.length) {
307
+ content_elem.appendChild(rows_nodes.shift());
308
+ }
309
+ } else {
310
+ content_elem.innerHTML = data;
311
+ }
312
+ },
313
+ getChildNodes: function(tag) {
314
+ var child_nodes = tag.children, nodes = [];
315
+ for (var i = 0, ii = child_nodes.length; i < ii; i++) {
316
+ nodes.push(child_nodes[i]);
317
+ }
318
+ return nodes;
319
+ },
320
+ checkChanges: function(type, value, cache) {
321
+ var changed = value != cache[type];
322
+ cache[type] = value;
323
+ return changed;
324
+ }
325
+ }
326
+
327
+ // support functions
328
+ function on(evt, element, fnc) {
329
+ return element.addEventListener ? element.addEventListener(evt, fnc, false) : element.attachEvent("on" + evt, fnc);
330
+ }
331
+ function off(evt, element, fnc) {
332
+ return element.removeEventListener ? element.removeEventListener(evt, fnc, false) : element.detachEvent("on" + evt, fnc);
333
+ }
334
+ function isArray(arr) {
335
+ return Object.prototype.toString.call(arr) === '[object Array]';
336
+ }
337
+ function getStyle(prop, elem) {
338
+ return window.getComputedStyle ? window.getComputedStyle(elem)[prop] : elem.currentStyle[prop];
339
+ }
340
+
341
+ return Clusterize;
342
+ }));
@@ -0,0 +1,35 @@
1
+ /* Clusterize scroll container. Height is controlled by parent container. */
2
+ .clusterize-scroll{
3
+ overflow: auto;
4
+ }
5
+
6
+ /**
7
+ * Avoid vertical margins for extra tags
8
+ * Necessary for correct calculations when rows have nonzero vertical margins
9
+ */
10
+ .clusterize-extra-row{
11
+ margin-top: 0 !important;
12
+ margin-bottom: 0 !important;
13
+ }
14
+
15
+ /* By default extra tag .clusterize-keep-parity added to keep parity of rows.
16
+ * Useful when used :nth-child(even/odd)
17
+ */
18
+ .clusterize-extra-row.clusterize-keep-parity{
19
+ display: none;
20
+ }
21
+
22
+ /* During initialization clusterize adds tabindex to force the browser to keep focus
23
+ * on the scrolling list, see issue #11
24
+ * Outline removes default browser's borders for focused elements.
25
+ */
26
+ .clusterize-content{
27
+ outline: 0;
28
+ counter-reset: clusterize-counter;
29
+ }
30
+
31
+ /* Centering message that appears when no data provided
32
+ */
33
+ .clusterize-no-data td{
34
+ text-align: center;
35
+ }
@@ -3,12 +3,17 @@
3
3
  module Onlylogs
4
4
  class LogsChannel < ActionCable::Channel::Base
5
5
  def subscribed
6
- # Rails.logger.info "Client subscribed to Onlylogs::LogsChannel"
7
- # Wait for the client to send the cursor position
8
- # start_log_watcher will be called from the initialize_watcher method
6
+ @last_initialize_params = nil
9
7
  end
10
8
 
11
9
  def initialize_watcher(data)
10
+ # Prevent duplicate calls with identical parameters
11
+ if @last_initialize_params == data
12
+ Rails.logger.info "Onlylogs: Ignoring duplicate initialize_watcher call"
13
+ return
14
+ end
15
+
16
+ @last_initialize_params = data.dup
12
17
  cleanup_existing_operations
13
18
 
14
19
  # Decrypt and verify the file path
@@ -39,19 +44,20 @@ module Onlylogs
39
44
  return
40
45
  end
41
46
 
42
- cursor_position = data["cursor_position"] || 0
43
47
  filter = data["filter"].presence
44
48
  mode = data["mode"] || "live"
45
49
  regexp_mode = data["regexp_mode"] == true || data["regexp_mode"] == "true"
46
- start_position = data["start_position"]&.to_i || 0
47
- end_position = data["end_position"]&.to_i
48
50
 
49
- if mode == "search"
50
- # For search mode, read the entire file with filter and send all matching lines
51
- read_entire_file_with_filter(file_path, filter, regexp_mode, start_position, end_position)
51
+ file_size = ::File.size(file_path)
52
+ start_position = (data["start_position"]&.to_i || 0).clamp(0, file_size)
53
+ end_position = data["end_position"]&.to_i&.clamp(0, file_size) if data["end_position"]
54
+
55
+ if mode == "static"
56
+ # Read the entire file with filter and send all matching lines
57
+ read_static(file_path, filter, regexp_mode, start_position, end_position)
52
58
  else
53
- # For live mode, start the watcher
54
- start_log_watcher(file_path, cursor_position, filter, regexp_mode)
59
+ # Follow the tail of the file indefinitely
60
+ start_log_watcher(file_path, filter, regexp_mode)
55
61
  end
56
62
  end
57
63
 
@@ -75,7 +81,11 @@ module Onlylogs
75
81
  stop_log_watcher
76
82
  end
77
83
 
78
- def start_log_watcher(file_path, cursor_position, filter = nil, regexp_mode = false)
84
+ # Bytes from the end of the file to show when starting a live tail without
85
+ # an explicit cursor (matches the default whole-file live-mode page load).
86
+ LIVE_TAIL_BYTES = 10_000
87
+
88
+ def start_log_watcher(file_path, filter = nil, regexp_mode = false)
79
89
  return if @log_watcher_running
80
90
 
81
91
  @log_watcher_running = true
@@ -84,7 +94,9 @@ module Onlylogs
84
94
 
85
95
  transmit({action: "message", content: "Reading file. Please wait..."})
86
96
 
87
- @log_file = Onlylogs::File.new(file_path, last_position: cursor_position)
97
+ # Start from LIVE_TAIL_BYTES from the end, or from the beginning if file is smaller
98
+ starting_position = [::File.size(file_path) - LIVE_TAIL_BYTES, 0].max
99
+ @log_file = Onlylogs::File.new(file_path, last_position: starting_position)
88
100
 
89
101
  transmit({action: "message", content: ""})
90
102
 
@@ -97,10 +109,9 @@ module Onlylogs
97
109
  lines_to_send = []
98
110
 
99
111
  new_lines.each do |log_line|
100
- # Filters in live mode are not yet implemented
101
- # if @filter.present? && !Onlylogs::Grep.match_line?(log_line.text, @filter, regexp_mode: @regexp_mode)
102
- # next
103
- # end
112
+ if @filter.present? && !Onlylogs::Grep.match_line?(log_line, @filter, regexp_mode: @regexp_mode)
113
+ next
114
+ end
104
115
 
105
116
  lines_to_send << render_log_line(log_line)
106
117
  end
@@ -143,34 +154,82 @@ module Onlylogs
143
154
  @log_file = nil
144
155
  end
145
156
 
146
- def read_entire_file_with_filter(file_path, filter = nil, regexp_mode = false, start_position = 0, end_position = nil)
157
+ def read_static(file_path, filter = nil, regexp_mode = false, start_position = 0, end_position = nil)
147
158
  @log_watcher_running = true
148
159
  @log_file = Onlylogs::File.new(file_path, last_position: 0)
149
160
 
150
- transmit({action: "message", content: "Searching..."})
161
+ transmit({action: "message", content: filter.present? ? "Searching..." : "Loading..."})
151
162
 
152
163
  @batch_sender = BatchSender.new(self)
153
164
  @batch_sender.start
154
165
 
155
- line_count = 0
156
-
157
166
  begin
158
- Rails.logger.silence(Logger::ERROR) do
159
- @log_file.grep(filter, regexp_mode: regexp_mode, start_position: start_position, end_position: end_position) do |log_line|
160
- break if @batch_sender.nil?
161
-
162
- # Add to batch buffer (sender thread will handle sending)
163
- @batch_sender.add_line(render_log_line(log_line))
167
+ last_line = nil
168
+ last_byte_offset = nil
169
+ line_count = 0
164
170
 
165
- line_count += 1
171
+ Rails.logger.silence(Logger::ERROR) do
172
+ skip_first = start_position > 0
173
+
174
+ if filter.present?
175
+ # Use grep for filtered search
176
+ @log_file.grep(filter, regexp_mode: regexp_mode, start_position: start_position, end_position: end_position) do |result|
177
+ break if @batch_sender.nil? || @log_watcher_running == false
178
+
179
+ # Skip first line if start_position > 0 (line is cut off at byte boundary)
180
+ if skip_first
181
+ skip_first = false
182
+ next
183
+ end
184
+
185
+ # Result is a hash with {byte_offset, content}
186
+ byte_offset = result[:byte_offset]
187
+ log_line = result[:content]
188
+
189
+ # Buffer previous line and skip it to avoid cut-off lines at boundaries
190
+ if last_line
191
+ @batch_sender.add_line(render_log_line(last_line, byte_offset: last_byte_offset, show_expand_button: true))
192
+ line_count += 1
193
+ end
194
+ last_line = log_line
195
+ last_byte_offset = byte_offset
196
+ end
197
+ else
198
+ # No filter - read all lines directly (skip grep)
199
+ # Still need byte_offset for highlighting when expanding around a line
200
+ current_byte_offset = start_position
201
+ read_byte_range(file_path, start_position, end_position) do |log_line|
202
+ break if @batch_sender.nil? || @log_watcher_running == false
203
+
204
+ # Skip first line if start_position > 0 (line is cut off at byte boundary)
205
+ if skip_first
206
+ skip_first = false
207
+ next
208
+ end
209
+
210
+ # Buffer previous line and skip it to avoid cut-off lines at boundaries
211
+ if last_line
212
+ @batch_sender.add_line(render_log_line(last_line, byte_offset: last_byte_offset))
213
+ line_count += 1
214
+ end
215
+ last_line = log_line
216
+ last_byte_offset = current_byte_offset
217
+ # Account for line content plus newline character (2 bytes for \r\n or 1 for \n)
218
+ current_byte_offset += log_line.bytesize + 1
219
+ end
166
220
  end
167
221
  end
168
222
 
169
- # Stop batch sender and flush any remaining lines
223
+ # Send last line only if no end_position (avoid cut-off line at byte boundary)
224
+ if last_line && !end_position
225
+ @batch_sender.add_line(render_log_line(last_line, byte_offset: last_byte_offset, show_expand_button: filter.present?))
226
+ line_count += 1
227
+ end
228
+
170
229
  @batch_sender.stop
171
230
 
172
231
  # Send completion message
173
- if line_count >= Onlylogs.max_line_matches
232
+ if Onlylogs.max_line_matches && line_count >= Onlylogs.max_line_matches
174
233
  transmit({action: "finish", content: "Search finished. Search results limit reached."})
175
234
  else
176
235
  transmit({action: "finish", content: "Search finished."})
@@ -184,8 +243,27 @@ module Onlylogs
184
243
  end
185
244
  end
186
245
 
187
- def render_log_line(log_line)
188
- "<pre>#{FilePathParser.parse(AnsiColorParser.parse(ERB::Util.html_escape(log_line)))}</pre>"
246
+ def read_byte_range(file_path, start_position, end_position)
247
+ file_size = ::File.size(file_path)
248
+ range_size = (end_position || file_size) - start_position
249
+
250
+ return if start_position < 0 || range_size <= 0 || start_position >= file_size
251
+
252
+ ::File.read(file_path, range_size, start_position).each_line do |line|
253
+ yield line.chomp
254
+ end
255
+ rescue => e
256
+ Rails.logger.error "Error reading byte range: #{e.message}"
257
+ end
258
+
259
+ def render_log_line(log_line, byte_offset: nil, show_expand_button: false)
260
+ parsed = FilePathParser.parse(AnsiColorParser.parse(ERB::Util.html_escape(log_line)))
261
+
262
+ {
263
+ content: parsed,
264
+ byte_offset: byte_offset,
265
+ show_expand_button: show_expand_button
266
+ }
189
267
  end
190
268
  end
191
269
  end
@@ -3,15 +3,8 @@
3
3
  module Onlylogs
4
4
  class LogsController < ApplicationController
5
5
  def index
6
- @max_lines = (params[:max_lines] || 100).to_i
7
-
8
6
  @available_log_files = Onlylogs.available_log_files
9
7
  @log_file_path = selected_log_file_path
10
-
11
- @filter = params[:filter]
12
- @autoscroll = params[:autoscroll] != "false"
13
- @regexp_mode = params[:regexp_mode] == "true"
14
- @mode = @filter.blank? ? (params[:mode] || "live") : "search" # "live" or "search"
15
8
  end
16
9
 
17
10
  def download