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.
- checksums.yaml +4 -4
- data/README.md +251 -77
- data/app/assets/images/onlylogs/favicon/apple-touch-icon.png +0 -0
- data/app/assets/images/onlylogs/favicon/favicon-96x96.png +0 -0
- data/app/assets/images/onlylogs/favicon/favicon.ico +0 -0
- data/app/assets/images/onlylogs/favicon/favicon.svg +1 -3
- data/app/assets/images/onlylogs/favicon/web-app-manifest-192x192.png +0 -0
- data/app/assets/images/onlylogs/favicon/web-app-manifest-512x512.png +0 -0
- data/app/assets/images/onlylogs/logo.png +0 -0
- data/app/assets/javascripts/onlylogs/clusterize.js +26 -1
- data/app/channels/onlylogs/logs_channel.rb +14 -9
- data/app/controllers/concerns/onlylogs/authentication.rb +26 -0
- data/app/controllers/onlylogs/application_controller.rb +1 -18
- data/app/controllers/onlylogs/queries_controller.rb +72 -0
- data/app/controllers/onlylogs/viewer_controller.rb +7 -0
- data/app/javascript/onlylogs/controllers/keyboard_shortcuts_controller.js +131 -21
- data/app/javascript/onlylogs/controllers/log_cursor_controller.js +286 -0
- data/app/javascript/onlylogs/controllers/log_streamer_controller.js +42 -2
- data/app/javascript/onlylogs/controllers/match_highlight_controller.js +82 -0
- data/app/javascript/onlylogs/controllers/queries_controller.js +470 -0
- data/app/javascript/onlylogs/controllers/range_slider_controller.js +5 -4
- data/app/javascript/onlylogs/controllers/text_selection_controller.js +136 -15
- data/app/models/onlylogs/file.rb +14 -5
- data/app/models/onlylogs/grep.rb +31 -12
- data/app/models/onlylogs/queries_record.rb +30 -0
- data/app/models/onlylogs/query.rb +29 -0
- data/app/views/onlylogs/logs/index.html.erb +1 -1
- data/app/views/onlylogs/shared/_log_container.html.erb +165 -27
- data/app/views/onlylogs/shared/_log_container_styles.html.erb +729 -44
- data/app/views/onlylogs/shared/_shortcuts_dialog.html.erb +62 -0
- data/bin/super_grep +7 -0
- data/bin/super_ripgrep +14 -0
- data/config/routes.rb +1 -0
- data/lib/onlylogs/configuration.rb +14 -1
- data/lib/onlylogs/engine.rb +9 -0
- data/lib/onlylogs/http_device.rb +28 -13
- data/lib/onlylogs/version.rb +1 -1
- data/lib/onlylogs.rb +3 -0
- metadata +38 -2
- 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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
}
|
data/app/models/onlylogs/file.rb
CHANGED
|
@@ -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,
|
|
74
|
-
end_position: end_position, max_matches: max_matches,
|
|
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
|
data/app/models/onlylogs/grep.rb
CHANGED
|
@@ -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,
|
|
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
|
-
#
|
|
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
|
-
|
|
26
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 %>>
|