onlylogs 0.1.2
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 +7 -0
- data/README.md +311 -0
- data/Rakefile +8 -0
- data/app/assets/config/onlylogs_manifest.js +2 -0
- 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 +3 -0
- data/app/assets/images/onlylogs/favicon/site.webmanifest.erb +21 -0
- 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/channels/onlylogs/application_cable/channel.rb +11 -0
- data/app/channels/onlylogs/logs_channel.rb +181 -0
- data/app/controllers/onlylogs/application_controller.rb +22 -0
- data/app/controllers/onlylogs/logs_controller.rb +23 -0
- data/app/helpers/onlylogs/application_helper.rb +4 -0
- data/app/javascript/onlylogs/application.js +1 -0
- data/app/javascript/onlylogs/controllers/application.js +9 -0
- data/app/javascript/onlylogs/controllers/index.js +11 -0
- data/app/javascript/onlylogs/controllers/keyboard_shortcuts_controller.js +46 -0
- data/app/javascript/onlylogs/controllers/log_streamer_controller.js +432 -0
- data/app/javascript/onlylogs/controllers/text_selection_controller.js +90 -0
- data/app/jobs/onlylogs/application_job.rb +4 -0
- data/app/models/onlylogs/ansi_color_parser.rb +78 -0
- data/app/models/onlylogs/application_record.rb +5 -0
- data/app/models/onlylogs/batch_sender.rb +61 -0
- data/app/models/onlylogs/file.rb +151 -0
- data/app/models/onlylogs/file_path_parser.rb +118 -0
- data/app/models/onlylogs/grep.rb +54 -0
- data/app/models/onlylogs/log_line.rb +24 -0
- data/app/models/onlylogs/secure_file_path.rb +31 -0
- data/app/views/home/show.html.erb +10 -0
- data/app/views/layouts/onlylogs/application.html.erb +27 -0
- data/app/views/onlylogs/logs/index.html.erb +49 -0
- data/app/views/onlylogs/shared/_log_container.html.erb +106 -0
- data/app/views/onlylogs/shared/_log_container_styles.html.erb +228 -0
- data/config/importmap.rb +6 -0
- data/config/puma_plugins/vector.rb +94 -0
- data/config/routes.rb +4 -0
- data/config/udp_logger.rb +40 -0
- data/config/vector.toml +32 -0
- data/db/migrate/20250902112548_create_books.rb +9 -0
- data/lib/onlylogs/configuration.rb +133 -0
- data/lib/onlylogs/engine.rb +39 -0
- data/lib/onlylogs/formatter.rb +14 -0
- data/lib/onlylogs/log_silencer_middleware.rb +26 -0
- data/lib/onlylogs/logger.rb +10 -0
- data/lib/onlylogs/socket_logger.rb +71 -0
- data/lib/onlylogs/version.rb +3 -0
- data/lib/onlylogs.rb +17 -0
- data/lib/puma/plugin/onlylogs_sidecar.rb +113 -0
- data/lib/tasks/onlylogs_tasks.rake +4 -0
- metadata +110 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onlylogs
|
|
4
|
+
class AnsiColorParser
|
|
5
|
+
ANSI_COLORS = {
|
|
6
|
+
"1" => "fw-bold",
|
|
7
|
+
"30" => "log-black",
|
|
8
|
+
"31" => "log-red",
|
|
9
|
+
"32" => "log-green",
|
|
10
|
+
"33" => "log-yellow",
|
|
11
|
+
"34" => "log-blue",
|
|
12
|
+
"35" => "log-magenta",
|
|
13
|
+
"36" => "log-cyan",
|
|
14
|
+
"37" => "log-white",
|
|
15
|
+
"39" => "", # Default foreground color (reset)
|
|
16
|
+
"0" => "" # Reset (no color)
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# Pre-compiled regex for better performance
|
|
20
|
+
ANSI_REGEX = /\x1b\[(\d+)m/.freeze
|
|
21
|
+
|
|
22
|
+
# Pre-built HTML templates to avoid string interpolation (frozen for better performance)
|
|
23
|
+
HTML_TEMPLATES = {
|
|
24
|
+
"1" => '<span class="fw-bold">'.freeze,
|
|
25
|
+
"30" => '<span class="log-black">'.freeze,
|
|
26
|
+
"31" => '<span class="log-red">'.freeze,
|
|
27
|
+
"32" => '<span class="log-green">'.freeze,
|
|
28
|
+
"33" => '<span class="log-yellow">'.freeze,
|
|
29
|
+
"34" => '<span class="log-blue">'.freeze,
|
|
30
|
+
"35" => '<span class="log-magenta">'.freeze,
|
|
31
|
+
"36" => '<span class="log-cyan">'.freeze,
|
|
32
|
+
"37" => '<span class="log-white">'.freeze,
|
|
33
|
+
"39" => '<span class="">'.freeze,
|
|
34
|
+
"0" => "".freeze # Reset (no color)
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# Pre-built closing span (frozen for better performance)
|
|
38
|
+
CLOSING_SPAN = '</span>'.freeze
|
|
39
|
+
|
|
40
|
+
def self.parse(string)
|
|
41
|
+
return string if string.blank?
|
|
42
|
+
|
|
43
|
+
# Early return if no ANSI codes present
|
|
44
|
+
return string unless string.include?("\x1b[")
|
|
45
|
+
|
|
46
|
+
result = string
|
|
47
|
+
stack = []
|
|
48
|
+
|
|
49
|
+
# Replace ANSI color codes with HTML spans
|
|
50
|
+
result = result.gsub(ANSI_REGEX) do |_match|
|
|
51
|
+
code = ::Regexp.last_match(1)
|
|
52
|
+
|
|
53
|
+
if code == "0"
|
|
54
|
+
# Reset - close all open spans
|
|
55
|
+
if stack.empty?
|
|
56
|
+
""
|
|
57
|
+
else
|
|
58
|
+
spans = CLOSING_SPAN * stack.length
|
|
59
|
+
stack.clear
|
|
60
|
+
spans
|
|
61
|
+
end
|
|
62
|
+
elsif (template = HTML_TEMPLATES[code])
|
|
63
|
+
# Add span for this color/attribute
|
|
64
|
+
stack.push(code)
|
|
65
|
+
template
|
|
66
|
+
else
|
|
67
|
+
# Unknown code, ignore
|
|
68
|
+
""
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Close any remaining open spans using string multiplication (faster than map/join)
|
|
73
|
+
result += CLOSING_SPAN * stack.length if stack.any?
|
|
74
|
+
|
|
75
|
+
result.html_safe
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onlylogs
|
|
4
|
+
class BatchSender
|
|
5
|
+
def initialize(channel, interval: 0.05)
|
|
6
|
+
@channel = channel
|
|
7
|
+
@interval = interval
|
|
8
|
+
@buffer = []
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
@running = false
|
|
11
|
+
@sender_thread = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def start
|
|
15
|
+
return if @running
|
|
16
|
+
|
|
17
|
+
@running = true
|
|
18
|
+
@sender_thread = Thread.new do
|
|
19
|
+
while @running
|
|
20
|
+
send_batch
|
|
21
|
+
sleep(@interval)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def stop
|
|
27
|
+
return unless @running
|
|
28
|
+
|
|
29
|
+
@running = false
|
|
30
|
+
@sender_thread&.join(0.1)
|
|
31
|
+
|
|
32
|
+
# Send any remaining lines
|
|
33
|
+
send_batch
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def add_line(line_data)
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
@buffer << line_data
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def send_batch
|
|
45
|
+
lines_to_send = nil
|
|
46
|
+
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
return if @buffer.empty?
|
|
49
|
+
lines_to_send = @buffer.dup
|
|
50
|
+
@buffer.clear
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
return if lines_to_send.empty?
|
|
54
|
+
|
|
55
|
+
@channel.send(:transmit, {
|
|
56
|
+
action: "append_logs",
|
|
57
|
+
lines: lines_to_send
|
|
58
|
+
})
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
module Onlylogs
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
class File
|
|
5
|
+
attr_reader :path, :last_position, :last_line_number
|
|
6
|
+
|
|
7
|
+
def initialize(path, last_position: 0)
|
|
8
|
+
self.path = path
|
|
9
|
+
self.last_position = last_position
|
|
10
|
+
self.last_line_number = 0
|
|
11
|
+
validate!
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def go_to_position(position)
|
|
15
|
+
return if position < 0
|
|
16
|
+
|
|
17
|
+
self.last_position = position
|
|
18
|
+
self.last_line_number = 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def watch(&block)
|
|
22
|
+
# return enum_for(:watch) unless block
|
|
23
|
+
|
|
24
|
+
loop do
|
|
25
|
+
sleep 0.5
|
|
26
|
+
|
|
27
|
+
new_lines = read_new_lines
|
|
28
|
+
next if new_lines.empty?
|
|
29
|
+
|
|
30
|
+
yield new_lines
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def size
|
|
35
|
+
::File.size(path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def exist?
|
|
39
|
+
::File.exist?(path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def text_file?
|
|
43
|
+
self.class.text_file?(path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.text_file?(path)
|
|
47
|
+
return false unless ::File.exist?(path)
|
|
48
|
+
return false if ::File.zero?(path)
|
|
49
|
+
|
|
50
|
+
# Read first chunk and check for null bytes (binary indicator)
|
|
51
|
+
::File.open(path, "rb") do |file|
|
|
52
|
+
chunk = file.read(8192) || ""
|
|
53
|
+
# If it contains null bytes, it's likely binary
|
|
54
|
+
return false if chunk.include?("\x00")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def grep(filter, regexp_mode: false, start_position: 0, end_position: nil, &block)
|
|
61
|
+
Grep.grep(filter, path, regexp_mode: regexp_mode, start_position: start_position, end_position: end_position) do |line_number, content|
|
|
62
|
+
yield Onlylogs::LogLine.new(line_number, content)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
attr_writer :path, :last_position, :last_line_number
|
|
69
|
+
|
|
70
|
+
def read_new_lines
|
|
71
|
+
return [] unless exist?
|
|
72
|
+
|
|
73
|
+
current_size = ::File.size(path)
|
|
74
|
+
return [] if current_size <= last_position
|
|
75
|
+
|
|
76
|
+
# Read new content from last_position to end of file
|
|
77
|
+
new_content = ""
|
|
78
|
+
::File.open(path, "rb") do |file|
|
|
79
|
+
file.seek(last_position)
|
|
80
|
+
new_content = file.read
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
return [] if new_content.empty?
|
|
84
|
+
|
|
85
|
+
# Split into lines, handling incomplete lines
|
|
86
|
+
lines = new_content.lines(chomp: true)
|
|
87
|
+
|
|
88
|
+
# If we're not at the beginning of the file, check if we're at a line boundary
|
|
89
|
+
first_line_removed = false
|
|
90
|
+
if last_position > 0
|
|
91
|
+
# Read one character before to see if it was a newline
|
|
92
|
+
::File.open(path, "rb") do |file|
|
|
93
|
+
file.seek(last_position - 1)
|
|
94
|
+
char_before = file.read(1)
|
|
95
|
+
# If the character before wasn't a newline, we're in the middle of a line
|
|
96
|
+
if char_before != "\n" && lines.any?
|
|
97
|
+
# Remove the first line as it's incomplete
|
|
98
|
+
lines.shift
|
|
99
|
+
first_line_removed = true
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Check if the last line is complete (ends with newline)
|
|
105
|
+
last_line_incomplete = lines.any? && !new_content.end_with?("\n")
|
|
106
|
+
if last_line_incomplete
|
|
107
|
+
# Remove the last line as it's incomplete
|
|
108
|
+
lines.pop
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Update position to end of last complete line
|
|
112
|
+
if lines.any?
|
|
113
|
+
# Find the position after the last complete line
|
|
114
|
+
::File.open(path, "rb") do |file|
|
|
115
|
+
file.seek(last_position)
|
|
116
|
+
# Read and count newlines to find where complete lines end
|
|
117
|
+
newline_count = 0
|
|
118
|
+
# If we removed the first line, we need to count one extra newline
|
|
119
|
+
# to account for the incomplete first line
|
|
120
|
+
target_newlines = lines.length + (first_line_removed ? 1 : 0)
|
|
121
|
+
while newline_count < target_newlines
|
|
122
|
+
char = file.read(1)
|
|
123
|
+
break unless char
|
|
124
|
+
|
|
125
|
+
newline_count += 1 if char == "\n"
|
|
126
|
+
end
|
|
127
|
+
self.last_position = file.tell
|
|
128
|
+
end
|
|
129
|
+
elsif last_line_incomplete
|
|
130
|
+
# If we had lines but removed the last incomplete one,
|
|
131
|
+
# position should be at the start of the incomplete line
|
|
132
|
+
self.last_position = current_size - new_content.lines.last.length
|
|
133
|
+
elsif first_line_removed
|
|
134
|
+
# If we removed the first line but have no complete lines,
|
|
135
|
+
# position should be at the end of the file since we consumed all content
|
|
136
|
+
self.last_position = current_size
|
|
137
|
+
else
|
|
138
|
+
# No lines at all, position at end of file
|
|
139
|
+
self.last_position = current_size
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
lines = lines.map.with_index { |line, index| Onlylogs::LogLine.new(self.last_line_number + index, line) }
|
|
143
|
+
self.last_line_number += lines.length
|
|
144
|
+
lines
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def validate!
|
|
148
|
+
raise Error, "File not found: #{path}" unless exist?
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
# kudos to https://github.com/BetterErrors/better_errors for this code!
|
|
6
|
+
module Onlylogs
|
|
7
|
+
class FilePathParser
|
|
8
|
+
KNOWN_EDITORS = [
|
|
9
|
+
{ symbols: [ :atom ], sniff: /atom/i, url: "atom://core/open/file?filename=%{file}&line=%{line}" },
|
|
10
|
+
{ symbols: [ :emacs, :emacsclient ], sniff: /emacs/i, url: "emacs://open?url=file://%{file}&line=%{line}" },
|
|
11
|
+
{ symbols: [ :idea ], sniff: /idea/i, url: "idea://open?file=%{file}&line=%{line}" },
|
|
12
|
+
{ symbols: [ :macvim, :mvim ], sniff: /vim/i, url: "mvim://open?url=file://%{file_unencoded}&line=%{line}" },
|
|
13
|
+
{ symbols: [ :rubymine ], sniff: /mine/i, url: "x-mine://open?file=%{file}&line=%{line}" },
|
|
14
|
+
{ symbols: [ :sublime, :subl, :st ], sniff: /subl/i, url: "subl://open?url=file://%{file}&line=%{line}" },
|
|
15
|
+
{ symbols: [ :textmate, :txmt, :tm ], sniff: /mate/i, url: "txmt://open?url=file://%{file}&line=%{line}" },
|
|
16
|
+
{ symbols: [ :vscode, :code ], sniff: /code/i, url: "vscode://file/%{file}:%{line}" },
|
|
17
|
+
{ symbols: [ :vscodium, :codium ], sniff: /codium/i, url: "vscodium://file/%{file}:%{line}" }
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# Pre-compiled regex for better performance
|
|
21
|
+
FILE_PATH_PATTERN = %r{
|
|
22
|
+
(?<![a-zA-Z0-9_/]) # Negative lookbehind - not preceded by word chars or /
|
|
23
|
+
(?:\./)? # Optional relative path indicator
|
|
24
|
+
(?:/[a-zA-Z0-9_\-\.\s]+)+ # File path with allowed characters
|
|
25
|
+
(?:\.rb|\.js|\.ts|\.tsx|\.jsx|\.py|\.java|\.go|\.rs|\.php|\.html|\.erb|\.haml|\.slim|\.css|\.scss|\.sass|\.less|\.xml|\.json|\.yml|\.yaml|\.md|\.txt|\.log) # File extensions
|
|
26
|
+
(?::\d+)? # Optional line number
|
|
27
|
+
(?![a-zA-Z0-9_/]) # Negative lookahead - not followed by word chars or /
|
|
28
|
+
}x.freeze
|
|
29
|
+
|
|
30
|
+
# Pre-built HTML template to avoid string interpolation
|
|
31
|
+
HTML_TEMPLATE = '<a href="%{url}" class="file-link">%{match}</a>'.freeze
|
|
32
|
+
|
|
33
|
+
def self.parse(string)
|
|
34
|
+
return string if string.blank?
|
|
35
|
+
|
|
36
|
+
# Early return if no file paths present
|
|
37
|
+
return string unless string.match?(FILE_PATH_PATTERN)
|
|
38
|
+
|
|
39
|
+
string.gsub(FILE_PATH_PATTERN) do |match|
|
|
40
|
+
file_path = extract_file_path(match)
|
|
41
|
+
line_number = extract_line_number(match)
|
|
42
|
+
url = cached_editor_instance.url(file_path, line_number)
|
|
43
|
+
HTML_TEMPLATE % { url: url, match: match }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.for_formatting_string(formatting_string)
|
|
48
|
+
new proc { |file, line|
|
|
49
|
+
formatting_string % { file: URI.encode_www_form_component(file), file_unencoded: file, line: line }
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.for_proc(url_proc)
|
|
54
|
+
new url_proc
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Cache for the editor instance
|
|
58
|
+
@cached_editor_instance = nil
|
|
59
|
+
|
|
60
|
+
def self.cached_editor_instance
|
|
61
|
+
return @cached_editor_instance if @cached_editor_instance
|
|
62
|
+
@cached_editor_instance = editor_from_symbol(Onlylogs.editor)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def self.editor_from_symbol(symbol)
|
|
67
|
+
KNOWN_EDITORS.each do |preset|
|
|
68
|
+
return for_formatting_string(preset[:url]) if preset[:symbols].include?(symbol)
|
|
69
|
+
end
|
|
70
|
+
editor_from_symbol(:vscode)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def initialize(url_proc)
|
|
74
|
+
@url_proc = url_proc
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def url(raw_path, line)
|
|
78
|
+
if virtual_path && raw_path.start_with?(virtual_path)
|
|
79
|
+
if host_path
|
|
80
|
+
file = raw_path.sub(%r{\A#{virtual_path}}, host_path)
|
|
81
|
+
else
|
|
82
|
+
file = raw_path.sub(%r{\A#{virtual_path}/}, "")
|
|
83
|
+
end
|
|
84
|
+
else
|
|
85
|
+
file = raw_path
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
url_proc.call(file, line)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def scheme
|
|
92
|
+
url("/fake", 42).sub(/:.*/, ":")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
attr_reader :url_proc
|
|
98
|
+
|
|
99
|
+
def virtual_path
|
|
100
|
+
@virtual_path ||= ENV["ONLYLOGS_VIRTUAL_PATH"]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def host_path
|
|
104
|
+
@host_path ||= ENV["ONLYLOGS_HOST_PATH"]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.extract_file_path(match)
|
|
108
|
+
# Remove line number if present
|
|
109
|
+
match.sub(/:\d+$/, "")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.extract_line_number(match)
|
|
113
|
+
# Extract line number or default to 1
|
|
114
|
+
line_match = match.match(/:(\d+)$/)
|
|
115
|
+
line_match ? line_match[1].to_i : 1
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Onlylogs
|
|
2
|
+
class Grep
|
|
3
|
+
def self.grep(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false, &block)
|
|
4
|
+
# Use the appropriate script based on configuration
|
|
5
|
+
script_name = Onlylogs.ripgrep_enabled? ? "super_ripgrep" : "super_grep"
|
|
6
|
+
super_grep_path = ::File.expand_path("../../../bin/#{script_name}", __dir__)
|
|
7
|
+
|
|
8
|
+
command_args = [ super_grep_path ]
|
|
9
|
+
command_args += [ "--max-matches", Onlylogs.max_line_matches.to_s ] if Onlylogs.max_line_matches.present?
|
|
10
|
+
command_args << "--regexp" if regexp_mode
|
|
11
|
+
|
|
12
|
+
# Add byte range parameters if specified
|
|
13
|
+
if start_position > 0 || end_position
|
|
14
|
+
command_args << "--start-position" << start_position.to_s
|
|
15
|
+
command_args << "--end-position" << end_position.to_s if end_position
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
command_args += [ pattern, file_path ]
|
|
19
|
+
|
|
20
|
+
results = []
|
|
21
|
+
|
|
22
|
+
IO.popen(command_args, err: "/dev/null") do |io|
|
|
23
|
+
io.each_line do |line|
|
|
24
|
+
# Parse each line as it comes in - super_grep returns grep output with line numbers (format: line_number:content)
|
|
25
|
+
if match = line.strip.match(/^(\d+):(.*)/)
|
|
26
|
+
line_number = match[1].to_i
|
|
27
|
+
content = match[2]
|
|
28
|
+
|
|
29
|
+
if block_given?
|
|
30
|
+
yield line_number, content
|
|
31
|
+
else
|
|
32
|
+
results << [ line_number, content ]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
block_given? ? nil : results
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.match_line?(line, string, regexp_mode: false)
|
|
42
|
+
# Strip ANSI color codes from the line before matching
|
|
43
|
+
stripped_line = line.gsub(/\e\[[0-9;]*m/, "")
|
|
44
|
+
# Normalize multiple spaces to single spaces
|
|
45
|
+
normalized_line = stripped_line.gsub(/\s+/, " ")
|
|
46
|
+
|
|
47
|
+
if regexp_mode
|
|
48
|
+
normalized_line.match?(string)
|
|
49
|
+
else
|
|
50
|
+
normalized_line.match?(Regexp.escape(string))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onlylogs
|
|
4
|
+
class LogLine
|
|
5
|
+
attr_reader :number, :text
|
|
6
|
+
|
|
7
|
+
def initialize(number, text)
|
|
8
|
+
@number = number
|
|
9
|
+
@text = text
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def parsed_number
|
|
13
|
+
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1'").reverse.rjust(7)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def parsed_text
|
|
17
|
+
FilePathParser.parse(AnsiColorParser.parse(ERB::Util.html_escape(text)))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_a
|
|
21
|
+
[number, text]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onlylogs
|
|
4
|
+
class SecureFilePath
|
|
5
|
+
class SecurityError < StandardError; end
|
|
6
|
+
|
|
7
|
+
def self.encrypt(file_path)
|
|
8
|
+
encryptor = ActiveSupport::MessageEncryptor.new(encryption_key)
|
|
9
|
+
encrypted = encryptor.encrypt_and_sign(file_path.to_s)
|
|
10
|
+
Base64.urlsafe_encode64(encrypted).tr("=", "")
|
|
11
|
+
rescue => e
|
|
12
|
+
Rails.logger.error "Onlylogs: Encryption failed for #{file_path}: #{e.message}"
|
|
13
|
+
raise SecurityError, "Encryption failed"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.decrypt(encrypted_path)
|
|
17
|
+
decoded = Base64.urlsafe_decode64(encrypted_path)
|
|
18
|
+
encryptor = ActiveSupport::MessageEncryptor.new(encryption_key)
|
|
19
|
+
encryptor.decrypt_and_verify(decoded)
|
|
20
|
+
rescue => e
|
|
21
|
+
Rails.logger.error "Onlylogs: Decryption failed: #{e.message}"
|
|
22
|
+
raise SecurityError, "Invalid encrypted file path"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def self.encryption_key
|
|
28
|
+
Rails.application.secret_key_base[0..31]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<% content_for :head do %>
|
|
2
|
+
<meta http-equiv="refresh" content="5">
|
|
3
|
+
<% end %>
|
|
4
|
+
|
|
5
|
+
<p>
|
|
6
|
+
<%= link_to "Check logs in Real Time", onlylogs.root_path, target: :_blank %>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p>When loading this page, some lines are added to the logs, for testing purposes.</p>
|
|
10
|
+
<p><em>This page refreshes automatically every 5 seconds.</em></p>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Onlylogs</title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<%= csp_meta_tag %>
|
|
7
|
+
|
|
8
|
+
<%= yield :head %>
|
|
9
|
+
|
|
10
|
+
<%= javascript_importmap_tags "application", importmap: Onlylogs.importmap %>
|
|
11
|
+
|
|
12
|
+
<style>
|
|
13
|
+
html, body {
|
|
14
|
+
margin: 0;
|
|
15
|
+
padding: 0;
|
|
16
|
+
height: 100%;
|
|
17
|
+
width: 100%;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
}
|
|
20
|
+
</style>
|
|
21
|
+
</head>
|
|
22
|
+
<body>
|
|
23
|
+
|
|
24
|
+
<%= yield %>
|
|
25
|
+
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<div class="sidebar-layout" data-controller="toggle-class" data-toggle-class-toggle-class="closed">
|
|
2
|
+
<aside id="sidebar">
|
|
3
|
+
<div class="sidebar-menu">
|
|
4
|
+
<%= link_to onlylogs.root_path, class: "btn sidebar-menu__button" do %>
|
|
5
|
+
<%= image_tag "onlylogs/logo.png" %>
|
|
6
|
+
<% end %>
|
|
7
|
+
<div class="sidebar-menu__content">
|
|
8
|
+
<div class="sidebar-menu__items">
|
|
9
|
+
<div class="sidebar-menu__group">
|
|
10
|
+
<div class="sidebar-menu__group-label">Projects</div>
|
|
11
|
+
<nav class="sidebar-menu__items">
|
|
12
|
+
<%= link_to "All Projects", root_path, class: "btn sidebar-menu__button" %>
|
|
13
|
+
|
|
14
|
+
<nav class="sidebar-menu__sub">
|
|
15
|
+
<nav class="sidebar-menu__sub text-sm">
|
|
16
|
+
<div>
|
|
17
|
+
<% if Onlylogs::File.text_file?(drain_file) %>
|
|
18
|
+
<%= link_to drain_file.basename, project_path(@project, file_path: Onlylogs::SecureFilePath.encrypt(drain_file.to_s)) %>
|
|
19
|
+
<% else %>
|
|
20
|
+
<%= drain_file.basename %>
|
|
21
|
+
<% end %>
|
|
22
|
+
<small>(<%= number_to_human_size(File.size(drain_file)) %>)</small>
|
|
23
|
+
<%= link_to project_drain_file_path(@project, Onlylogs::SecureFilePath.encrypt(drain_file.to_s)),
|
|
24
|
+
title: t('drain_files.show.title'),
|
|
25
|
+
class: 'download-link' do %>
|
|
26
|
+
<i class="fa-regular fa-download" aria-label="<%= t('drain_files.show.title') %>"></i>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
29
|
+
</nav>
|
|
30
|
+
</nav>
|
|
31
|
+
</nav>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
</aside>
|
|
38
|
+
<main id="main">
|
|
39
|
+
<header class="flex items-center gap show@md">
|
|
40
|
+
<button type="button" class="btn btn--borderless p-1" data-action="toggle-class#toggle">
|
|
41
|
+
<i class="fa-regular fa-sidebar"></i>
|
|
42
|
+
<span class="sr-only">Toggle Sidebar</span>
|
|
43
|
+
</button>
|
|
44
|
+
<div class="separator-vertical mi-1" style="--sep-size: 1rem"></div>
|
|
45
|
+
</header>
|
|
46
|
+
|
|
47
|
+
<%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path, tail: @max_lines, filter: @filter, autoscroll: @autoscroll } %>
|
|
48
|
+
</main>
|
|
49
|
+
</div>
|