onlylogs 0.11.0 → 0.13.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +251 -77
  3. data/app/assets/images/onlylogs/favicon/apple-touch-icon.png +0 -0
  4. data/app/assets/images/onlylogs/favicon/favicon-96x96.png +0 -0
  5. data/app/assets/images/onlylogs/favicon/favicon.ico +0 -0
  6. data/app/assets/images/onlylogs/favicon/favicon.svg +1 -3
  7. data/app/assets/images/onlylogs/favicon/web-app-manifest-192x192.png +0 -0
  8. data/app/assets/images/onlylogs/favicon/web-app-manifest-512x512.png +0 -0
  9. data/app/assets/images/onlylogs/logo.png +0 -0
  10. data/app/assets/javascripts/onlylogs/clusterize.js +26 -1
  11. data/app/channels/onlylogs/logs_channel.rb +14 -9
  12. data/app/controllers/concerns/onlylogs/authentication.rb +26 -0
  13. data/app/controllers/onlylogs/application_controller.rb +1 -18
  14. data/app/controllers/onlylogs/queries_controller.rb +72 -0
  15. data/app/controllers/onlylogs/viewer_controller.rb +7 -0
  16. data/app/javascript/onlylogs/controllers/keyboard_shortcuts_controller.js +131 -21
  17. data/app/javascript/onlylogs/controllers/log_cursor_controller.js +286 -0
  18. data/app/javascript/onlylogs/controllers/log_streamer_controller.js +42 -2
  19. data/app/javascript/onlylogs/controllers/match_highlight_controller.js +82 -0
  20. data/app/javascript/onlylogs/controllers/queries_controller.js +470 -0
  21. data/app/javascript/onlylogs/controllers/range_slider_controller.js +5 -4
  22. data/app/javascript/onlylogs/controllers/text_selection_controller.js +136 -15
  23. data/app/models/onlylogs/file.rb +14 -5
  24. data/app/models/onlylogs/grep.rb +31 -12
  25. data/app/models/onlylogs/queries_record.rb +30 -0
  26. data/app/models/onlylogs/query.rb +29 -0
  27. data/app/views/onlylogs/logs/index.html.erb +1 -1
  28. data/app/views/onlylogs/shared/_log_container.html.erb +165 -27
  29. data/app/views/onlylogs/shared/_log_container_styles.html.erb +729 -44
  30. data/app/views/onlylogs/shared/_shortcuts_dialog.html.erb +62 -0
  31. data/bin/super_grep +7 -0
  32. data/bin/super_ripgrep +14 -0
  33. data/config/routes.rb +1 -0
  34. data/lib/onlylogs/configuration.rb +14 -1
  35. data/lib/onlylogs/engine.rb +9 -0
  36. data/lib/onlylogs/http_device.rb +28 -13
  37. data/lib/onlylogs/version.rb +1 -1
  38. data/lib/onlylogs.rb +3 -0
  39. metadata +38 -2
  40. data/app/jobs/onlylogs/application_job.rb +0 -4
