From 83e2aed4109df699eab8c9ddb90e7c4aa7cae8f0 Mon Sep 17 00:00:00 2001 From: Alessandro Rodi Date: Fri, 12 Jun 2026 15:10:38 +0200 Subject: [PATCH 1/2] Add explore mode to view logs around a specific byte offset Introduce byte-offset navigation: track byte offsets while reading logs, include them in ripgrep output and parse them in grep, and add an explore mode that displays logs around a specific offset with an input field and expand button on search results. Always allow switching to live mode visually, build the pre element client-side, and adjust tests to the new ripgrep output format. --- Gemfile.lock | 2 +- app/channels/onlylogs/logs_channel.rb | 43 +++-- app/controllers/onlylogs/logs_controller.rb | 17 ++ .../controllers/log_streamer_controller.js | 147 ++++++++++++++++-- app/models/onlylogs/grep.rb | 21 ++- app/views/onlylogs/logs/index.html.erb | 2 +- .../onlylogs/shared/_log_container.html.erb | 38 ++++- .../shared/_log_container_styles.html.erb | 30 +++- bin/super_ripgrep | 2 +- .../onlylogs/logs_controller_test.rb | 45 ++++++ test/models/onlylogs/grep_test.rb | 18 +-- 11 files changed, 312 insertions(+), 53 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e26f5fc..c471a22 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - onlylogs (0.5.3) + onlylogs (0.5.1) rails (~> 8.0) GEM diff --git a/app/channels/onlylogs/logs_channel.rb b/app/channels/onlylogs/logs_channel.rb index efc24d6..daecbee 100644 --- a/app/channels/onlylogs/logs_channel.rb +++ b/app/channels/onlylogs/logs_channel.rb @@ -42,15 +42,19 @@ def initialize_watcher(data) cursor_position = data["cursor_position"] || 0 filter = data["filter"].presence mode = data["mode"] || "live" + search_type = data["search_type"] regexp_mode = data["regexp_mode"] == true || data["regexp_mode"] == "true" start_position = data["start_position"]&.to_i || 0 end_position = data["end_position"]&.to_i - if mode == "search" - # For search mode, read the entire file with filter and send all matching lines + if mode == "search" && search_type != "byteoffset" + # For filter-based search, read the entire file with filter and send all matching lines read_entire_file_with_filter(file_path, filter, regexp_mode, start_position, end_position) + elsif search_type == "byteoffset" + # For byteoffset search, read a fixed range + start_log_watcher(file_path, cursor_position, filter, regexp_mode, end_position) else - # For live mode, start the watcher + # For live mode, stream indefinitely without end position start_log_watcher(file_path, cursor_position, filter, regexp_mode) end end @@ -75,12 +79,13 @@ def cleanup_existing_operations stop_log_watcher end - def start_log_watcher(file_path, cursor_position, filter = nil, regexp_mode = false) + def start_log_watcher(file_path, cursor_position, filter = nil, regexp_mode = false, end_position = nil) return if @log_watcher_running @log_watcher_running = true @filter = filter @regexp_mode = regexp_mode + @end_position = end_position transmit({action: "message", content: "Reading file. Please wait..."}) @@ -90,6 +95,7 @@ def start_log_watcher(file_path, cursor_position, filter = nil, regexp_mode = fa @log_watcher_thread = Thread.new do Rails.logger.silence(Logger::ERROR) do + current_byte_offset = cursor_position @log_file.watch do |new_lines| break unless @log_watcher_running @@ -97,12 +103,19 @@ def start_log_watcher(file_path, cursor_position, filter = nil, regexp_mode = fa lines_to_send = [] new_lines.each do |log_line| + # Stop if we've reached the end position (only when expanding context) + if @end_position && @end_position > 0 && current_byte_offset >= @end_position + @log_watcher_running = false + break + end + # Filters in live mode are not yet implemented # if @filter.present? && !Onlylogs::Grep.match_line?(log_line.text, @filter, regexp_mode: @regexp_mode) # next # end - lines_to_send << render_log_line(log_line) + lines_to_send << render_log_line(log_line, byte_offset: current_byte_offset) + current_byte_offset += log_line.bytesize end if lines_to_send.any? @@ -118,6 +131,8 @@ def start_log_watcher(file_path, cursor_position, filter = nil, regexp_mode = fa Rails.logger.error e.backtrace.join("\n") ensure @log_watcher_running = false + # Send finish message if we had an end position (byteoffset mode) + transmit({action: "finish", content: "Context loaded."}) if @end_position.present? end end @@ -156,11 +171,15 @@ def read_entire_file_with_filter(file_path, filter = nil, regexp_mode = false, s begin Rails.logger.silence(Logger::ERROR) do - @log_file.grep(filter, regexp_mode: regexp_mode, start_position: start_position, end_position: end_position) do |log_line| + @log_file.grep(filter, regexp_mode: regexp_mode, start_position: start_position, end_position: end_position) do |result| break if @batch_sender.nil? + # Result is now a hash with {byte_offset, content} + byte_offset = result[:byte_offset] + log_line = result[:content] + # Add to batch buffer (sender thread will handle sending) - @batch_sender.add_line(render_log_line(log_line)) + @batch_sender.add_line(render_log_line(log_line, byte_offset: byte_offset, show_expand_button: true)) line_count += 1 end @@ -184,8 +203,14 @@ def read_entire_file_with_filter(file_path, filter = nil, regexp_mode = false, s end end - def render_log_line(log_line) - "
#{FilePathParser.parse(AnsiColorParser.parse(ERB::Util.html_escape(log_line)))}
" + def render_log_line(log_line, byte_offset: nil, show_expand_button: false) + parsed = FilePathParser.parse(AnsiColorParser.parse(ERB::Util.html_escape(log_line))) + + { + content: parsed, + byte_offset: byte_offset, + show_expand_button: show_expand_button + } end end end diff --git a/app/controllers/onlylogs/logs_controller.rb b/app/controllers/onlylogs/logs_controller.rb index b503225..5e947b6 100644 --- a/app/controllers/onlylogs/logs_controller.rb +++ b/app/controllers/onlylogs/logs_controller.rb @@ -12,6 +12,11 @@ def index @autoscroll = params[:autoscroll] != "false" @regexp_mode = params[:regexp_mode] == "true" @mode = @filter.blank? ? (params[:mode] || "live") : "search" # "live" or "search" + @search_type = @filter.present? ? "filter" : nil + @start_position = nil + @end_position = nil + + handle_byte_offset if params[:byte_offset].present? end def download @@ -31,6 +36,18 @@ def download private + def handle_byte_offset + byte_offset = params[:byte_offset]&.to_i + return unless byte_offset.present? + + @start_position = [byte_offset - 10000, 0].max + @end_position = byte_offset + 10000 + @filter = nil + @mode = "search" + @search_type = "byteoffset" + @autoscroll = false + end + def selected_log_file_path return default_log_file_path if params[:log_file_path].blank? authorized_log_file_path(params[:log_file_path]) diff --git a/app/javascript/onlylogs/controllers/log_streamer_controller.js b/app/javascript/onlylogs/controllers/log_streamer_controller.js index e5781cc..5ff497d 100644 --- a/app/javascript/onlylogs/controllers/log_streamer_controller.js +++ b/app/javascript/onlylogs/controllers/log_streamer_controller.js @@ -5,14 +5,16 @@ export default class LogStreamerController extends Controller { static values = { filePath: { type: String }, cursorPosition: { type: Number, default: 0 }, + endPosition: { type: Number, default: 0 }, autoScroll: { type: Boolean, default: true }, autoStart: { type: Boolean, default: true }, filter: { type: String, default: '' }, mode: { type: String, default: 'live' }, + searchType: { type: String, default: null }, regexpMode: { type: Boolean, default: false } }; - static targets = ["logLines", "filterInput", "results", "liveMode", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton", "autoscroll"]; + static targets = ["logLines", "filterInput", "byteOffsetInput", "results", "liveMode", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton", "autoscroll"]; connect() { this.consumer = createConsumer(); @@ -21,6 +23,7 @@ export default class LogStreamerController extends Controller { this.isRunning = false; this.reconnectTimeout = null; this.isSearchFinished = true; + this.contextLineHighlighted = false; // Initialize clusterize this.clusterize = null; @@ -116,9 +119,9 @@ export default class LogStreamerController extends Controller { } toggleLiveMode() { - // this condition looks revered, but the value here has been changed already. so the live mode has been enabled. if (this.isLiveMode()) { this.modeValue = 'live'; + this.clearFilter(); this.updateLiveModeState(); if (!this.isRunning) { this.start(); @@ -132,20 +135,24 @@ export default class LogStreamerController extends Controller { applyFilter() { const filterValue = this.filterInputTarget.value; - // If filter is applied, disable live mode + // If filter is applied, disable live mode and set search type if (filterValue && filterValue.trim() !== '') { this.liveModeTarget.checked = false; this.modeValue = 'search'; + this.searchTypeValue = 'filter'; } else { // If no filter, enable live mode this.liveModeTarget.checked = true; this.modeValue = 'live'; + this.searchTypeValue = null; } // Update visual state this.updateLiveModeState(); this.updateStopButtonVisibility(); this.#updateUrlParam('filter', filterValue || null); + this.#updateUrlParam('byte_offset', null); + this.byteOffsetInputTarget.value = ''; // Use the global debounced reconnection (300ms delay) this.reconnectWithNewMode(); @@ -178,19 +185,29 @@ export default class LogStreamerController extends Controller { } clearFilter() { - // Clear the filter input + // Clear filter, byte_offset, and search type to go back to pure live mode this.filterInputTarget.value = ''; + this.byteOffsetInputTarget.value = ''; + this.modeValue = 'live'; + this.searchTypeValue = null; + this.endPositionValue = 0; + this.contextLineHighlighted = false; + + // Remove any highlighting + this.logLinesTarget.querySelectorAll('.highlighted-context-line').forEach(el => { + el.classList.remove('highlighted-context-line'); + }); - // Re-enable live mode + // Re-enable live mode checkbox this.liveModeTarget.checked = true; - this.modeValue = 'live'; // Update visual state this.updateLiveModeState(); this.updateStopButtonVisibility(); - // Update URL with cleared filter - this.#updateUrlParam('filter'); + // Update URL with cleared params + this.#updateUrlParam('filter', null); + this.#updateUrlParam('byte_offset', null); // Reconnect with cleared filter and live mode this.reconnectWithNewMode(); @@ -206,21 +223,78 @@ export default class LogStreamerController extends Controller { this.#hideMessage(); } + jumpToByteOffset(e) { + if (e instanceof KeyboardEvent && e.key !== 'Enter') return; + + const byteOffset = this.byteOffsetInputTarget.value; + if (!byteOffset || isNaN(byteOffset)) return; + + const params = new URLSearchParams(window.location.search); + params.set('byte_offset', byteOffset); + params.delete('filter'); + window.location.href = `${window.location.pathname}?${params.toString()}`; + } + + handleExpandClick(e) { + const btn = e.target.closest('.onlylogs-expand-btn'); + if (!btn) return; + + const byteOffset = btn.getAttribute('data-byte-offset'); + if (!byteOffset) return; + + const params = new URLSearchParams(window.location.search); + params.set('byte_offset', byteOffset); + params.delete('filter'); + params.set('autoscroll', 'false'); + params.delete('regexp_mode'); + + window.location.href = `${window.location.pathname}?${params.toString()}`; + } + + #highlightContextLine() { + const target = Number(new URLSearchParams(window.location.search).get('byte_offset')); + if (Number.isNaN(target)) return; + + this.#applyContextLineHighlight(target); + + // Only scroll on initial highlight + if (!this.contextLineHighlighted) { + const closestPre = [...this.logLinesTarget.querySelectorAll('pre[data-byte-offset]')] + .find(pre => Number(pre.dataset.byteOffset) === target || + Math.abs(Number(pre.dataset.byteOffset) - target) < 1000); + if (closestPre) { + closestPre.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + } + + #applyContextLineHighlight(target) { + const closestPre = [...this.logLinesTarget.querySelectorAll('pre[data-byte-offset]')] + .reduce((closest, pre) => { + const distance = Math.abs(Number(pre.dataset.byteOffset) - target); + return !closest || distance < closest.distance ? { pre, distance } : closest; + }, null)?.pre; + + if (!closestPre) return; + + [closestPre.previousElementSibling, closestPre, closestPre.nextElementSibling] + .filter(Boolean) + .forEach(line => line.classList.add('highlighted-context-line')); + } + updateLiveModeState() { const liveModeLabel = this.liveModeTarget.closest('label'); const hasFilter = this.filterInputTarget.value && this.filterInputTarget.value.trim() !== ''; if (hasFilter) { - liveModeLabel.classList.add('live-mode-sticky'); this.liveModeTarget.disabled = true; } else { - liveModeLabel.classList.remove('live-mode-sticky'); this.liveModeTarget.disabled = false; } } updateStopButtonVisibility() { - const shouldShow = !this.isLiveMode() && this.subscription && this.isRunning && !this.isSearchFinished; + const shouldShow = this.modeValue === 'search' && this.subscription && this.isRunning && !this.isSearchFinished; this.stopButtonTarget.style.display = shouldShow ? 'inline-block' : 'none'; } @@ -261,13 +335,25 @@ export default class LogStreamerController extends Controller { * Handle successful connection */ #handleConnected() { - this.subscription.perform('initialize_watcher', { + const data = { cursor_position: this.cursorPositionValue, file_path: this.filePathValue, filter: this.filterInputTarget.value, mode: this.modeValue, regexp_mode: this.regexpModeValue - }); + }; + + // Only send end_position if it's set (when expanding context around a search result) + if (this.endPositionValue > 0) { + data.end_position = this.endPositionValue; + } + + // Send search_type if it's set + if (this.searchTypeValue) { + data.search_type = this.searchTypeValue; + } + + this.subscription.perform('initialize_watcher', data); this.element.classList.add("log-streamer--connected"); this.element.classList.remove("log-streamer--disconnected", "log-streamer--rejected"); @@ -293,9 +379,20 @@ export default class LogStreamerController extends Controller { try { // Append new lines to clusterize if (lines.length > 0) { - this.clusterize.append(lines); + // Render JSON log lines into HTML strings + const renderedLines = lines.map(line => this.#renderLogLineHtml(line)); + this.clusterize.append(renderedLines); this.#updateResultsDisplay(); this.scroll(); + + // Find and highlight lines around the target byte offset + const params = new URLSearchParams(window.location.search); + if (params.has('byte_offset') && !this.contextLineHighlighted) { + setTimeout(() => { + this.#highlightContextLine(); + this.contextLineHighlighted = true; + }, 100); + } } // Update stop button visibility after processing lines @@ -306,6 +403,19 @@ export default class LogStreamerController extends Controller { } } + #renderLogLineHtml(logLine) { + // logLine is now a JSON object: {content, byte_offset, show_expand_button} + const { content, byte_offset, show_expand_button } = logLine; + + if (byte_offset && show_expand_button) { + return `
${content}
`; + } else if (byte_offset) { + return `
${content}
`; + } else { + return `
${content}
`; + } + } + #handleMessage(message) { this.#hideMessage(); if (message === '') { @@ -403,7 +513,14 @@ export default class LogStreamerController extends Controller { // Optional: handle cluster change }, clusterChanged: () => { - // Optional: handle after cluster change + // Re-apply highlighting when cluster changes (for virtual scrolling) + // Only highlight if we're in byteoffset mode + if (this.searchTypeValue === 'byteoffset') { + const target = Number(new URLSearchParams(window.location.search).get('byte_offset')); + if (!Number.isNaN(target)) { + this.#applyContextLineHighlight(target); + } + } }, scrollingProgress: (progress) => { // Optional: handle scrolling progress diff --git a/app/models/onlylogs/grep.rb b/app/models/onlylogs/grep.rb index e9e2711..e7f1eae 100644 --- a/app/models/onlylogs/grep.rb +++ b/app/models/onlylogs/grep.rb @@ -19,16 +19,29 @@ def self.grep(pattern, file_path, start_position: 0, end_position: nil, regexp_m results = [] + # Set up parsing logic based on whether ripgrep includes byte offsets + parse_line = if Onlylogs.ripgrep_enabled? + ->(line) { + parts = line.split(":", 2) + [parts[0].to_i, parts[1] || ""] + } + else + ->(line) { [nil, line] } + end + IO.popen(command_args, err: "/dev/null") do |io| io.each_line do |line| - # Line numbers are no longer outputted by super_grep/super_ripgrep + byte_offset, content = parse_line.call(line.chomp) + # Use String.new to create a copy and prevent memory retention from IO buffers - content = String.new(line.chomp, encoding: Encoding::UTF_8).scrub + content = String.new(content, encoding: Encoding::UTF_8).scrub + + result = {byte_offset: byte_offset, content: content} if block_given? - yield content + yield result else - results << content + results << result end end end diff --git a/app/views/onlylogs/logs/index.html.erb b/app/views/onlylogs/logs/index.html.erb index 6cc8564..e35b362 100644 --- a/app/views/onlylogs/logs/index.html.erb +++ b/app/views/onlylogs/logs/index.html.erb @@ -36,5 +36,5 @@ <% end %> <% end %> - <%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path, tail: @max_lines, filter: @filter, autoscroll: @autoscroll, regexp_mode: @regexp_mode } %> + <%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path, tail: @max_lines, filter: @filter, autoscroll: @autoscroll, regexp_mode: @regexp_mode, cursor_position: @cursor_position, start_position: @start_position, end_position: @end_position, mode: @mode, search_type: @search_type } %> diff --git a/app/views/onlylogs/shared/_log_container.html.erb b/app/views/onlylogs/shared/_log_container.html.erb index 916274d..f6f79ca 100644 --- a/app/views/onlylogs/shared/_log_container.html.erb +++ b/app/views/onlylogs/shared/_log_container.html.erb @@ -1,11 +1,13 @@ -<%# locals: (log_file_path:, tail: 100, filter: "", autoscroll: true, regexp_mode: false) %> +<%# locals: (log_file_path:, tail: 100, filter: "", autoscroll: true, regexp_mode: false, cursor_position: nil, start_position: nil, end_position: nil, mode: nil, search_type: nil) %> <%= render "onlylogs/shared/log_container_styles" %> <% - mode = filter.blank? ? "live" : "search" - cursor_position = mode == "search" ? 0 : [File.size(log_file_path) - (tail * 100), 0].max + mode = local_assigns[:mode] || (filter.blank? ? "live" : "search") + cursor_position = local_assigns[:start_position] || local_assigns[:cursor_position] || (mode == "search" ? 0 : [File.size(log_file_path) - (tail * 100), 0].max) + end_position = local_assigns[:end_position] + byte_offset = request.params[:byte_offset] raise SecurityError, "File path not allowed" unless Onlylogs.file_path_permitted?(log_file_path) @@ -15,11 +17,13 @@
data-log-streamer-end-position-value="<%= end_position %>"<% end %> data-log-streamer-filter-value="<%= filter %>" data-log-streamer-auto-scroll-value="<%= autoscroll %>" data-log-streamer-regexp-mode-value="<%= regexp_mode %>" data-log-streamer-mode-value="<%= mode %>" data-action="text-selection:start->log-streamer#pauseForSelection" + <% if search_type.present? %>data-log-streamer-search-type-value="<%= search_type %>"<% end %> class="onlylogs-log-container" >
@@ -75,6 +79,9 @@
+
+ Results: 0 +
-
- Results: 0 -
+ <% if Onlylogs.ripgrep_enabled? %> +
+ +
+ <% end %> <% unless Onlylogs.ripgrep_enabled? %> ⚠️ <% end %> diff --git a/app/views/onlylogs/shared/_log_container_styles.html.erb b/app/views/onlylogs/shared/_log_container_styles.html.erb index 37a2cf3..9628bb0 100644 --- a/app/views/onlylogs/shared/_log_container_styles.html.erb +++ b/app/views/onlylogs/shared/_log_container_styles.html.erb @@ -24,8 +24,25 @@ } } + &[data-log-streamer-mode-value="search"] .onlylogs-expand-btn { + cursor: pointer; + padding: 0 0.5rem; + margin-right: 0.25rem; + background: none; + border: none; + color: #666; + font-weight: bold; + font-size: 1.1em; + line-height: 1; + + &:hover { + color: #333; + } + } + .clusterize-content { outline: 0; + min-width: max-content; } .color-success { @@ -76,6 +93,11 @@ margin: 0 !important; padding: 0.2rem; word-break: break-word; /* allow breaking long tokens like UUIDs/SQL */ + white-space: pre; + + &.highlighted-context-line { + background-color: rgba(255, 193, 7, 0.3); + } } a { @@ -121,15 +143,9 @@ align-items: center; gap: 1.5rem; - .live-mode-sticky { - opacity: 0.7; - cursor: not-allowed; - input[type="checkbox"] { - cursor: not-allowed; - } - } + } .clear-filter-button { position: absolute; right: 0.25rem; diff --git a/bin/super_ripgrep b/bin/super_ripgrep index 8dac08a..2774698 100755 --- a/bin/super_ripgrep +++ b/bin/super_ripgrep @@ -56,7 +56,7 @@ actual_color_regex='\x1b\[[0-9;]*m' query_regex="${query_regex//$placeholder/$actual_color_regex}" # Build ripgrep command -rg_cmd="rg --color=never --no-filename" +rg_cmd="rg --color=never --no-filename --byte-offset" [ -n "$max_matches" ] && rg_cmd="$rg_cmd --max-count=$max_matches" # Handle byte range if specified diff --git a/test/controllers/onlylogs/logs_controller_test.rb b/test/controllers/onlylogs/logs_controller_test.rb index 2432585..1d84426 100644 --- a/test/controllers/onlylogs/logs_controller_test.rb +++ b/test/controllers/onlylogs/logs_controller_test.rb @@ -115,5 +115,50 @@ class LogsControllerTest < ActionDispatch::IntegrationTest assert_select "[data-log-streamer-auto-scroll-value='false']" assert_select "[data-log-streamer-regexp-mode-value='true']" end + + test "index handles byte_offset param and sets search mode with byteoffset type" do + get "/onlylogs", params: {byte_offset: "50000"} + assert_response :success + # Should be in search mode with byteoffset search type + assert_select "[data-log-streamer-mode-value='search']" + assert_select "[data-log-streamer-search-type-value='byteoffset']" + assert_select "[data-log-streamer-filter-value='']" + assert_select "[data-log-streamer-auto-scroll-value='false']" + end + + test "index calculates cursor_position from byte_offset" do + get "/onlylogs", params: {byte_offset: "50000"} + assert_response :success + # cursor_position should be byte_offset - 10000 = 40000 + assert_select "[data-log-streamer-cursor-position-value='40000']" + end + + test "index sets end_position from byte_offset" do + get "/onlylogs", params: {byte_offset: "50000"} + assert_response :success + # end_position should be byte_offset + 10000 = 60000 + assert_select "[data-log-streamer-end-position-value='60000']" + end + + test "index sets search_type to filter when filter param is present" do + get "/onlylogs", params: {filter: "warning"} + assert_response :success + assert_select "[data-log-streamer-mode-value='search']" + assert_select "[data-log-streamer-search-type-value='filter']" + end + + test "index preserves autoscroll and regexp_mode params when not exploring" do + get "/onlylogs", params: {filter: "warning", autoscroll: "true", regexp_mode: "true"} + assert_response :success + assert_select "[data-log-streamer-auto-scroll-value='true']" + assert_select "[data-log-streamer-regexp-mode-value='true']" + end + + test "index byte_offset with cursor_position boundary at 0" do + get "/onlylogs", params: {byte_offset: "5000"} + assert_response :success + # cursor_position should be max(5000 - 10000, 0) = 0 + assert_select "[data-log-streamer-cursor-position-value='0']" + end end end diff --git a/test/models/onlylogs/grep_test.rb b/test/models/onlylogs/grep_test.rb index 7a8254a..1967985 100644 --- a/test/models/onlylogs/grep_test.rb +++ b/test/models/onlylogs/grep_test.rb @@ -28,31 +28,31 @@ def self.test_both_engine_modes(test_name, &block) test_both_engine_modes "it can grep for a simple string in a log file" do |engine_name| lines = Onlylogs::Grep.grep("[DEBUG]", @fixture_path) assert_equal 49, lines.length, "Failed with #{engine_name}" - assert_equal "[DEBUG] Initializing database connection - Line 2", lines.first - assert_equal "[DEBUG] Application metrics - Line 98", lines.last + assert_equal "[DEBUG] Initializing database connection - Line 2", lines.first[:content] + assert_equal "[DEBUG] Application metrics - Line 98", lines.last[:content] end test_both_engine_modes "it can grep a simple string in a log file and yield each returned line" do |engine_name| lines = [] - Onlylogs::Grep.grep("[DEBUG]", @fixture_path) do |content| - lines << content + Onlylogs::Grep.grep("[DEBUG]", @fixture_path) do |result| + lines << result end assert_equal 49, lines.length, "Failed with #{engine_name}" - assert_equal "[DEBUG] Initializing database connection - Line 2", lines.first - assert_equal "[DEBUG] Application metrics - Line 98", lines.last + assert_equal "[DEBUG] Initializing database connection - Line 2", lines.first[:content] + assert_equal "[DEBUG] Application metrics - Line 98", lines.last[:content] end test_both_engine_modes "it returns all INFO lines" do |engine_name| lines = Onlylogs::Grep.grep("[INFO]", @fixture_path) assert_equal 50, lines.length, "Failed with #{engine_name}" - assert_equal "[INFO] Application started - Line 1", lines.first - assert_equal "[INFO] Metrics collected: 150 data points - Line 99", lines.last + assert_equal "[INFO] Application started - Line 1", lines.first[:content] + assert_equal "[INFO] Metrics collected: 150 data points - Line 99", lines.last[:content] end test_both_engine_modes "it can grep a string when the line contains ansi colors" do |engine_name| expected_line = "\e[1m\e[36mActiveRecord::SchemaMigration Load (0.0ms)\e[0m \e[1m\e[34mSELECT ...\e[0m" lines = Onlylogs::Grep.grep("(0.0ms) SELECT", @special_lines_path) - assert_equal [expected_line], lines, "Failed with #{engine_name}" + assert_equal [expected_line], lines.map { |l| l[:content] }, "Failed with #{engine_name}" end test_both_engine_modes "it can grep a string with special regex characters" do |engine_name| From e58f2725808688e340e2b9116c33d0dbc596a8e8 Mon Sep 17 00:00:00 2001 From: Alessandro Rodi Date: Sat, 13 Jun 2026 22:15:46 +0200 Subject: [PATCH 2/2] Adapt byteOffset and reduce modes to static/live --- Gemfile.lock | 2 +- app/channels/onlylogs/logs_channel.rb | 40 +++++------ app/controllers/onlylogs/logs_controller.rb | 12 ++-- .../controllers/log_streamer_controller.js | 72 ++++++++++++------- app/views/onlylogs/logs/index.html.erb | 2 +- .../onlylogs/shared/_log_container.html.erb | 9 ++- .../shared/_log_container_styles.html.erb | 12 ++-- 7 files changed, 81 insertions(+), 68 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index c471a22..e26f5fc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - onlylogs (0.5.1) + onlylogs (0.5.3) rails (~> 8.0) GEM diff --git a/app/channels/onlylogs/logs_channel.rb b/app/channels/onlylogs/logs_channel.rb index daecbee..e793872 100644 --- a/app/channels/onlylogs/logs_channel.rb +++ b/app/channels/onlylogs/logs_channel.rb @@ -39,23 +39,18 @@ def initialize_watcher(data) return end - cursor_position = data["cursor_position"] || 0 filter = data["filter"].presence mode = data["mode"] || "live" - search_type = data["search_type"] regexp_mode = data["regexp_mode"] == true || data["regexp_mode"] == "true" start_position = data["start_position"]&.to_i || 0 end_position = data["end_position"]&.to_i - if mode == "search" && search_type != "byteoffset" - # For filter-based search, read the entire file with filter and send all matching lines - read_entire_file_with_filter(file_path, filter, regexp_mode, start_position, end_position) - elsif search_type == "byteoffset" - # For byteoffset search, read a fixed range - start_log_watcher(file_path, cursor_position, filter, regexp_mode, end_position) + if mode == "static" + # Read a bounded snapshot once, then finish. + read_static(file_path, filter, regexp_mode, start_position, end_position) else - # For live mode, stream indefinitely without end position - start_log_watcher(file_path, cursor_position, filter, regexp_mode) + # Follow the tail of the file indefinitely. + start_log_watcher(file_path, live_tail_position(file_path), filter, regexp_mode) end end @@ -79,13 +74,20 @@ def cleanup_existing_operations stop_log_watcher end - def start_log_watcher(file_path, cursor_position, filter = nil, regexp_mode = false, end_position = nil) + # Bytes from the end of the file to show when starting a live tail without + # an explicit cursor (matches the default whole-file live-mode page load). + LIVE_TAIL_BYTES = 10_000 + + def live_tail_position(file_path) + [::File.size(file_path) - LIVE_TAIL_BYTES, 0].max + end + + def start_log_watcher(file_path, cursor_position, filter = nil, regexp_mode = false) return if @log_watcher_running @log_watcher_running = true @filter = filter @regexp_mode = regexp_mode - @end_position = end_position transmit({action: "message", content: "Reading file. Please wait..."}) @@ -103,12 +105,6 @@ def start_log_watcher(file_path, cursor_position, filter = nil, regexp_mode = fa lines_to_send = [] new_lines.each do |log_line| - # Stop if we've reached the end position (only when expanding context) - if @end_position && @end_position > 0 && current_byte_offset >= @end_position - @log_watcher_running = false - break - end - # Filters in live mode are not yet implemented # if @filter.present? && !Onlylogs::Grep.match_line?(log_line.text, @filter, regexp_mode: @regexp_mode) # next @@ -131,8 +127,6 @@ def start_log_watcher(file_path, cursor_position, filter = nil, regexp_mode = fa Rails.logger.error e.backtrace.join("\n") ensure @log_watcher_running = false - # Send finish message if we had an end position (byteoffset mode) - transmit({action: "finish", content: "Context loaded."}) if @end_position.present? end end @@ -158,11 +152,11 @@ def stop_log_watcher @log_file = nil end - def read_entire_file_with_filter(file_path, filter = nil, regexp_mode = false, start_position = 0, end_position = nil) + def read_static(file_path, filter = nil, regexp_mode = false, start_position = 0, end_position = nil) @log_watcher_running = true @log_file = Onlylogs::File.new(file_path, last_position: 0) - transmit({action: "message", content: "Searching..."}) + transmit({action: "message", content: filter.present? ? "Searching..." : "Loading..."}) @batch_sender = BatchSender.new(self) @batch_sender.start @@ -189,7 +183,7 @@ def read_entire_file_with_filter(file_path, filter = nil, regexp_mode = false, s @batch_sender.stop # Send completion message - if line_count >= Onlylogs.max_line_matches + if Onlylogs.max_line_matches && line_count >= Onlylogs.max_line_matches transmit({action: "finish", content: "Search finished. Search results limit reached."}) else transmit({action: "finish", content: "Search finished."}) diff --git a/app/controllers/onlylogs/logs_controller.rb b/app/controllers/onlylogs/logs_controller.rb index 5e947b6..c9e4c2c 100644 --- a/app/controllers/onlylogs/logs_controller.rb +++ b/app/controllers/onlylogs/logs_controller.rb @@ -11,8 +11,7 @@ def index @filter = params[:filter] @autoscroll = params[:autoscroll] != "false" @regexp_mode = params[:regexp_mode] == "true" - @mode = @filter.blank? ? (params[:mode] || "live") : "search" # "live" or "search" - @search_type = @filter.present? ? "filter" : nil + @mode = @filter.blank? ? (params[:mode] || "live") : "static" # "live" or "static" @start_position = nil @end_position = nil @@ -36,15 +35,16 @@ def download private + EXPLORE_WINDOW_BYTES = 10_000 + def handle_byte_offset byte_offset = params[:byte_offset]&.to_i return unless byte_offset.present? - @start_position = [byte_offset - 10000, 0].max - @end_position = byte_offset + 10000 + @start_position = [byte_offset - EXPLORE_WINDOW_BYTES, 0].max + @end_position = byte_offset + EXPLORE_WINDOW_BYTES @filter = nil - @mode = "search" - @search_type = "byteoffset" + @mode = "static" @autoscroll = false end diff --git a/app/javascript/onlylogs/controllers/log_streamer_controller.js b/app/javascript/onlylogs/controllers/log_streamer_controller.js index 5ff497d..6add16e 100644 --- a/app/javascript/onlylogs/controllers/log_streamer_controller.js +++ b/app/javascript/onlylogs/controllers/log_streamer_controller.js @@ -4,13 +4,12 @@ import { createConsumer } from "@rails/actioncable"; export default class LogStreamerController extends Controller { static values = { filePath: { type: String }, - cursorPosition: { type: Number, default: 0 }, + startPosition: { type: Number, default: 0 }, endPosition: { type: Number, default: 0 }, autoScroll: { type: Boolean, default: true }, autoStart: { type: Boolean, default: true }, filter: { type: String, default: '' }, mode: { type: String, default: 'live' }, - searchType: { type: String, default: null }, regexpMode: { type: Boolean, default: false } }; @@ -103,7 +102,7 @@ export default class LogStreamerController extends Controller { if (this.isLiveMode()) { this.liveModeTarget.checked = false; - this.modeValue = 'search'; + this.modeValue = 'static'; this.updateLiveModeState(); this.stop(); } @@ -135,18 +134,19 @@ export default class LogStreamerController extends Controller { applyFilter() { const filterValue = this.filterInputTarget.value; - // If filter is applied, disable live mode and set search type + // A filter switches to static mode; an empty filter goes back to live. if (filterValue && filterValue.trim() !== '') { this.liveModeTarget.checked = false; - this.modeValue = 'search'; - this.searchTypeValue = 'filter'; + this.modeValue = 'static'; } else { - // If no filter, enable live mode this.liveModeTarget.checked = true; this.modeValue = 'live'; - this.searchTypeValue = null; } + // Applying a filter searches the whole file, so drop any explore window. + this.startPositionValue = 0; + this.endPositionValue = 0; + // Update visual state this.updateLiveModeState(); this.updateStopButtonVisibility(); @@ -185,11 +185,11 @@ export default class LogStreamerController extends Controller { } clearFilter() { - // Clear filter, byte_offset, and search type to go back to pure live mode + // Clear filter and explore window to go back to pure live mode this.filterInputTarget.value = ''; this.byteOffsetInputTarget.value = ''; this.modeValue = 'live'; - this.searchTypeValue = null; + this.startPositionValue = 0; this.endPositionValue = 0; this.contextLineHighlighted = false; @@ -263,11 +263,19 @@ export default class LogStreamerController extends Controller { .find(pre => Number(pre.dataset.byteOffset) === target || Math.abs(Number(pre.dataset.byteOffset) - target) < 1000); if (closestPre) { - closestPre.scrollIntoView({ behavior: 'smooth', block: 'center' }); + this.#scrollVerticallyToCenter(closestPre); } } } + #scrollVerticallyToCenter(element) { + const container = this.logLinesTarget; + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + const delta = (elementRect.top - containerRect.top) - (container.clientHeight / 2) + (elementRect.height / 2); + container.scrollBy({ top: delta, behavior: 'smooth' }); + } + #applyContextLineHighlight(target) { const closestPre = [...this.logLinesTarget.querySelectorAll('pre[data-byte-offset]')] .reduce((closest, pre) => { @@ -277,11 +285,23 @@ export default class LogStreamerController extends Controller { if (!closestPre) return; - [closestPre.previousElementSibling, closestPre, closestPre.nextElementSibling] + const row = this.#rowElement(closestPre); + [row.previousElementSibling, row, row.nextElementSibling] .filter(Boolean) .forEach(line => line.classList.add('highlighted-context-line')); } + // A row is either a bare
 or an expand-button wrapper 
directly + // under the clusterize content area. Walk up to that top-level element so the + // highlight covers the whole line, including the "+" toggle. + #rowElement(element) { + let node = element; + while (node.parentElement && !node.parentElement.classList.contains('clusterize-content')) { + node = node.parentElement; + } + return node; + } + updateLiveModeState() { const liveModeLabel = this.liveModeTarget.closest('label'); const hasFilter = this.filterInputTarget.value && this.filterInputTarget.value.trim() !== ''; @@ -294,7 +314,7 @@ export default class LogStreamerController extends Controller { } updateStopButtonVisibility() { - const shouldShow = this.modeValue === 'search' && this.subscription && this.isRunning && !this.isSearchFinished; + const shouldShow = this.modeValue === 'static' && this.subscription && this.isRunning && !this.isSearchFinished; this.stopButtonTarget.style.display = shouldShow ? 'inline-block' : 'none'; } @@ -336,21 +356,19 @@ export default class LogStreamerController extends Controller { */ #handleConnected() { const data = { - cursor_position: this.cursorPositionValue, file_path: this.filePathValue, filter: this.filterInputTarget.value, mode: this.modeValue, regexp_mode: this.regexpModeValue }; - // Only send end_position if it's set (when expanding context around a search result) - if (this.endPositionValue > 0) { - data.end_position = this.endPositionValue; - } - - // Send search_type if it's set - if (this.searchTypeValue) { - data.search_type = this.searchTypeValue; + if (this.modeValue === 'static') { + // A byte-offset explore window reads a bounded range. Without a window + // (start/end), a static read is a whole-file filter search. + if (this.endPositionValue > 0) { + data.start_position = this.startPositionValue; + data.end_position = this.endPositionValue; + } } this.subscription.perform('initialize_watcher', data); @@ -490,7 +508,6 @@ export default class LogStreamerController extends Controller { return { isRunning: this.isRunning, filePath: this.filePathValue, - cursorPosition: this.cursorPositionValue, lineCount: this.clusterize.getRowsAmount(), connected: this.subscription && this.subscription.identifier }; @@ -513,10 +530,11 @@ export default class LogStreamerController extends Controller { // Optional: handle cluster change }, clusterChanged: () => { - // Re-apply highlighting when cluster changes (for virtual scrolling) - // Only highlight if we're in byteoffset mode - if (this.searchTypeValue === 'byteoffset') { - const target = Number(new URLSearchParams(window.location.search).get('byte_offset')); + // Re-apply highlighting when cluster changes (for virtual scrolling). + // The byte_offset URL param is the highlight anchor for an explore window. + const params = new URLSearchParams(window.location.search); + if (params.has('byte_offset')) { + const target = Number(params.get('byte_offset')); if (!Number.isNaN(target)) { this.#applyContextLineHighlight(target); } diff --git a/app/views/onlylogs/logs/index.html.erb b/app/views/onlylogs/logs/index.html.erb index e35b362..3bf5498 100644 --- a/app/views/onlylogs/logs/index.html.erb +++ b/app/views/onlylogs/logs/index.html.erb @@ -36,5 +36,5 @@ <% end %> <% end %>
- <%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path, tail: @max_lines, filter: @filter, autoscroll: @autoscroll, regexp_mode: @regexp_mode, cursor_position: @cursor_position, start_position: @start_position, end_position: @end_position, mode: @mode, search_type: @search_type } %> + <%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path, tail: @max_lines, filter: @filter, autoscroll: @autoscroll, regexp_mode: @regexp_mode, start_position: @start_position, end_position: @end_position, mode: @mode } %>
diff --git a/app/views/onlylogs/shared/_log_container.html.erb b/app/views/onlylogs/shared/_log_container.html.erb index f6f79ca..0a99220 100644 --- a/app/views/onlylogs/shared/_log_container.html.erb +++ b/app/views/onlylogs/shared/_log_container.html.erb @@ -1,11 +1,11 @@ -<%# locals: (log_file_path:, tail: 100, filter: "", autoscroll: true, regexp_mode: false, cursor_position: nil, start_position: nil, end_position: nil, mode: nil, search_type: nil) %> +<%# locals: (log_file_path:, tail: 100, filter: "", autoscroll: true, regexp_mode: false, start_position: nil, end_position: nil, mode: nil) %> <%= render "onlylogs/shared/log_container_styles" %> <% - mode = local_assigns[:mode] || (filter.blank? ? "live" : "search") - cursor_position = local_assigns[:start_position] || local_assigns[:cursor_position] || (mode == "search" ? 0 : [File.size(log_file_path) - (tail * 100), 0].max) + mode = local_assigns[:mode] || (filter.blank? ? "live" : "static") + start_position = local_assigns[:start_position] end_position = local_assigns[:end_position] byte_offset = request.params[:byte_offset] @@ -16,14 +16,13 @@
data-log-streamer-start-position-value="<%= start_position %>"<% end %> <% if end_position.present? %>data-log-streamer-end-position-value="<%= end_position %>"<% end %> data-log-streamer-filter-value="<%= filter %>" data-log-streamer-auto-scroll-value="<%= autoscroll %>" data-log-streamer-regexp-mode-value="<%= regexp_mode %>" data-log-streamer-mode-value="<%= mode %>" data-action="text-selection:start->log-streamer#pauseForSelection" - <% if search_type.present? %>data-log-streamer-search-type-value="<%= search_type %>"<% end %> class="onlylogs-log-container" >
diff --git a/app/views/onlylogs/shared/_log_container_styles.html.erb b/app/views/onlylogs/shared/_log_container_styles.html.erb index 9628bb0..40fb36d 100644 --- a/app/views/onlylogs/shared/_log_container_styles.html.erb +++ b/app/views/onlylogs/shared/_log_container_styles.html.erb @@ -24,7 +24,7 @@ } } - &[data-log-streamer-mode-value="search"] .onlylogs-expand-btn { + .onlylogs-expand-btn { cursor: pointer; padding: 0 0.5rem; margin-right: 0.25rem; @@ -91,13 +91,15 @@ pre { margin: 0 !important; - padding: 0.2rem; + padding: 0 0.2rem; + line-height: var(--onlylogs-line-height, 1.6rem); + min-height: var(--onlylogs-line-height, 1.6rem); word-break: break-word; /* allow breaking long tokens like UUIDs/SQL */ white-space: pre; + } - &.highlighted-context-line { - background-color: rgba(255, 193, 7, 0.3); - } + .highlighted-context-line { + background-color: rgba(255, 193, 7, 0.3); } a {