onlylogs 0.8.0 → 0.10.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 +27 -1
- data/app/channels/onlylogs/logs_channel.rb +87 -69
- data/app/javascript/onlylogs/controllers/keyboard_shortcuts_controller.js +15 -11
- data/app/javascript/onlylogs/controllers/log_streamer_controller.js +170 -202
- data/app/javascript/onlylogs/controllers/range_slider_controller.js +96 -0
- data/app/javascript/onlylogs/controllers/text_selection_controller.js +1 -0
- data/app/models/onlylogs/grep.rb +25 -7
- data/app/views/onlylogs/logs/index.html.erb +25 -5
- data/app/views/onlylogs/shared/_log_container.html.erb +71 -35
- data/app/views/onlylogs/shared/_log_container_styles.html.erb +246 -49
- data/app/views/onlylogs/shared/_range_slider.html.erb +9 -5
- data/lib/onlylogs/configuration.rb +13 -1
- data/lib/onlylogs/continuous_log_writer.rb +161 -0
- data/lib/onlylogs/formatter.rb +4 -3
- data/lib/onlylogs/http_device.rb +319 -84
- data/lib/onlylogs/http_logger.rb +4 -7
- data/lib/onlylogs/spool.rb +61 -13
- data/lib/onlylogs/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9303c9ba4c727343d48702332baef85d112068f0639a148ff42afc8693b1f7af
|
|
4
|
+
data.tar.gz: 82fe3f9d70cfc3b2d4a6f661a296974037aca9fd8bd8e97317476bb091d3c320
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d7bf3497e6013d367776e50e716d113781cdde78b79d302b01a126778092aa36fb0f025c93162301b4f9444d9be83bd30ebd2b832d3e3e5da29d6ed721bd6138
|
|
7
|
+
data.tar.gz: 879705e6eb4dafe29e1bf1698495cfb0731ccefc13f3127547a45173e4fdc6beda4ea53c13b4406ae6c3d1ba10ffe36f3b2f21bf1a7e3ff17adf195b5161170f
|
data/README.md
CHANGED
|
@@ -235,7 +235,7 @@ end
|
|
|
235
235
|
|
|
236
236
|
Onlylogs automatically detects file paths in log messages and converts them into clickable links that open in your preferred code editor.
|
|
237
237
|
|
|
238
|
-
For a complete list of supported editors, see [
|
|
238
|
+
For a complete list of supported editors, see [app/models/onlylogs/file_path_parser.rb](app/models/onlylogs/file_path_parser.rb).
|
|
239
239
|
|
|
240
240
|
```bash
|
|
241
241
|
# env variables
|
|
@@ -272,6 +272,22 @@ Onlylogs.configure do |config|
|
|
|
272
272
|
end
|
|
273
273
|
```
|
|
274
274
|
|
|
275
|
+
#### Bounding How Long a Viewer Search Can Run
|
|
276
|
+
|
|
277
|
+
Searches started from the log viewer stop after `search_timeout` seconds, keeping whatever they
|
|
278
|
+
found and reporting that they did not reach the end of the file. The default is 120 seconds:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
# config/initializers/onlylogs.rb
|
|
282
|
+
Onlylogs.configure do |config|
|
|
283
|
+
config.search_timeout = 300
|
|
284
|
+
|
|
285
|
+
# Or remove the ceiling entirely (a search then holds a CPU until it reaches
|
|
286
|
+
# the end of the file, however large it is)
|
|
287
|
+
config.search_timeout = nil
|
|
288
|
+
end
|
|
289
|
+
```
|
|
290
|
+
|
|
275
291
|
### Filtering Log Lines with a Denylist
|
|
276
292
|
|
|
277
293
|
The `Onlylogs::Formatter` supports a denylist: an array of regular expressions that prevents matching lines from being logged. This is useful for filtering out noisy or irrelevant entries like health checks or asset requests.
|
|
@@ -324,6 +340,16 @@ bin/continuous_log_writer 10 3
|
|
|
324
340
|
|
|
325
341
|
The script will write logs to `test/dummy/log/development.log`, which will appear in real-time in the onlylogs UI at `http://localhost:3000/onlylogs`.
|
|
326
342
|
|
|
343
|
+
The lines are generated by `Onlylogs::ContinuousLogWriter`, in the same format a Rails app logging through `Onlylogs::Logger` produces. The script is a thin wrapper, so applications embedding onlylogs can reuse the writer against any file of their own:
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
require "onlylogs/continuous_log_writer"
|
|
347
|
+
|
|
348
|
+
Onlylogs::ContinuousLogWriter.new(some_log_path, logs_per_batch: 3, interval: 1).call
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
It is plain Ruby (it does not need Rails to be booted) and is not loaded by `require "onlylogs"` — require it explicitly where you need it.
|
|
352
|
+
|
|
327
353
|
**Example workflow:**
|
|
328
354
|
|
|
329
355
|
```bash
|
|
@@ -16,27 +16,8 @@ module Onlylogs
|
|
|
16
16
|
@last_initialize_params = data.dup
|
|
17
17
|
cleanup_existing_operations
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
encrypted_file_path = data["file_path"]
|
|
22
|
-
if encrypted_file_path.present?
|
|
23
|
-
file_path = Onlylogs::SecureFilePath.decrypt(encrypted_file_path)
|
|
24
|
-
|
|
25
|
-
# Verify the decrypted path is still allowed
|
|
26
|
-
unless Onlylogs.file_path_permitted?(file_path)
|
|
27
|
-
Rails.logger.error "Onlylogs: Attempted to access non-allowed file: #{file_path}"
|
|
28
|
-
transmit({action: "error", content: "Access denied"})
|
|
29
|
-
return
|
|
30
|
-
end
|
|
31
|
-
else
|
|
32
|
-
# Fallback to default if no encrypted path provided
|
|
33
|
-
file_path = Onlylogs.default_log_file_path
|
|
34
|
-
end
|
|
35
|
-
rescue Onlylogs::SecureFilePath::SecurityError => e
|
|
36
|
-
Rails.logger.error "Onlylogs: Security violation - #{e.message}"
|
|
37
|
-
transmit({action: "error", content: "Access denied"})
|
|
38
|
-
return
|
|
39
|
-
end
|
|
19
|
+
file_path = authorized_file_path(data["file_path"])
|
|
20
|
+
return unless file_path
|
|
40
21
|
|
|
41
22
|
# Check if the file is a text file
|
|
42
23
|
unless Onlylogs::File.text_file?(file_path)
|
|
@@ -72,6 +53,32 @@ module Onlylogs
|
|
|
72
53
|
|
|
73
54
|
private
|
|
74
55
|
|
|
56
|
+
# Decrypts and authorises the requested file. Returns nil (after transmitting
|
|
57
|
+
# an error) when there is no token, when it cannot be decrypted, or when the
|
|
58
|
+
# decrypted path is not on the configured allow-list — the three ways a client
|
|
59
|
+
# can ask for something it is not entitled to stream.
|
|
60
|
+
def authorized_file_path(encrypted_file_path)
|
|
61
|
+
if encrypted_file_path.blank?
|
|
62
|
+
Rails.logger.error "Onlylogs: initialize_watcher without a file path token; refusing to stream"
|
|
63
|
+
transmit({action: "error", content: "Access denied"})
|
|
64
|
+
return nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
file_path = Onlylogs::SecureFilePath.decrypt(encrypted_file_path)
|
|
68
|
+
|
|
69
|
+
unless Onlylogs.file_path_permitted?(file_path)
|
|
70
|
+
Rails.logger.error "Onlylogs: Attempted to access non-allowed file: #{file_path}"
|
|
71
|
+
transmit({action: "error", content: "Access denied"})
|
|
72
|
+
return nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
file_path
|
|
76
|
+
rescue Onlylogs::SecureFilePath::SecurityError => e
|
|
77
|
+
Rails.logger.error "Onlylogs: Security violation - #{e.message}"
|
|
78
|
+
transmit({action: "error", content: "Access denied"})
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
75
82
|
def cleanup_existing_operations
|
|
76
83
|
if @batch_sender
|
|
77
84
|
@batch_sender.stop(send_remaining_lines: false)
|
|
@@ -168,55 +175,18 @@ module Onlylogs
|
|
|
168
175
|
last_byte_offset = nil
|
|
169
176
|
line_count = 0
|
|
170
177
|
|
|
171
|
-
|
|
172
|
-
skip_first = start_position > 0
|
|
173
|
-
|
|
174
|
-
if filter.present?
|
|
175
|
-
# Use grep for filtered search
|
|
176
|
-
@log_file.grep(filter, regexp_mode: regexp_mode, start_position: start_position, end_position: end_position) do |result|
|
|
177
|
-
break if @batch_sender.nil? || @log_watcher_running == false
|
|
178
|
-
|
|
179
|
-
# Skip first line if start_position > 0 (line is cut off at byte boundary)
|
|
180
|
-
if skip_first
|
|
181
|
-
skip_first = false
|
|
182
|
-
next
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
# Result is a hash with {byte_offset, content}
|
|
186
|
-
byte_offset = result[:byte_offset]
|
|
187
|
-
log_line = result[:content]
|
|
178
|
+
show_expand_button = filter.present?
|
|
188
179
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
end
|
|
197
|
-
else
|
|
198
|
-
# No filter - read all lines directly (skip grep)
|
|
199
|
-
# Still need byte_offset for highlighting when expanding around a line
|
|
200
|
-
current_byte_offset = start_position
|
|
201
|
-
read_byte_range(file_path, start_position, end_position) do |log_line|
|
|
202
|
-
break if @batch_sender.nil? || @log_watcher_running == false
|
|
203
|
-
|
|
204
|
-
# Skip first line if start_position > 0 (line is cut off at byte boundary)
|
|
205
|
-
if skip_first
|
|
206
|
-
skip_first = false
|
|
207
|
-
next
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
# Buffer previous line and skip it to avoid cut-off lines at boundaries
|
|
211
|
-
if last_line
|
|
212
|
-
@batch_sender.add_line(render_log_line(last_line, byte_offset: last_byte_offset))
|
|
213
|
-
line_count += 1
|
|
214
|
-
end
|
|
215
|
-
last_line = log_line
|
|
216
|
-
last_byte_offset = current_byte_offset
|
|
217
|
-
# Account for line content plus newline character (2 bytes for \r\n or 1 for \n)
|
|
218
|
-
current_byte_offset += log_line.bytesize + 1
|
|
180
|
+
Rails.logger.silence(Logger::ERROR) do
|
|
181
|
+
each_matching_line(file_path, filter, regexp_mode, start_position, end_position) do |log_line, byte_offset|
|
|
182
|
+
# Buffer previous line and skip it to avoid cut-off lines at boundaries
|
|
183
|
+
if last_line
|
|
184
|
+
@batch_sender.add_line(render_log_line(last_line, byte_offset: last_byte_offset,
|
|
185
|
+
show_expand_button: show_expand_button))
|
|
186
|
+
line_count += 1
|
|
219
187
|
end
|
|
188
|
+
last_line = log_line
|
|
189
|
+
last_byte_offset = byte_offset
|
|
220
190
|
end
|
|
221
191
|
end
|
|
222
192
|
|
|
@@ -234,6 +204,11 @@ module Onlylogs
|
|
|
234
204
|
else
|
|
235
205
|
transmit({action: "finish", content: "Search finished."})
|
|
236
206
|
end
|
|
207
|
+
rescue Onlylogs::Grep::TimeoutError
|
|
208
|
+
@batch_sender&.stop
|
|
209
|
+
transmit({action: "finish",
|
|
210
|
+
content: "Search stopped after #{Onlylogs.search_timeout} seconds and did not reach " \
|
|
211
|
+
"the end of the file. Narrow the range or the filter and search again."})
|
|
237
212
|
ensure
|
|
238
213
|
# Always cleanup even if interrupted or error occurs
|
|
239
214
|
@batch_sender&.stop
|
|
@@ -243,6 +218,49 @@ module Onlylogs
|
|
|
243
218
|
end
|
|
244
219
|
end
|
|
245
220
|
|
|
221
|
+
# Yields [line, byte_offset] over the requested slice: through grep when there
|
|
222
|
+
# is a filter, straight off disk when there is not. Either way the offsets are
|
|
223
|
+
# the real ones, so "show around this line" can re-centre on them.
|
|
224
|
+
#
|
|
225
|
+
# A range that starts mid-line opens with a fragment of a line, which is
|
|
226
|
+
# dropped - but its bytes still count towards every offset after it.
|
|
227
|
+
def each_matching_line(file_path, filter, regexp_mode, start_position, end_position)
|
|
228
|
+
skip_first = start_position > 0
|
|
229
|
+
|
|
230
|
+
if filter.present?
|
|
231
|
+
@log_file.grep(filter, regexp_mode: regexp_mode, start_position: start_position,
|
|
232
|
+
end_position: end_position, timeout: Onlylogs.search_timeout) do |result|
|
|
233
|
+
break if reading_stopped?
|
|
234
|
+
|
|
235
|
+
if skip_first
|
|
236
|
+
skip_first = false
|
|
237
|
+
next
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
yield result[:content], result[:byte_offset]
|
|
241
|
+
end
|
|
242
|
+
else
|
|
243
|
+
offset = start_position
|
|
244
|
+
|
|
245
|
+
read_byte_range(file_path, start_position, end_position) do |log_line, raw_bytesize|
|
|
246
|
+
break if reading_stopped?
|
|
247
|
+
|
|
248
|
+
if skip_first
|
|
249
|
+
skip_first = false
|
|
250
|
+
offset += raw_bytesize
|
|
251
|
+
next
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
yield log_line, offset
|
|
255
|
+
offset += raw_bytesize
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def reading_stopped?
|
|
261
|
+
@batch_sender.nil? || @log_watcher_running == false
|
|
262
|
+
end
|
|
263
|
+
|
|
246
264
|
def read_byte_range(file_path, start_position, end_position)
|
|
247
265
|
file_size = ::File.size(file_path)
|
|
248
266
|
range_size = (end_position || file_size) - start_position
|
|
@@ -250,7 +268,7 @@ module Onlylogs
|
|
|
250
268
|
return if start_position < 0 || range_size <= 0 || start_position >= file_size
|
|
251
269
|
|
|
252
270
|
::File.read(file_path, range_size, start_position).each_line do |line|
|
|
253
|
-
yield line.chomp
|
|
271
|
+
yield line.chomp, line.bytesize
|
|
254
272
|
end
|
|
255
273
|
rescue => e
|
|
256
274
|
Rails.logger.error "Error reading byte range: #{e.message}"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
3
|
export default class KeyboardShortcutsController extends Controller {
|
|
4
|
-
static targets = ["
|
|
4
|
+
static targets = ["liveButton", "searchButton", "autoscroll"]
|
|
5
5
|
|
|
6
6
|
connect() {
|
|
7
7
|
this.boundHandleKeydown = this.handleKeydown.bind(this)
|
|
@@ -13,15 +13,25 @@ export default class KeyboardShortcutsController extends Controller {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
handleKeydown(event) {
|
|
16
|
+
// These are bare single-key shortcuts. Any modifier means the keystroke belongs
|
|
17
|
+
// to the browser or the OS (cmd+L, cmd+A, cmd+S) and must pass straight through.
|
|
18
|
+
if (event.metaKey || event.ctrlKey || event.altKey) {
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
// Only handle shortcuts when not typing in input fields
|
|
17
|
-
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
|
|
23
|
+
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA' || event.target.isContentEditable) {
|
|
18
24
|
return
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
switch (event.key.toLowerCase()) {
|
|
22
28
|
case 'l':
|
|
23
29
|
event.preventDefault()
|
|
24
|
-
this.
|
|
30
|
+
if (this.hasLiveButtonTarget) this.liveButtonTarget.click()
|
|
31
|
+
break
|
|
32
|
+
case 's':
|
|
33
|
+
event.preventDefault()
|
|
34
|
+
if (this.hasSearchButtonTarget) this.searchButtonTarget.click()
|
|
25
35
|
break
|
|
26
36
|
case 'a':
|
|
27
37
|
event.preventDefault()
|
|
@@ -30,15 +40,9 @@ export default class KeyboardShortcutsController extends Controller {
|
|
|
30
40
|
}
|
|
31
41
|
}
|
|
32
42
|
|
|
33
|
-
toggleLiveMode() {
|
|
34
|
-
if (this.hasLiveModeTarget) {
|
|
35
|
-
this.liveModeTarget.checked = !this.liveModeTarget.checked
|
|
36
|
-
this.liveModeTarget.dispatchEvent(new Event('change', { bubbles: true }))
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
43
|
toggleAutoscroll() {
|
|
41
|
-
|
|
44
|
+
// Autoscroll only exists while live; ignore the shortcut when it is not on screen.
|
|
45
|
+
if (this.hasAutoscrollTarget && this.autoscrollTarget.offsetParent !== null) {
|
|
42
46
|
this.autoscrollTarget.checked = !this.autoscrollTarget.checked
|
|
43
47
|
this.autoscrollTarget.dispatchEvent(new Event('change', { bubbles: true }))
|
|
44
48
|
}
|