@@ -6,12 +6,16 @@ export default class TextSelectionController extends Controller {
6
6
  connect() {
7
7
  this.boundHandleTextSelection = this.handleTextSelection.bind(this)
8
8
  this.boundHandleDocumentClick = this.handleDocumentClick.bind(this)
9
+ this.boundHandleDocumentMouseDown = this.handleDocumentMouseDown.bind(this)
9
10
  this.boundHandleSelectionChange = this.handleSelectionChange.bind(this)
10
11
  this.boundHandleMouseDown = this.handleMouseDown.bind(this)
12
+ this.boundPlaceButton = this.placeButton.bind(this)
11
13
 
12
14
  // Only listen for text selection on the log lines, not the toolbar
13
15
  this.logLinesTarget.addEventListener('mousedown', this.boundHandleMouseDown)
14
16
  this.logLinesTarget.addEventListener('mouseup', this.boundHandleTextSelection)
17
+ this.logLinesTarget.addEventListener('scroll', this.boundPlaceButton)
18
+ document.addEventListener('mousedown', this.boundHandleDocumentMouseDown)
15
19
  document.addEventListener('click', this.boundHandleDocumentClick)
16
20
  document.addEventListener('selectionchange', this.boundHandleSelectionChange)
17
21
  }
@@ -19,43 +23,80 @@ export default class TextSelectionController extends Controller {
19
23
  disconnect() {
20
24
  this.logLinesTarget.removeEventListener('mousedown', this.boundHandleMouseDown)
21
25
  this.logLinesTarget.removeEventListener('mouseup', this.boundHandleTextSelection)
26
+ this.logLinesTarget.removeEventListener('scroll', this.boundPlaceButton)
27
+ document.removeEventListener('mousedown', this.boundHandleDocumentMouseDown)
22
28
  document.removeEventListener('click', this.boundHandleDocumentClick)
23
29
  document.removeEventListener('selectionchange', this.boundHandleSelectionChange)
24
30
  }
25
31
 
26
- handleMouseDown() {
32
+ handleMouseDown(event) {
33
+ if (this.#onScrollbar(event)) return
34
+
27
35
  this.dispatch("start")
28
36
  }
29
37
 
38
+ handleDocumentMouseDown(event) {
39
+ if (this.buttonTarget.contains(event.target) || this.#onScrollbar(event)) return
40
+
41
+ this.saved = null
42
+ }
43
+
30
44
  handleTextSelection(event) {
31
45
  const selection = window.getSelection()
32
46
  const selectedText = selection.toString().trim()
33
-
47
+
34
48
  // Check if there's actually a text selection
35
49
  if (selectedText.length > 0 && selection.rangeCount > 0) {
36
- this.showButton(event, selectedText)
50
+ this.showButton(event.clientX, event.clientY, selectedText)
37
51
  } else {
38
52
  this.hideButton()
39
53
  }
40
54
  }
41
55
 
42
- showButton(event, selectedText) {
56
+ // Bound to log-cursor:select, a selection made with the keyboard.
57
+ showForSelection() {
58
+ const selection = window.getSelection()
59
+ const selectedText = selection.toString().trim()
60
+ if (selectedText.length === 0 || selection.rangeCount === 0) return
61
+
62
+ this.saved = null
63
+ const rect = selection.getRangeAt(0).getBoundingClientRect()
64
+ this.showButton(rect.left + 40, rect.bottom, selectedText)
65
+ }
66
+
67
+ showButton(clientX, clientY, selectedText) {
43
68
  if (!this.buttonTarget) {
44
69
  return
45
70
  }
46
-
71
+
47
72
  const containerRect = this.element.getBoundingClientRect()
48
-
73
+
49
74
  // Position button below mouse cursor
50
- const left = event.clientX - containerRect.left - 40 // Roughly center the button
51
- const top = event.clientY - containerRect.top + 10
52
-
53
- this.buttonTarget.style.left = Math.max(0, left) + 'px'
54
- this.buttonTarget.style.top = Math.max(0, top) + 'px'
55
- this.buttonTarget.style.display = 'block'
56
-
75
+ this.buttonPosition = {
76
+ left: clientX - containerRect.left - 40, // Roughly center the button
77
+ top: clientY - containerRect.top + 10,
78
+ scrollLeft: this.logLinesTarget.scrollLeft,
79
+ scrollTop: this.logLinesTarget.scrollTop
80
+ }
81
+
57
82
  // Store selected text for search
58
83
  this.selectedText = selectedText
84
+ this.placeButton()
85
+ }
86
+
87
+ placeButton() {
88
+ if (!this.buttonPosition || !this.selectedText) return
89
+
90
+ const lines = this.logLinesTarget
91
+ const left = this.buttonPosition.left - (lines.scrollLeft - this.buttonPosition.scrollLeft)
92
+ const top = this.buttonPosition.top - (lines.scrollTop - this.buttonPosition.scrollTop)
93
+
94
+ const linesTop = lines.getBoundingClientRect().top - this.element.getBoundingClientRect().top
95
+ const visible = top >= linesTop && top <= linesTop + lines.clientHeight - this.buttonTarget.offsetHeight
96
+
97
+ this.buttonTarget.style.left = Math.max(0, left) + 'px'
98
+ this.buttonTarget.style.top = Math.max(0, top) + 'px'
99
+ this.buttonTarget.style.display = visible ? 'block' : 'none'
59
100
  }
60
101
 
61
102
  hideButton() {
@@ -63,6 +104,35 @@ export default class TextSelectionController extends Controller {
63
104
  this.selectedText = null
64
105
  }
65
106
 
107
+ saveSelection({ detail: { start } }) {
108
+ const selection = window.getSelection()
109
+ if (!this.#selectionInLines(selection)) return
110
+
111
+ const anchor = this.#positionOf(selection.anchorNode, selection.anchorOffset, start)
112
+ const focus = this.#positionOf(selection.focusNode, selection.focusOffset, start)
113
+ if (anchor && focus) this.saved = { anchor, focus }
114
+ }
115
+
116
+ restoreSelection({ detail: { start } }) {
117
+ if (!this.saved) return
118
+
119
+ const selection = window.getSelection()
120
+ if (this.#selectionInLines(selection)) return
121
+
122
+ const anchor = this.#pointIn(this.saved.anchor, start)
123
+ const focus = this.#pointIn(this.saved.focus, start)
124
+ if (!anchor || !focus) return
125
+
126
+ selection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset)
127
+ this.selectedText = selection.toString().trim()
128
+ this.placeButton()
129
+ }
130
+
131
+ forgetSelection() {
132
+ this.saved = null
133
+ this.hideButton()
134
+ }
135
+
66
136
  handleDocumentClick(event) {
67
137
  // Only hide if clicking outside the button and outside the log lines container
68
138
  if (!this.buttonTarget.contains(event.target) && !this.logLinesTarget.contains(event.target)) {
@@ -74,7 +144,7 @@ export default class TextSelectionController extends Controller {
74
144
  // Check if the current selection is still valid
75
145
  const selection = window.getSelection()
76
146
  const selectedText = selection.toString().trim()
77
-
147
+
78
148
  // If no text is selected, hide the button
79
149
  if (selectedText.length === 0 || selection.rangeCount === 0) {
80
150
  this.hideButton()
@@ -87,12 +157,63 @@ export default class TextSelectionController extends Controller {
87
157
  this.regexpModeTarget.checked = false
88
158
  this.regexpModeTarget.dispatchEvent(new Event('change', { bubbles: true }))
89
159
  }
90
-
160
+
91
161
  this.filterInputTarget.value = this.selectedText
92
162
  this.filterInputTarget.dispatchEvent(new Event('input', { bubbles: true }))
93
163
  this.dispatch("search")
94
164
  this.hideButton()
165
+ this.saved = null
95
166
  window.getSelection().removeAllRanges()
96
167
  }
97
168
  }
169
+
170
+ #onScrollbar(event) {
171
+ const lines = this.logLinesTarget
172
+ if (event.target !== lines) return false
173
+
174
+ const rect = lines.getBoundingClientRect()
175
+ return event.clientX - rect.left >= lines.clientWidth || event.clientY - rect.top >= lines.clientHeight
176
+ }
177
+
178
+ #selectionInLines(selection) {
179
+ return !selection.isCollapsed && this.#content.contains(selection.anchorNode) && this.#content.contains(selection.focusNode)
180
+ }
181
+
182
+ #positionOf(node, offset, start) {
183
+ const row = this.#rowFor(node)
184
+ if (!row) return null
185
+
186
+ const range = document.createRange()
187
+ range.setStart(row, 0)
188
+ range.setEnd(node, offset)
189
+ return { row: start + this.#renderedRows().indexOf(row), offset: range.toString().length }
190
+ }
191
+
192
+ #pointIn(position, start) {
193
+ const row = this.#renderedRows()[position.row - start]
194
+ if (!row) return null
195
+
196
+ const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT)
197
+ let remaining = position.offset
198
+ let last = null
199
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
200
+ if (remaining <= node.length) return { node, offset: remaining }
201
+ remaining -= node.length
202
+ last = node
203
+ }
204
+ return last ? { node: last, offset: last.length } : { node: row, offset: 0 }
205
+ }
206
+
207
+ #rowFor(node) {
208
+ while (node && node.parentNode !== this.#content) node = node.parentNode
209
+ return node && !node.classList.contains("clusterize-extra-row") ? node : null
210
+ }
211
+
212
+ #renderedRows() {
213
+ return [...this.#content.children].filter(row => !row.classList.contains("clusterize-extra-row"))
214
+ }
215
+
216
+ get #content() {
217
+ return this.logLinesTarget.querySelector(".clusterize-content")
218
+ }
98
219
  }
@@ -1,6 +1,4 @@
1
1
  module Onlylogs
2
- class Error < StandardError; end
3
-
4
2
  class File
5
3
  attr_reader :path, :last_position
6
4
 
@@ -11,6 +9,16 @@ module Onlylogs
11
9
  @inode = ::File.stat(path).ino
12
10
  end
13
11
 
12
+ QUERIES_DATABASE = "queries.sqlite3"
13
+
14
+ # The saved queries of this file's directory, shared by all log files in it, newest first.
15
+ def queries = QueriesRecord.bound_to(queries_database_path) { Query.newest_first }
16
+
17
+ # Connects Query to the database of this file's directory.
18
+ def with_database(create: true, &) = QueriesRecord.with_database(queries_database_path, create: create, &)
19
+
20
+ def queries_database_path = ::File.join(::File.dirname(path), Onlylogs.configuration.database_dir, QUERIES_DATABASE)
21
+
14
22
  def go_to_position(position)
15
23
  return if position < 0
16
24
 
@@ -68,10 +76,11 @@ module Onlylogs
68
76
 
69
77
  # +timeout+ is in seconds and defaults to nil, meaning unbounded. See
70
78
  # Grep.grep.
71
- def grep(filter, regexp_mode: false, start_position: 0, end_position: nil,
79
+ def grep(filter, regexp_mode: false, case_sensitive: true, start_position: 0, end_position: nil,
72
80
  max_matches: Onlylogs.max_line_matches, timeout: nil, &block)
73
- Grep.grep(filter, path, regexp_mode: regexp_mode, start_position: start_position,
74
- end_position: end_position, max_matches: max_matches, timeout: timeout) do |content|
81
+ Grep.grep(filter, path, regexp_mode: regexp_mode, case_sensitive: case_sensitive,
82
+ start_position: start_position, end_position: end_position, max_matches: max_matches,
83
+ timeout: timeout) do |content|
75
84
  yield content
76
85
  end
77
86
  end
@@ -12,36 +12,46 @@ module Onlylogs
12
12
  # is deliberately not a number, because only the caller knows how long it
13
13
  # can afford to hold the thread it runs on. Anything serving a request
14
14
  # should pass one.
15
+ # +context+ asks for that many lines before and after each match, yielded with
16
+ # <tt>context: true</tt>. It needs ripgrep: plain grep is run without byte
17
+ # offsets, so its context lines could not be told apart from matches, and
18
+ # +context+ is ignored there. Context never reaches outside the byte window.
15
19
  def self.grep(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false,
16
- max_matches: Onlylogs.max_line_matches, timeout: nil, &block)
20
+ case_sensitive: true, max_matches: Onlylogs.max_line_matches, context: 0, timeout: nil, &block)
21
+ context = 0 unless Onlylogs.ripgrep_enabled?
17
22
  command_args = search_command(pattern, file_path, start_position: start_position,
18
- end_position: end_position, regexp_mode: regexp_mode, max_matches: max_matches)
23
+ end_position: end_position, regexp_mode: regexp_mode, case_sensitive: case_sensitive,
24
+ max_matches: max_matches, context: context)
19
25
 
20
26
  results = []
21
27
 
22
- # Set up parsing logic based on whether ripgrep includes byte offsets
28
+ # ripgrep prints "offset:line" for a match and "offset-line" for context
23
29
  parse_line = if Onlylogs.ripgrep_enabled?
24
30
  ->(line) {
25
- parts = line.split(":", 2)
26
- [parts[0].to_i + start_position, parts[1] || ""]
31
+ offset, separator, content = line.partition(/[:-]/)
32
+ [offset.to_i + start_position, content, separator == "-"]
27
33
  }
28
34
  else
29
- ->(line) { [nil, line] }
35
+ ->(line) { [nil, line, false] }
30
36
  end
31
37
 
32
38
  matches = 0
33
39
 
34
40
  ActiveSupport::Notifications.instrument("search.onlylogs", file_path: file_path,
35
- query: pattern, regexp: regexp_mode, start_position: start_position,
41
+ query: pattern, regexp: regexp_mode, case_sensitive: case_sensitive, start_position: start_position,
36
42
  end_position: end_position, max_matches: max_matches) do |payload|
37
43
  each_output_line(command_args, timeout: timeout) do |line|
38
- byte_offset, content = parse_line.call(line.chomp)
44
+ line = line.chomp
45
+ next if context.positive? && line == GROUP_SEPARATOR
46
+
47
+ byte_offset, content, context_line = parse_line.call(line)
39
48
 
40
49
  # Use String.new to create a copy and prevent memory retention from IO buffers
41
50
  content = String.new(content, encoding: Encoding::UTF_8).scrub
42
51
 
43
52
  result = {byte_offset: byte_offset, content: content}
44
- matches += 1
53
+ result[:context] = true if context_line
54
+ matches += 1 unless context_line
45
55
 
46
56
  if block_given?
47
57
  yield result
@@ -61,14 +71,21 @@ module Onlylogs
61
71
  block_given? ? nil : results
62
72
  end
63
73
 
74
+ GROUP_SEPARATOR = "--"
75
+
76
+ # The scripts run under LC_ALL=C. ripgrep folds case over Unicode anyway,
77
+ # grep then folds ASCII only: "error" finds "ERROR" with both engines, but
78
+ # "über" finds "Über" with ripgrep only.
64
79
  def self.search_command(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false,
65
- max_matches: Onlylogs.max_line_matches)
80
+ case_sensitive: true, max_matches: Onlylogs.max_line_matches, context: 0)
66
81
  script_name = Onlylogs.ripgrep_enabled? ? "super_ripgrep" : "super_grep"
67
82
  super_grep_path = ::File.expand_path("../../../bin/#{script_name}", __dir__)
68
83
 
69
84
  command_args = [super_grep_path]
70
85
  command_args += ["--max-matches", max_matches.to_s] if max_matches.present?
86
+ command_args += ["--context", context.to_s] if context.positive?
71
87
  command_args << "--regexp" if regexp_mode
88
+ command_args << "--case-insensitive" unless case_sensitive
72
89
 
73
90
  # Add byte range parameters if specified
74
91
  if start_position > 0 || end_position
@@ -170,14 +187,16 @@ module Onlylogs
170
187
 
171
188
  MATCH_TIMEOUT = 0.1 # seconds
172
189
 
173
- def self.match_line?(line, string, regexp_mode: false)
190
+ # Folds case over Unicode, like ripgrep. See search_command for grep.
191
+ def self.match_line?(line, string, regexp_mode: false, case_sensitive: true)
174
192
  # Strip ANSI color codes from the line before matching
175
193
  stripped_line = line.gsub(/\e\[[0-9;]*m/, "")
176
194
  # Normalize multiple spaces to single spaces
177
195
  normalized_line = stripped_line.gsub(/\s+/, " ")
178
196
 
179
197
  pattern = regexp_mode ? string : Regexp.escape(string)
180
- normalized_line.match?(Regexp.new(pattern, timeout: MATCH_TIMEOUT))
198
+ options = case_sensitive ? nil : Regexp::IGNORECASE
199
+ normalized_line.match?(Regexp.new(pattern, options, timeout: MATCH_TIMEOUT))
181
200
  rescue ::RegexpError
182
201
  false
183
202
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite_shards"
4
+
5
+ module Onlylogs
6
+ # Saved queries live in a database kept in each log directory, shared by all log files in it:
7
+ # see File#with_database.
8
+ class QueriesRecord < ActiveRecord::Base
9
+ self.abstract_class = true
10
+ include SqliteShards
11
+
12
+ database_migrations(
13
+ ->(connection) {
14
+ connection.create_table(:queries, if_not_exists: true) do |t|
15
+ t.string :name, null: false
16
+ t.string :filter, null: false, default: ""
17
+ t.boolean :regexp_mode, null: false, default: false
18
+ t.timestamps
19
+
20
+ t.index "LOWER(name)", unique: true, name: "index_queries_on_lower_name"
21
+ end
22
+ },
23
+ ->(connection) {
24
+ unless connection.column_exists?(:queries, :case_insensitive)
25
+ connection.add_column(:queries, :case_insensitive, :boolean, null: false, default: false)
26
+ end
27
+ }
28
+ )
29
+ end
30
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onlylogs
4
+ class Query < QueriesRecord
5
+ self.table_name = "queries"
6
+
7
+ scope :newest_first, -> { order(created_at: :desc, id: :desc) }
8
+
9
+ normalizes :name, with: ->(name) { name.strip }
10
+
11
+ normalizes :filter, with: ->(filter) { filter.to_s }, apply_to_nil: true
12
+ normalizes :regexp_mode, with: ->(regexp_mode) { regexp_mode || false }, apply_to_nil: true
13
+ normalizes :case_insensitive, with: ->(case_insensitive) { case_insensitive || false }, apply_to_nil: true
14
+
15
+ validates :name, presence: true, length: {maximum: 255}, uniqueness: {case_sensitive: false}
16
+ validate :filter_must_be_a_valid_regexp
17
+
18
+ private
19
+
20
+ def filter_must_be_a_valid_regexp
21
+ return unless regexp_mode?
22
+ return if filter.blank?
23
+
24
+ Regexp.new(filter)
25
+ rescue RegexpError => e
26
+ errors.add(:filter, "is not a valid regexp: #{e.message}")
27
+ end
28
+ end
29
+ end
@@ -42,7 +42,7 @@
42
42
  <div class="grid">
43
43
  <div class="grid-header">
44
44
  <% if @available_log_files.any? %>
45
- <%= form_with url: root_path, method: :get, local: true, class: "log-file-dropdown", data: { controller: "auto-submit" } do %>
45
+ <%= form_with url: root_path, method: :get, local: true, class: "log-file-dropdown", data: { controller: "auto-submit", onlylogs_shortcut: "o" } do %>
46
46
  <select id="log_file_path" name="log_file_path" data-action="change->auto-submit#submit">
47
47
  <% @available_log_files.each do |file| %>
48
48
  <option value="<%= Onlylogs::SecureFilePath.encrypt(file) %>" <%= "selected" if file.to_s == @log_file_path.to_s %>>