onlylogs 0.1.2 → 0.2.1
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/app/channels/onlylogs/logs_channel.rb +39 -29
- data/app/javascript/onlylogs/controllers/log_streamer_controller.js +9 -48
- data/app/models/onlylogs/batch_sender.rb +15 -3
- data/app/models/onlylogs/file.rb +20 -64
- data/app/models/onlylogs/grep.rb +8 -10
- data/app/views/onlylogs/logs/index.html.erb +1 -49
- data/app/views/onlylogs/shared/_log_container.html.erb +1 -1
- data/app/views/onlylogs/shared/_log_container_styles.html.erb +14 -5
- data/bin/onlylogs_sidecar +112 -0
- data/lib/onlylogs/configuration.rb +0 -4
- data/lib/onlylogs/engine.rb +0 -10
- data/lib/onlylogs/version.rb +1 -1
- metadata +4 -8
- data/app/models/onlylogs/application_record.rb +0 -5
- data/app/models/onlylogs/log_line.rb +0 -24
- data/config/puma_plugins/vector.rb +0 -94
- data/config/udp_logger.rb +0 -40
- data/config/vector.toml +0 -32
- data/lib/onlylogs/log_silencer_middleware.rb +0 -26
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: baff3b51025f63d5ba3537ebf50f48c75478e64287ea98e9634d19fe070a42f5
|
|
4
|
+
data.tar.gz: 47c2a4a23cfbfe44c25e0fbdbb96413e49aac96fe7ff6695c3440ec00aa0b08b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1676249dd2d66f2b9b4032473313fac9306c2f05283dfa927c229af55f5ace1f7a740456231d15de5a92b7de5b7fc56a54ecdc643e1adc33e53718ec5e2befce
|
|
7
|
+
data.tar.gz: 73d9f8296c302d3c84d5ae74748a0ca6601c26f00722b33a1fe067f1c1e62810b6e70fac8825e04763de0640759caa785092ba56f8df69f3dc8bee0373150056
|
|
@@ -68,7 +68,7 @@ module Onlylogs
|
|
|
68
68
|
|
|
69
69
|
def cleanup_existing_operations
|
|
70
70
|
if @batch_sender
|
|
71
|
-
@batch_sender.stop
|
|
71
|
+
@batch_sender.stop(send_remaining_lines: false)
|
|
72
72
|
@batch_sender = nil
|
|
73
73
|
end
|
|
74
74
|
|
|
@@ -102,10 +102,7 @@ module Onlylogs
|
|
|
102
102
|
# next
|
|
103
103
|
# end
|
|
104
104
|
|
|
105
|
-
lines_to_send <<
|
|
106
|
-
line_number: log_line.number,
|
|
107
|
-
html: render_log_line(log_line)
|
|
108
|
-
}
|
|
105
|
+
lines_to_send << render_log_line(log_line)
|
|
109
106
|
end
|
|
110
107
|
|
|
111
108
|
if lines_to_send.any?
|
|
@@ -129,10 +126,21 @@ module Onlylogs
|
|
|
129
126
|
|
|
130
127
|
@log_watcher_running = false
|
|
131
128
|
|
|
132
|
-
|
|
129
|
+
# Wait for graceful shutdown
|
|
130
|
+
if @log_watcher_thread&.alive?
|
|
131
|
+
@log_watcher_thread.join(3)
|
|
133
132
|
|
|
134
|
-
|
|
135
|
-
|
|
133
|
+
# If still alive after 3 seconds, force kill (but log it)
|
|
134
|
+
if @log_watcher_thread.alive?
|
|
135
|
+
Rails.logger.warn "Onlylogs: Force killing watcher thread after timeout"
|
|
136
|
+
@log_watcher_thread.kill
|
|
137
|
+
@log_watcher_thread.join(1)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Clear references to allow GC
|
|
142
|
+
@log_watcher_thread = nil
|
|
143
|
+
@log_file = nil
|
|
136
144
|
end
|
|
137
145
|
|
|
138
146
|
def read_entire_file_with_filter(file_path, filter = nil, regexp_mode = false, start_position = 0, end_position = nil)
|
|
@@ -146,36 +154,38 @@ module Onlylogs
|
|
|
146
154
|
|
|
147
155
|
line_count = 0
|
|
148
156
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
157
|
+
begin
|
|
158
|
+
Rails.logger.silence(Logger::ERROR) do
|
|
159
|
+
@log_file.grep(filter, regexp_mode: regexp_mode, start_position: start_position, end_position: end_position) do |log_line|
|
|
160
|
+
return if @batch_sender.nil?
|
|
152
161
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
line_number: log_line.number,
|
|
156
|
-
html: render_log_line(log_line)
|
|
157
|
-
})
|
|
162
|
+
# Add to batch buffer (sender thread will handle sending)
|
|
163
|
+
@batch_sender.add_line(render_log_line(log_line))
|
|
158
164
|
|
|
159
|
-
|
|
165
|
+
line_count += 1
|
|
166
|
+
end
|
|
160
167
|
end
|
|
161
|
-
end
|
|
162
168
|
|
|
163
|
-
|
|
164
|
-
|
|
169
|
+
# Stop batch sender and flush any remaining lines
|
|
170
|
+
@batch_sender.stop
|
|
165
171
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
172
|
+
# Send completion message
|
|
173
|
+
if line_count >= Onlylogs.max_line_matches
|
|
174
|
+
transmit({ action: "finish", content: "Search finished. Search results limit reached." })
|
|
175
|
+
else
|
|
176
|
+
transmit({ action: "finish", content: "Search finished." })
|
|
177
|
+
end
|
|
178
|
+
ensure
|
|
179
|
+
# Always cleanup even if interrupted or error occurs
|
|
180
|
+
@batch_sender&.stop
|
|
181
|
+
@batch_sender = nil
|
|
182
|
+
@log_file = nil
|
|
183
|
+
@log_watcher_running = false
|
|
171
184
|
end
|
|
172
|
-
|
|
173
|
-
@log_watcher_running = false
|
|
174
185
|
end
|
|
175
186
|
|
|
176
187
|
def render_log_line(log_line)
|
|
177
|
-
"<pre
|
|
178
|
-
"<span class=\"line-number\">#{log_line.parsed_number}</span>#{log_line.parsed_text}</pre>"
|
|
188
|
+
"<pre>#{FilePathParser.parse(AnsiColorParser.parse(ERB::Util.html_escape(log_line)))}</pre>"
|
|
179
189
|
end
|
|
180
190
|
end
|
|
181
191
|
end
|
|
@@ -5,7 +5,6 @@ export default class LogStreamerController extends Controller {
|
|
|
5
5
|
static values = {
|
|
6
6
|
filePath: { type: String },
|
|
7
7
|
cursorPosition: { type: Number, default: 0 },
|
|
8
|
-
lastLineNumber: { type: Number, default: 0 },
|
|
9
8
|
autoScroll: { type: Boolean, default: true },
|
|
10
9
|
autoStart: { type: Boolean, default: true },
|
|
11
10
|
filter: { type: String, default: '' },
|
|
@@ -13,7 +12,7 @@ export default class LogStreamerController extends Controller {
|
|
|
13
12
|
regexpMode: { type: Boolean, default: false }
|
|
14
13
|
};
|
|
15
14
|
|
|
16
|
-
static targets = ["logLines", "filterInput", "
|
|
15
|
+
static targets = ["logLines", "filterInput", "results", "liveMode", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton"];
|
|
17
16
|
|
|
18
17
|
connect() {
|
|
19
18
|
this.consumer = createConsumer();
|
|
@@ -21,8 +20,6 @@ export default class LogStreamerController extends Controller {
|
|
|
21
20
|
this.subscription = null;
|
|
22
21
|
this.isRunning = false;
|
|
23
22
|
this.reconnectTimeout = null;
|
|
24
|
-
this.minLineNumber = null;
|
|
25
|
-
this.maxLineNumber = 0;
|
|
26
23
|
this.isSearchFinished = true;
|
|
27
24
|
|
|
28
25
|
// Initialize clusterize
|
|
@@ -83,12 +80,8 @@ export default class LogStreamerController extends Controller {
|
|
|
83
80
|
}
|
|
84
81
|
|
|
85
82
|
clear() {
|
|
86
|
-
this.minLineNumber = null;
|
|
87
|
-
this.maxLineNumber = 0;
|
|
88
|
-
|
|
89
83
|
this.clusterize.clear();
|
|
90
|
-
|
|
91
|
-
this.#updateLineRangeDisplay();
|
|
84
|
+
this.#updateResultsDisplay();
|
|
92
85
|
}
|
|
93
86
|
|
|
94
87
|
toggleAutoScroll() {
|
|
@@ -180,9 +173,8 @@ export default class LogStreamerController extends Controller {
|
|
|
180
173
|
}
|
|
181
174
|
|
|
182
175
|
stopSearch() {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
176
|
+
console.log("stop search");
|
|
177
|
+
this.subscription.perform('stop_watcher');
|
|
186
178
|
}
|
|
187
179
|
|
|
188
180
|
clearLogs() {
|
|
@@ -244,7 +236,6 @@ export default class LogStreamerController extends Controller {
|
|
|
244
236
|
#handleConnected() {
|
|
245
237
|
this.subscription.perform('initialize_watcher', {
|
|
246
238
|
cursor_position: this.cursorPositionValue,
|
|
247
|
-
last_line_number: this.lastLineNumberValue,
|
|
248
239
|
file_path: this.filePathValue,
|
|
249
240
|
filter: this.filterInputTarget.value,
|
|
250
241
|
mode: this.modeValue,
|
|
@@ -273,24 +264,10 @@ export default class LogStreamerController extends Controller {
|
|
|
273
264
|
|
|
274
265
|
#handleLogLines(lines) {
|
|
275
266
|
try {
|
|
276
|
-
const newLines = [];
|
|
277
|
-
|
|
278
|
-
lines.forEach(line => {
|
|
279
|
-
const { line_number, html } = line;
|
|
280
|
-
|
|
281
|
-
if (this.minLineNumber === null || line_number < this.minLineNumber) {
|
|
282
|
-
this.minLineNumber = line_number;
|
|
283
|
-
}
|
|
284
|
-
this.maxLineNumber = Math.max(this.maxLineNumber, line_number);
|
|
285
|
-
|
|
286
|
-
// Add to new lines array for clusterize
|
|
287
|
-
newLines.push(html);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
267
|
// Append new lines to clusterize
|
|
291
|
-
if (
|
|
292
|
-
this.clusterize.append(
|
|
293
|
-
this.#
|
|
268
|
+
if (lines.length > 0) {
|
|
269
|
+
this.clusterize.append(lines);
|
|
270
|
+
this.#updateResultsDisplay();
|
|
294
271
|
this.scroll();
|
|
295
272
|
}
|
|
296
273
|
|
|
@@ -313,13 +290,8 @@ export default class LogStreamerController extends Controller {
|
|
|
313
290
|
}
|
|
314
291
|
|
|
315
292
|
#handleFinish(message) {
|
|
316
|
-
// Display the finish message without loading icon
|
|
317
293
|
this.messageTarget.innerHTML = message;
|
|
318
|
-
|
|
319
|
-
// Mark search as finished
|
|
320
294
|
this.isSearchFinished = true;
|
|
321
|
-
|
|
322
|
-
// Update stop button visibility (should hide it)
|
|
323
295
|
this.updateStopButtonVisibility();
|
|
324
296
|
}
|
|
325
297
|
|
|
@@ -341,19 +313,9 @@ export default class LogStreamerController extends Controller {
|
|
|
341
313
|
this.messageTarget.innerHTML = '';
|
|
342
314
|
}
|
|
343
315
|
|
|
344
|
-
#
|
|
316
|
+
#updateResultsDisplay() {
|
|
345
317
|
const resultsCount = this.clusterize.getRowsAmount();
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (this.minLineNumber === null || this.maxLineNumber === 0) {
|
|
349
|
-
lineRangeText = `No lines`;
|
|
350
|
-
} else if (this.minLineNumber === this.maxLineNumber) {
|
|
351
|
-
lineRangeText = `Line ${this.#formatNumber(this.minLineNumber)}`;
|
|
352
|
-
} else {
|
|
353
|
-
lineRangeText = `Lines ${this.#formatNumber(this.minLineNumber)} - ${this.#formatNumber(this.maxLineNumber)}`;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
this.lineRangeTarget.textContent = `${lineRangeText} | Results: ${this.#formatNumber(resultsCount)}`;
|
|
318
|
+
this.resultsTarget.textContent = `Results: ${this.#formatNumber(resultsCount)}`;
|
|
357
319
|
}
|
|
358
320
|
|
|
359
321
|
#formatNumber(number) {
|
|
@@ -393,7 +355,6 @@ export default class LogStreamerController extends Controller {
|
|
|
393
355
|
filePath: this.filePathValue,
|
|
394
356
|
cursorPosition: this.cursorPositionValue,
|
|
395
357
|
lineCount: this.clusterize.getRowsAmount(),
|
|
396
|
-
maxLineNumber: this.maxLineNumber,
|
|
397
358
|
connected: this.subscription && this.subscription.identifier
|
|
398
359
|
};
|
|
399
360
|
}
|
|
@@ -23,14 +23,26 @@ module Onlylogs
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def stop
|
|
26
|
+
def stop(send_remaining_lines: true)
|
|
27
27
|
return unless @running
|
|
28
28
|
|
|
29
29
|
@running = false
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
|
|
31
|
+
# Wait longer for graceful shutdown
|
|
32
|
+
if @sender_thread&.alive?
|
|
33
|
+
@sender_thread.join(0.5)
|
|
34
|
+
end
|
|
35
|
+
|
|
32
36
|
# Send any remaining lines
|
|
33
37
|
send_batch
|
|
38
|
+
if send_remaining_lines
|
|
39
|
+
send_batch
|
|
40
|
+
else
|
|
41
|
+
@mutex.synchronize { @buffer.clear }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Clear thread reference to allow GC
|
|
45
|
+
@sender_thread = nil
|
|
34
46
|
end
|
|
35
47
|
|
|
36
48
|
def add_line(line_data)
|
data/app/models/onlylogs/file.rb
CHANGED
|
@@ -2,12 +2,11 @@ module Onlylogs
|
|
|
2
2
|
class Error < StandardError; end
|
|
3
3
|
|
|
4
4
|
class File
|
|
5
|
-
attr_reader :path, :last_position
|
|
5
|
+
attr_reader :path, :last_position
|
|
6
6
|
|
|
7
7
|
def initialize(path, last_position: 0)
|
|
8
8
|
self.path = path
|
|
9
9
|
self.last_position = last_position
|
|
10
|
-
self.last_line_number = 0
|
|
11
10
|
validate!
|
|
12
11
|
end
|
|
13
12
|
|
|
@@ -15,7 +14,6 @@ module Onlylogs
|
|
|
15
14
|
return if position < 0
|
|
16
15
|
|
|
17
16
|
self.last_position = position
|
|
18
|
-
self.last_line_number = 0
|
|
19
17
|
end
|
|
20
18
|
|
|
21
19
|
def watch(&block)
|
|
@@ -58,14 +56,14 @@ module Onlylogs
|
|
|
58
56
|
end
|
|
59
57
|
|
|
60
58
|
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 |
|
|
62
|
-
yield
|
|
59
|
+
Grep.grep(filter, path, regexp_mode: regexp_mode, start_position: start_position, end_position: end_position) do |content|
|
|
60
|
+
yield content
|
|
63
61
|
end
|
|
64
62
|
end
|
|
65
63
|
|
|
66
64
|
private
|
|
67
65
|
|
|
68
|
-
attr_writer :path, :last_position
|
|
66
|
+
attr_writer :path, :last_position
|
|
69
67
|
|
|
70
68
|
def read_new_lines
|
|
71
69
|
return [] unless exist?
|
|
@@ -73,74 +71,32 @@ module Onlylogs
|
|
|
73
71
|
current_size = ::File.size(path)
|
|
74
72
|
return [] if current_size <= last_position
|
|
75
73
|
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
lines = []
|
|
75
|
+
|
|
78
76
|
::File.open(path, "rb") do |file|
|
|
79
77
|
file.seek(last_position)
|
|
80
|
-
new_content = file.read
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
return [] if new_content.empty?
|
|
84
78
|
|
|
85
|
-
|
|
86
|
-
|
|
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|
|
|
79
|
+
# Skip first line if we're mid-line (not at start or after newline)
|
|
80
|
+
if last_position > 0
|
|
93
81
|
file.seek(last_position - 1)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if
|
|
97
|
-
# Remove the first line as it's incomplete
|
|
98
|
-
lines.shift
|
|
99
|
-
first_line_removed = true
|
|
100
|
-
end
|
|
82
|
+
skip_first = (file.read(1) != "\n")
|
|
83
|
+
file.seek(last_position)
|
|
84
|
+
file.gets if skip_first # Consume incomplete line
|
|
101
85
|
end
|
|
102
|
-
end
|
|
103
86
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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"
|
|
87
|
+
# Read complete lines using gets (memory efficient, no buffer needed)
|
|
88
|
+
while (line = file.gets)
|
|
89
|
+
if line.end_with?("\n")
|
|
90
|
+
# Complete line - store as simple string
|
|
91
|
+
lines << line.chomp
|
|
92
|
+
self.last_position = file.pos
|
|
93
|
+
else
|
|
94
|
+
# Incomplete line at EOF - skip it
|
|
95
|
+
break
|
|
126
96
|
end
|
|
127
|
-
self.last_position = file.tell
|
|
128
97
|
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
98
|
end
|
|
141
99
|
|
|
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
100
|
lines
|
|
145
101
|
end
|
|
146
102
|
|
data/app/models/onlylogs/grep.rb
CHANGED
|
@@ -21,16 +21,14 @@ module Onlylogs
|
|
|
21
21
|
|
|
22
22
|
IO.popen(command_args, err: "/dev/null") do |io|
|
|
23
23
|
io.each_line do |line|
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
results << [ line_number, content ]
|
|
33
|
-
end
|
|
24
|
+
# Line numbers are no longer outputted by super_grep/super_ripgrep
|
|
25
|
+
# Use String.new to create a copy and prevent memory retention from IO buffers
|
|
26
|
+
content = String.new(line.chomp)
|
|
27
|
+
|
|
28
|
+
if block_given?
|
|
29
|
+
yield content
|
|
30
|
+
else
|
|
31
|
+
results << content
|
|
34
32
|
end
|
|
35
33
|
end
|
|
36
34
|
end
|
|
@@ -1,49 +1 @@
|
|
|
1
|
-
|
|
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>
|
|
1
|
+
<%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path, tail: @max_lines, filter: @filter, autoscroll: @autoscroll } %>
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
</button>
|
|
84
84
|
</div>
|
|
85
85
|
<div>
|
|
86
|
-
<span data-log-streamer-target="
|
|
86
|
+
<span data-log-streamer-target="results" style="color: #666;">Results: 0</span>
|
|
87
87
|
</div>
|
|
88
88
|
<div data-log-streamer-target="message"></div>
|
|
89
89
|
<% unless Onlylogs.ripgrep_enabled? %>
|
|
@@ -26,14 +26,23 @@
|
|
|
26
26
|
|
|
27
27
|
.clusterize-content {
|
|
28
28
|
outline: 0;
|
|
29
|
+
counter-reset: line-number;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
pre {
|
|
33
|
+
counter-increment: line-number;
|
|
34
|
+
|
|
35
|
+
&::before {
|
|
36
|
+
content: counter(line-number);
|
|
37
|
+
color: #aaa;
|
|
38
|
+
user-select: none;
|
|
39
|
+
margin-right: 0.5em;
|
|
40
|
+
display: inline-block;
|
|
41
|
+
text-align: right;
|
|
42
|
+
min-width: 3.5em;
|
|
43
|
+
}
|
|
35
44
|
}
|
|
36
|
-
|
|
45
|
+
|
|
37
46
|
.color-success {
|
|
38
47
|
color: green;
|
|
39
48
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# this is a simple sidecar process that takes log lines sent into the socket and sends them to the drain url in batches.
|
|
5
|
+
# You can also attach it to puma using `plugin :onlylogs_sidecar` in your config/puma.rb file so you don't need to run it manually.
|
|
6
|
+
# The following parameters are availble:
|
|
7
|
+
# - ONLYLOGS_DRAIN_URL: the url to the drain. this is mandatory and must be set.
|
|
8
|
+
# - ONLYLOGS_SIDECAR_SOCKET: the path to the socket file. this is optional and will default to tmp/sockets/onlylogs-sidecar.sock in the current directory.
|
|
9
|
+
# - ONLYLOGS_BATCH_SIZE: the number of lines to batch before sending. this is optional and will default to 100.
|
|
10
|
+
# - ONLYLOGS_FLUSH_INTERVAL: the interval in seconds to flush the batch. this is optional and will default to 0.5 seconds.
|
|
11
|
+
|
|
12
|
+
require "socket"
|
|
13
|
+
require "net/http"
|
|
14
|
+
require "uri"
|
|
15
|
+
require "thread"
|
|
16
|
+
require "fileutils"
|
|
17
|
+
|
|
18
|
+
drain_url = ENV["ONLYLOGS_DRAIN_URL"]
|
|
19
|
+
socket_path = ENV.fetch("ONLYLOGS_SIDECAR_SOCKET", File.expand_path("tmp/sockets/onlylogs-sidecar.sock", Dir.pwd))
|
|
20
|
+
batch_size = ENV.fetch("ONLYLOGS_BATCH_SIZE", "100").to_i
|
|
21
|
+
flush_interval = ENV.fetch("ONLYLOGS_FLUSH_INTERVAL", "0.5").to_f
|
|
22
|
+
|
|
23
|
+
if drain_url.to_s.strip.empty?
|
|
24
|
+
error "[OnlylogsSidecar] ERROR: ONLYLOGS_DRAIN_URL is not set. Exiting."
|
|
25
|
+
exit 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
uri = URI.parse(drain_url)
|
|
29
|
+
FileUtils.mkdir_p(File.dirname(socket_path))
|
|
30
|
+
FileUtils.rm_f(socket_path)
|
|
31
|
+
|
|
32
|
+
running = true
|
|
33
|
+
queue = Queue.new
|
|
34
|
+
|
|
35
|
+
trap("INT") { running = false }
|
|
36
|
+
trap("TERM") { running = false }
|
|
37
|
+
|
|
38
|
+
server = UNIXServer.new(socket_path)
|
|
39
|
+
|
|
40
|
+
accept_thread = Thread.new do
|
|
41
|
+
while running
|
|
42
|
+
begin
|
|
43
|
+
client = server.accept_nonblock
|
|
44
|
+
rescue IO::WaitReadable
|
|
45
|
+
IO.select([server])
|
|
46
|
+
retry
|
|
47
|
+
rescue IOError, Errno::EBADF
|
|
48
|
+
break
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Thread.new(client) do |conn|
|
|
52
|
+
begin
|
|
53
|
+
conn.each_line do |line|
|
|
54
|
+
cleaned = line.to_s.strip
|
|
55
|
+
queue << cleaned unless cleaned.empty?
|
|
56
|
+
end
|
|
57
|
+
rescue => e
|
|
58
|
+
warn "[OnlylogsSidecar] client error: #{e.class}: #{e.message}"
|
|
59
|
+
ensure
|
|
60
|
+
conn.close rescue nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def send_batch(uri, lines)
|
|
67
|
+
return if lines.empty?
|
|
68
|
+
|
|
69
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
70
|
+
http.use_ssl = (uri.scheme == "https")
|
|
71
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
72
|
+
request.body = lines.join("\n")
|
|
73
|
+
puts "[OnlylogsSidecar] Sending #{request.body}"
|
|
74
|
+
request.content_type = "text/plain"
|
|
75
|
+
http.read_timeout = 5
|
|
76
|
+
http.open_timeout = 2
|
|
77
|
+
http.start
|
|
78
|
+
http.request(request)
|
|
79
|
+
rescue => e
|
|
80
|
+
warn "[OnlylogsSidecar] HTTP error: #{e.class}: #{e.message}"
|
|
81
|
+
ensure
|
|
82
|
+
http&.finish rescue nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
batch = []
|
|
86
|
+
last_flush = Time.now
|
|
87
|
+
|
|
88
|
+
while running || !queue.empty?
|
|
89
|
+
begin
|
|
90
|
+
line = queue.pop(true)
|
|
91
|
+
batch << line if line
|
|
92
|
+
rescue ThreadError
|
|
93
|
+
# queue empty
|
|
94
|
+
sleep 0.01
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
next if batch.empty?
|
|
98
|
+
|
|
99
|
+
if batch.size >= batch_size || (Time.now - last_flush) >= flush_interval
|
|
100
|
+
send_batch(uri, batch)
|
|
101
|
+
batch.clear
|
|
102
|
+
last_flush = Time.now
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Final flush
|
|
107
|
+
send_batch(uri, batch) unless batch.empty?
|
|
108
|
+
|
|
109
|
+
running = false
|
|
110
|
+
accept_thread.kill
|
|
111
|
+
server.close rescue nil
|
|
112
|
+
FileUtils.rm_f(socket_path)
|
data/lib/onlylogs/engine.rb
CHANGED
|
@@ -25,15 +25,5 @@ module Onlylogs
|
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
|
-
|
|
29
|
-
# initializer "onlylogs.add_log_silencer" do |app|
|
|
30
|
-
# silenced_routes = ['/onlylogs']
|
|
31
|
-
#
|
|
32
|
-
# app.middleware.insert_before(
|
|
33
|
-
# Rails::Rack::Logger,
|
|
34
|
-
# Onlylogs::LogSilencerMiddleware,
|
|
35
|
-
# paths_to_silence: silenced_routes
|
|
36
|
-
# )
|
|
37
|
-
# end
|
|
38
28
|
end
|
|
39
29
|
end
|
data/lib/onlylogs/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: onlylogs
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alessandro Rodi
|
|
@@ -27,7 +27,8 @@ description: This gem includes all the tools needed to view and stream you log f
|
|
|
27
27
|
directly on a web interface.
|
|
28
28
|
email:
|
|
29
29
|
- alessandro.rodi@renuo.ch
|
|
30
|
-
executables:
|
|
30
|
+
executables:
|
|
31
|
+
- onlylogs_sidecar
|
|
31
32
|
extensions: []
|
|
32
33
|
extra_rdoc_files: []
|
|
33
34
|
files:
|
|
@@ -55,29 +56,24 @@ files:
|
|
|
55
56
|
- app/javascript/onlylogs/controllers/text_selection_controller.js
|
|
56
57
|
- app/jobs/onlylogs/application_job.rb
|
|
57
58
|
- app/models/onlylogs/ansi_color_parser.rb
|
|
58
|
-
- app/models/onlylogs/application_record.rb
|
|
59
59
|
- app/models/onlylogs/batch_sender.rb
|
|
60
60
|
- app/models/onlylogs/file.rb
|
|
61
61
|
- app/models/onlylogs/file_path_parser.rb
|
|
62
62
|
- app/models/onlylogs/grep.rb
|
|
63
|
-
- app/models/onlylogs/log_line.rb
|
|
64
63
|
- app/models/onlylogs/secure_file_path.rb
|
|
65
64
|
- app/views/home/show.html.erb
|
|
66
65
|
- app/views/layouts/onlylogs/application.html.erb
|
|
67
66
|
- app/views/onlylogs/logs/index.html.erb
|
|
68
67
|
- app/views/onlylogs/shared/_log_container.html.erb
|
|
69
68
|
- app/views/onlylogs/shared/_log_container_styles.html.erb
|
|
69
|
+
- bin/onlylogs_sidecar
|
|
70
70
|
- config/importmap.rb
|
|
71
|
-
- config/puma_plugins/vector.rb
|
|
72
71
|
- config/routes.rb
|
|
73
|
-
- config/udp_logger.rb
|
|
74
|
-
- config/vector.toml
|
|
75
72
|
- db/migrate/20250902112548_create_books.rb
|
|
76
73
|
- lib/onlylogs.rb
|
|
77
74
|
- lib/onlylogs/configuration.rb
|
|
78
75
|
- lib/onlylogs/engine.rb
|
|
79
76
|
- lib/onlylogs/formatter.rb
|
|
80
|
-
- lib/onlylogs/log_silencer_middleware.rb
|
|
81
77
|
- lib/onlylogs/logger.rb
|
|
82
78
|
- lib/onlylogs/socket_logger.rb
|
|
83
79
|
- lib/onlylogs/version.rb
|
|
@@ -1,24 +0,0 @@
|
|
|
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
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "puma/plugin"
|
|
4
|
-
require "rbconfig"
|
|
5
|
-
require "timeout"
|
|
6
|
-
require "shellwords"
|
|
7
|
-
|
|
8
|
-
Puma::Plugin.create do
|
|
9
|
-
def start(launcher)
|
|
10
|
-
@launcher = launcher
|
|
11
|
-
@events = launcher.events
|
|
12
|
-
@options = launcher.config.options
|
|
13
|
-
@vector_pid = nil
|
|
14
|
-
|
|
15
|
-
setup_paths
|
|
16
|
-
start_vector
|
|
17
|
-
register_hooks
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
private
|
|
21
|
-
|
|
22
|
-
def setup_paths
|
|
23
|
-
@app_root = @options[:directory] || Dir.pwd
|
|
24
|
-
@vector_bin = env_or_option("ONLYLOGS_VECTOR_BIN", :onlylogs_vector_bin, "vector")
|
|
25
|
-
@vector_config = env_or_option(
|
|
26
|
-
"ONLYLOGS_VECTOR_CONFIG",
|
|
27
|
-
:onlylogs_vector_config,
|
|
28
|
-
File.expand_path("../vector.toml", __dir__)
|
|
29
|
-
)
|
|
30
|
-
@vector_args = env_or_option("ONLYLOGS_VECTOR_ARGS", :onlylogs_vector_args, "")
|
|
31
|
-
@dsn = env_or_option("ONLYLOGS_DSN", :onlylogs_dsn, "https://onlylogs.io/drain/testmac")
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def register_hooks
|
|
35
|
-
events = @launcher.events
|
|
36
|
-
events.register(:on_restart) { restart_vector }
|
|
37
|
-
at_exit { stop_vector }
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def env_or_option(env_key, option_key, default)
|
|
41
|
-
ENV.fetch(env_key, @options.fetch(option_key, default))
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def start_vector
|
|
45
|
-
stop_vector if @vector_pid
|
|
46
|
-
|
|
47
|
-
unless File.exist?(@vector_config)
|
|
48
|
-
warn "Vector config not found at #{@vector_config}; skipping start"
|
|
49
|
-
return
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
args = [ @vector_bin, "--config", @vector_config ]
|
|
53
|
-
args += Shellwords.split(@vector_args.to_s) unless @vector_args.to_s.empty?
|
|
54
|
-
|
|
55
|
-
info "Starting Vector sidecar (config: #{@vector_config}, dsn: #{@dsn})"
|
|
56
|
-
env = { "ONLYLOGS_DSN" => @dsn }
|
|
57
|
-
@vector_pid = Process.spawn(env, *args, chdir: @app_root, pgroup: true)
|
|
58
|
-
rescue Errno::ENOENT => e
|
|
59
|
-
error "Unable to start Vector sidecar: #{e.message}"
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def restart_vector
|
|
63
|
-
info "Restarting Vector sidecar"
|
|
64
|
-
start_vector
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def stop_vector
|
|
68
|
-
return unless @vector_pid
|
|
69
|
-
|
|
70
|
-
info "Stopping Vector sidecar"
|
|
71
|
-
pgid = Process.getpgid(@vector_pid)
|
|
72
|
-
Process.kill("TERM", -pgid)
|
|
73
|
-
Timeout.timeout(5) { Process.wait(@vector_pid) }
|
|
74
|
-
rescue Errno::ESRCH, Errno::ECHILD
|
|
75
|
-
# already stopped
|
|
76
|
-
rescue Timeout::Error
|
|
77
|
-
warn "Vector sidecar did not stop in time, killing"
|
|
78
|
-
Process.kill("KILL", -pgid) rescue nil
|
|
79
|
-
ensure
|
|
80
|
-
@vector_pid = nil
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def info(message)
|
|
84
|
-
@events.log("[VectorSidecar] #{message}")
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def warn(message)
|
|
88
|
-
@events.log("[VectorSidecar][WARN] #{message}")
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def error(message)
|
|
92
|
-
@events.error("[VectorSidecar][ERROR] #{message}")
|
|
93
|
-
end
|
|
94
|
-
end
|
data/config/udp_logger.rb
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# udp_logger.rb
|
|
2
|
-
require "logger"
|
|
3
|
-
require "socket"
|
|
4
|
-
|
|
5
|
-
class UdpLogger < Logger
|
|
6
|
-
def initialize(host: "127.0.0.1", port: 6000, local_fallback: $stdout)
|
|
7
|
-
# Use a normal Logger underneath so we still see logs locally
|
|
8
|
-
super(local_fallback)
|
|
9
|
-
|
|
10
|
-
@udp_host = host
|
|
11
|
-
@udp_port = port
|
|
12
|
-
@socket = UDPSocket.new
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
# Override Logger#add, which all the level methods delegate to
|
|
16
|
-
def add(severity, message = nil, progname = nil, &block)
|
|
17
|
-
# Same semantics as Logger:
|
|
18
|
-
if message.nil?
|
|
19
|
-
if block_given?
|
|
20
|
-
message = block.call
|
|
21
|
-
else
|
|
22
|
-
message = progname
|
|
23
|
-
progname = nil
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Send plain text over UDP to Vector
|
|
28
|
-
begin
|
|
29
|
-
payload = message.to_s
|
|
30
|
-
@socket.send(payload, 0, @udp_host, @udp_port)
|
|
31
|
-
rescue => e
|
|
32
|
-
# Swallow UDP errors so logging never crashes the app
|
|
33
|
-
warn "UDP logger error: #{e.class}: #{e.message}"
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# Also log locally (stdout / file) via normal Logger behavior
|
|
37
|
-
super(severity, message, progname, &block)
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
data/config/vector.toml
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
# Where Vector keeps internal state (buffers, etc.)
|
|
2
|
-
data_dir = "/usr/local/var/lib/vector"
|
|
3
|
-
|
|
4
|
-
# --- 1) SOURCE: UDP socket listening on localhost:6000 ---
|
|
5
|
-
|
|
6
|
-
[sources.udp_logs]
|
|
7
|
-
type = "socket"
|
|
8
|
-
mode = "udp" # UDP mode
|
|
9
|
-
address = "127.0.0.1:6000"
|
|
10
|
-
|
|
11
|
-
# --- 2) SINK: console (for debugging, optional but very handy) ---
|
|
12
|
-
|
|
13
|
-
[sinks.console]
|
|
14
|
-
type = "console"
|
|
15
|
-
inputs = ["udp_logs"]
|
|
16
|
-
encoding.codec = "json"
|
|
17
|
-
target = "stdout"
|
|
18
|
-
|
|
19
|
-
# --- 3) SINK: Onlylogs HTTP drain ---
|
|
20
|
-
|
|
21
|
-
[sinks.onlylogs]
|
|
22
|
-
type = "http"
|
|
23
|
-
inputs = ["udp_logs"] # consume events from udp_logs
|
|
24
|
-
method = "post"
|
|
25
|
-
uri = "${ONLYLOGS_DSN}"
|
|
26
|
-
|
|
27
|
-
encoding.codec = "text"
|
|
28
|
-
|
|
29
|
-
#[sinks.onlylogs.request]
|
|
30
|
-
# Adjust headers if Onlylogs expects something specific
|
|
31
|
-
#headers.Content-Type = "application/json"
|
|
32
|
-
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
module Onlylogs
|
|
2
|
-
class LogSilencerMiddleware
|
|
3
|
-
def initialize(app, paths_to_silence:)
|
|
4
|
-
@app = app
|
|
5
|
-
# Ensure paths are an array for flexibility
|
|
6
|
-
@paths_to_silence = Array(paths_to_silence)
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
def call(env)
|
|
10
|
-
if silence_request?(env)
|
|
11
|
-
Rails.logger.silence do
|
|
12
|
-
@app.call(env)
|
|
13
|
-
end
|
|
14
|
-
else
|
|
15
|
-
@app.call(env)
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
private
|
|
20
|
-
|
|
21
|
-
def silence_request?(env)
|
|
22
|
-
request_path = env['PATH_INFO']
|
|
23
|
-
@paths_to_silence.any? { |path| request_path.start_with?(path) }
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|