onlylogs 0.11.0 → 0.12.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 (38) 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 +70 -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 +13 -6
  25. data/app/models/onlylogs/queries_record.rb +28 -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 +5 -0
  33. data/config/routes.rb +1 -0
  34. data/lib/onlylogs/configuration.rb +14 -1
  35. data/lib/onlylogs/http_device.rb +28 -13
  36. data/lib/onlylogs/version.rb +1 -1
  37. data/lib/onlylogs.rb +4 -0
  38. metadata +38 -1
@@ -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
@@ -13,9 +13,10 @@ module Onlylogs
13
13
  # can afford to hold the thread it runs on. Anything serving a request
14
14
  # should pass one.
15
15
  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)
16
+ case_sensitive: true, max_matches: Onlylogs.max_line_matches, timeout: nil, &block)
17
17
  command_args = search_command(pattern, file_path, start_position: start_position,
18
- end_position: end_position, regexp_mode: regexp_mode, max_matches: max_matches)
18
+ end_position: end_position, regexp_mode: regexp_mode, case_sensitive: case_sensitive,
19
+ max_matches: max_matches)
19
20
 
20
21
  results = []
21
22
 
@@ -32,7 +33,7 @@ module Onlylogs
32
33
  matches = 0
33
34
 
34
35
  ActiveSupport::Notifications.instrument("search.onlylogs", file_path: file_path,
35
- query: pattern, regexp: regexp_mode, start_position: start_position,
36
+ query: pattern, regexp: regexp_mode, case_sensitive: case_sensitive, start_position: start_position,
36
37
  end_position: end_position, max_matches: max_matches) do |payload|
37
38
  each_output_line(command_args, timeout: timeout) do |line|
38
39
  byte_offset, content = parse_line.call(line.chomp)
@@ -61,14 +62,18 @@ module Onlylogs
61
62
  block_given? ? nil : results
62
63
  end
63
64
 
65
+ # The scripts run under LC_ALL=C. ripgrep folds case over Unicode anyway,
66
+ # grep then folds ASCII only: "error" finds "ERROR" with both engines, but
67
+ # "über" finds "Über" with ripgrep only.
64
68
  def self.search_command(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false,
65
- max_matches: Onlylogs.max_line_matches)
69
+ case_sensitive: true, max_matches: Onlylogs.max_line_matches)
66
70
  script_name = Onlylogs.ripgrep_enabled? ? "super_ripgrep" : "super_grep"
67
71
  super_grep_path = ::File.expand_path("../../../bin/#{script_name}", __dir__)
68
72
 
69
73
  command_args = [super_grep_path]
70
74
  command_args += ["--max-matches", max_matches.to_s] if max_matches.present?
71
75
  command_args << "--regexp" if regexp_mode
76
+ command_args << "--case-insensitive" unless case_sensitive
72
77
 
73
78
  # Add byte range parameters if specified
74
79
  if start_position > 0 || end_position
@@ -170,14 +175,16 @@ module Onlylogs
170
175
 
171
176
  MATCH_TIMEOUT = 0.1 # seconds
172
177
 
173
- def self.match_line?(line, string, regexp_mode: false)
178
+ # Folds case over Unicode, like ripgrep. See search_command for grep.
179
+ def self.match_line?(line, string, regexp_mode: false, case_sensitive: true)
174
180
  # Strip ANSI color codes from the line before matching
175
181
  stripped_line = line.gsub(/\e\[[0-9;]*m/, "")
176
182
  # Normalize multiple spaces to single spaces
177
183
  normalized_line = stripped_line.gsub(/\s+/, " ")
178
184
 
179
185
  pattern = regexp_mode ? string : Regexp.escape(string)
180
- normalized_line.match?(Regexp.new(pattern, timeout: MATCH_TIMEOUT))
186
+ options = case_sensitive ? nil : Regexp::IGNORECASE
187
+ normalized_line.match?(Regexp.new(pattern, options, timeout: MATCH_TIMEOUT))
181
188
  rescue ::RegexpError
182
189
  false
183
190
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onlylogs
4
+ # Saved queries live in a database kept in each log directory, shared by all log files in it:
5
+ # see File#with_database.
6
+ class QueriesRecord < ActiveRecord::Base
7
+ self.abstract_class = true
8
+ include SqliteShards
9
+
10
+ database_migrations(
11
+ ->(connection) {
12
+ connection.create_table(:queries, if_not_exists: true) do |t|
13
+ t.string :name, null: false
14
+ t.string :filter, null: false, default: ""
15
+ t.boolean :regexp_mode, null: false, default: false
16
+ t.timestamps
17
+
18
+ t.index "LOWER(name)", unique: true, name: "index_queries_on_lower_name"
19
+ end
20
+ },
21
+ ->(connection) {
22
+ unless connection.column_exists?(:queries, :case_insensitive)
23
+ connection.add_column(:queries, :case_insensitive, :boolean, null: false, default: false)
24
+ end
25
+ }
26
+ )
27
+ end
28
+ 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 %>>