onlylogs 0.5.3 → 0.8.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/app/assets/config/onlylogs_manifest.js +2 -0
- data/app/assets/javascripts/onlylogs/clusterize.js +342 -0
- data/app/assets/stylesheets/onlylogs/clusterize.css +35 -0
- data/app/channels/onlylogs/logs_channel.rb +110 -32
- data/app/controllers/onlylogs/logs_controller.rb +0 -7
- data/app/javascript/onlylogs/controllers/log_streamer_controller.js +431 -53
- data/app/models/onlylogs/file.rb +6 -2
- data/app/models/onlylogs/grep.rb +131 -16
- data/app/views/onlylogs/logs/index.html.erb +1 -1
- data/app/views/onlylogs/shared/_log_container.html.erb +44 -21
- data/app/views/onlylogs/shared/_log_container_styles.html.erb +193 -13
- data/app/views/onlylogs/shared/_range_slider.html.erb +47 -0
- data/bin/super_grep +9 -14
- data/bin/super_ripgrep +15 -15
- data/lib/onlylogs/engine.rb +1 -1
- data/lib/onlylogs/http_device.rb +317 -0
- data/lib/onlylogs/http_logger.rb +19 -316
- data/lib/onlylogs/multi_device.rb +30 -0
- data/lib/onlylogs/socket_device.rb +60 -0
- data/lib/onlylogs/socket_logger.rb +17 -63
- data/lib/onlylogs/version.rb +1 -1
- data/lib/onlylogs.rb +3 -0
- metadata +7 -1
data/app/models/onlylogs/grep.rb
CHANGED
|
@@ -1,12 +1,63 @@
|
|
|
1
|
+
require "timeout"
|
|
2
|
+
|
|
1
3
|
module Onlylogs
|
|
2
4
|
class Grep
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
# Raised when a search runs past its own +timeout+. It inherits from
|
|
6
|
+
# Timeout::Error so callers that already wrap searches in Timeout.timeout
|
|
7
|
+
# can keep a single rescue.
|
|
8
|
+
class TimeoutError < ::Timeout::Error; end
|
|
9
|
+
|
|
10
|
+
# +timeout+ is in seconds and defaults to nil, which is no deadline at all:
|
|
11
|
+
# the search runs until it finishes or the caller abandons it. The default
|
|
12
|
+
# is deliberately not a number, because only the caller knows how long it
|
|
13
|
+
# can afford to hold the thread it runs on. Anything serving a request
|
|
14
|
+
# should pass one.
|
|
15
|
+
def self.grep(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false,
|
|
16
|
+
max_matches: Onlylogs.max_line_matches, timeout: nil, &block)
|
|
17
|
+
command_args = search_command(pattern, file_path, start_position: start_position,
|
|
18
|
+
end_position: end_position, regexp_mode: regexp_mode, max_matches: max_matches)
|
|
19
|
+
|
|
20
|
+
results = []
|
|
21
|
+
|
|
22
|
+
# Set up parsing logic based on whether ripgrep includes byte offsets
|
|
23
|
+
parse_line = if Onlylogs.ripgrep_enabled?
|
|
24
|
+
->(line) {
|
|
25
|
+
parts = line.split(":", 2)
|
|
26
|
+
[parts[0].to_i + start_position, parts[1] || ""]
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
->(line) { [nil, line] }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
each_output_line(command_args, timeout: timeout) do |line|
|
|
34
|
+
byte_offset, content = parse_line.call(line.chomp)
|
|
35
|
+
|
|
36
|
+
# Use String.new to create a copy and prevent memory retention from IO buffers
|
|
37
|
+
content = String.new(content, encoding: Encoding::UTF_8).scrub
|
|
38
|
+
|
|
39
|
+
result = {byte_offset: byte_offset, content: content}
|
|
40
|
+
|
|
41
|
+
if block_given?
|
|
42
|
+
yield result
|
|
43
|
+
else
|
|
44
|
+
results << result
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
ensure
|
|
48
|
+
drop_page_cache(file_path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
block_given? ? nil : results
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.search_command(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false,
|
|
55
|
+
max_matches: Onlylogs.max_line_matches)
|
|
5
56
|
script_name = Onlylogs.ripgrep_enabled? ? "super_ripgrep" : "super_grep"
|
|
6
57
|
super_grep_path = ::File.expand_path("../../../bin/#{script_name}", __dir__)
|
|
7
58
|
|
|
8
59
|
command_args = [super_grep_path]
|
|
9
|
-
command_args += ["--max-matches",
|
|
60
|
+
command_args += ["--max-matches", max_matches.to_s] if max_matches.present?
|
|
10
61
|
command_args << "--regexp" if regexp_mode
|
|
11
62
|
|
|
12
63
|
# Add byte range parameters if specified
|
|
@@ -15,25 +66,89 @@ module Onlylogs
|
|
|
15
66
|
command_args << "--end-position" << end_position.to_s if end_position
|
|
16
67
|
end
|
|
17
68
|
|
|
18
|
-
command_args
|
|
69
|
+
command_args + [pattern, file_path]
|
|
70
|
+
end
|
|
19
71
|
|
|
20
|
-
|
|
72
|
+
# Runs the search subprocess and yields its output line by line.
|
|
73
|
+
#
|
|
74
|
+
# The child is a shell pipeline (tail | head | rg) that can spend minutes
|
|
75
|
+
# scanning a multi-GB file, so two things have to hold. timeout(1) bounds
|
|
76
|
+
# the run and escalates TERM to KILL on the whole pipeline by itself, which
|
|
77
|
+
# covers the deadline. The rest is the caller walking away early - a break
|
|
78
|
+
# out of the yield, an exception, a dropped connection - and there the order
|
|
79
|
+
# below is the point: signal the process group *before* closing the pipe.
|
|
80
|
+
# Closing first waits for a child that, having matched nothing, never wrote
|
|
81
|
+
# and so never received SIGPIPE. That is how a 25 second timeout once turned
|
|
82
|
+
# into a 24 minute request.
|
|
83
|
+
def self.each_output_line(command_args, timeout: nil, &block)
|
|
84
|
+
reader, writer = IO.pipe
|
|
21
85
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
86
|
+
begin
|
|
87
|
+
pid = Process.spawn(*bounded(command_args, timeout), out: writer, err: ::File::NULL, pgroup: true)
|
|
88
|
+
rescue
|
|
89
|
+
reader.close
|
|
90
|
+
raise
|
|
91
|
+
ensure
|
|
92
|
+
writer.close
|
|
93
|
+
end
|
|
27
94
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
95
|
+
begin
|
|
96
|
+
reader.each_line(&block)
|
|
97
|
+
ensure
|
|
98
|
+
status = stop(pid)
|
|
99
|
+
reader.close
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
raise TimeoutError, "search exceeded #{timeout}s" if status&.exitstatus == TIMED_OUT_EXIT_STATUS
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# timeout(1) exits with this when it had to stop the command.
|
|
106
|
+
TIMED_OUT_EXIT_STATUS = 124
|
|
107
|
+
KILL_GRACE_PERIOD = 0.5
|
|
108
|
+
|
|
109
|
+
def self.bounded(command_args, timeout)
|
|
110
|
+
return command_args unless timeout && timeout_command_available?
|
|
111
|
+
|
|
112
|
+
["timeout", "-k", KILL_GRACE_PERIOD.to_s, timeout.to_s, *command_args]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Not part of a BSD userland, so a machine without GNU coreutils falls back
|
|
116
|
+
# to whatever deadline the caller imposes. The kill path below still works.
|
|
117
|
+
def self.timeout_command_available?
|
|
118
|
+
return @timeout_command_available if defined?(@timeout_command_available)
|
|
119
|
+
|
|
120
|
+
@timeout_command_available = system("command -v timeout > /dev/null 2>&1")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Runs while unwinding from an async exception often enough that a second
|
|
124
|
+
# one - another Timeout::Error, a Thread#kill from a shutting down server -
|
|
125
|
+
# could otherwise land between the signal and the reap and leave the
|
|
126
|
+
# pipeline running with nobody left to stop it.
|
|
127
|
+
def self.stop(pid)
|
|
128
|
+
Thread.handle_interrupt(::Exception => :never) do
|
|
129
|
+
begin
|
|
130
|
+
Process.kill("TERM", -pid)
|
|
131
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
132
|
+
nil
|
|
33
133
|
end
|
|
134
|
+
|
|
135
|
+
Process.waitpid2(pid).last
|
|
34
136
|
end
|
|
137
|
+
rescue Errno::ECHILD
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
35
140
|
|
|
36
|
-
|
|
141
|
+
# Searching a large log file pulls the whole file into the OS page cache.
|
|
142
|
+
# In a container the kernel charges that cache to the cgroup, so a few
|
|
143
|
+
# searches over multi-GB logs can exhaust the memory limit and trigger an
|
|
144
|
+
# OOM kill even though no Ruby memory leaked. Hint the kernel to drop the
|
|
145
|
+
# pages we just read, once the child is gone and nothing is refilling them.
|
|
146
|
+
# Best-effort: advise is only a hint and is unsupported on some platforms,
|
|
147
|
+
# so never let it break a search.
|
|
148
|
+
def self.drop_page_cache(file_path)
|
|
149
|
+
::File.open(file_path) { |file| file.advise(:dontneed) }
|
|
150
|
+
rescue
|
|
151
|
+
nil
|
|
37
152
|
end
|
|
38
153
|
|
|
39
154
|
def self.match_line?(line, string, regexp_mode: false)
|
|
@@ -36,5 +36,5 @@
|
|
|
36
36
|
<% end %>
|
|
37
37
|
<% end %>
|
|
38
38
|
</div>
|
|
39
|
-
<%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path
|
|
39
|
+
<%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path } %>
|
|
40
40
|
</div>
|
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
<%# locals: (log_file_path
|
|
1
|
+
<%# locals: (log_file_path:) %>
|
|
2
2
|
<script nonce="<%= request.content_security_policy_nonce %>"
|
|
3
|
-
src="
|
|
3
|
+
src="<%= asset_path('onlylogs/clusterize.js') %>"></script>
|
|
4
|
+
<link rel="stylesheet" href="<%= asset_path('onlylogs/clusterize.css') %>" />
|
|
4
5
|
<%= render "onlylogs/shared/log_container_styles" %>
|
|
5
6
|
|
|
6
7
|
<%
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
autoscroll = local_assigns[:autoscroll] != false
|
|
9
|
+
filter = local_assigns[:filter] || ""
|
|
10
|
+
regexp_mode = local_assigns[:regexp_mode] == true
|
|
11
|
+
mode = local_assigns[:mode] || "live"
|
|
12
|
+
file_size = File.size(log_file_path)
|
|
13
|
+
start_position = local_assigns[:start_position]
|
|
14
|
+
end_position = local_assigns[:end_position]
|
|
15
|
+
byte_offset = request.params[:byte_offset]
|
|
9
16
|
|
|
10
17
|
raise SecurityError, "File path not allowed" unless Onlylogs.file_path_permitted?(log_file_path)
|
|
11
18
|
|
|
@@ -14,11 +21,13 @@
|
|
|
14
21
|
|
|
15
22
|
<div data-controller="log-streamer text-selection keyboard-shortcuts"
|
|
16
23
|
data-log-streamer-file-path-value="<%= encrypted_log_file_path %>"
|
|
17
|
-
data-log-streamer-
|
|
24
|
+
<% if start_position.present? %>data-log-streamer-start-position-value="<%= start_position %>"<% end %>
|
|
25
|
+
<% if end_position.present? %>data-log-streamer-end-position-value="<%= end_position %>"<% end %>
|
|
18
26
|
data-log-streamer-filter-value="<%= filter %>"
|
|
19
27
|
data-log-streamer-auto-scroll-value="<%= autoscroll %>"
|
|
20
28
|
data-log-streamer-regexp-mode-value="<%= regexp_mode %>"
|
|
21
29
|
data-log-streamer-mode-value="<%= mode %>"
|
|
30
|
+
data-log-streamer-file-size-value="<%= file_size %>"
|
|
22
31
|
data-action="text-selection:start->log-streamer#pauseForSelection"
|
|
23
32
|
class="onlylogs-log-container" >
|
|
24
33
|
<div data-log-streamer-target="logLines" data-text-selection-target="logLines" id="scrollArea" class="onlylogs-log-lines clusterize-scroll">
|
|
@@ -28,44 +37,42 @@
|
|
|
28
37
|
|
|
29
38
|
<button type="button"
|
|
30
39
|
data-text-selection-target="button"
|
|
31
|
-
class="onlylogs-context-menu"
|
|
40
|
+
class="onlylogs-context-menu hidden-button"
|
|
32
41
|
data-action="click->text-selection#searchSelectedText"
|
|
33
|
-
title="Search selected text"
|
|
34
|
-
style="display: none;">
|
|
42
|
+
title="Search selected text">
|
|
35
43
|
🔍 Search
|
|
36
44
|
</button>
|
|
37
45
|
|
|
38
46
|
<div class="onlylogs-log-toolbar">
|
|
39
47
|
<div>
|
|
40
|
-
<label
|
|
48
|
+
<label class="toolbar-label">
|
|
41
49
|
<input id="liveMode" type="checkbox" <%= mode == "live" ? "checked" : "" %> name="liveMode" data-log-streamer-target="liveMode" data-keyboard-shortcuts-target="liveMode" data-action="change->log-streamer#toggleLiveMode">
|
|
42
50
|
<u>L</u>ive Mode
|
|
43
51
|
</label>
|
|
44
52
|
</div>
|
|
45
53
|
<div>
|
|
46
|
-
<label
|
|
54
|
+
<label class="toolbar-label">
|
|
47
55
|
<input id="autoscroll" type="checkbox" <%= autoscroll ? "checked" : "" %> name="autoscroll" data-log-streamer-target="autoscroll" data-keyboard-shortcuts-target="autoscroll" data-action="change->log-streamer#toggleAutoScroll">
|
|
48
56
|
<u>A</u>utoscroll
|
|
49
57
|
</label>
|
|
50
58
|
</div>
|
|
51
59
|
<div>
|
|
52
|
-
<label
|
|
60
|
+
<label class="toolbar-label">
|
|
53
61
|
<input id="regexpMode" type="checkbox" <%= regexp_mode ? "checked" : "" %> name="regexpMode" data-log-streamer-target="regexpMode" data-text-selection-target="regexpMode" data-action="change->log-streamer#toggleRegexpMode">
|
|
54
62
|
<u>R</u>egexp Mode
|
|
55
63
|
</label>
|
|
56
64
|
</div>
|
|
57
65
|
<div>
|
|
58
|
-
<label
|
|
66
|
+
<label class="toolbar-label">
|
|
59
67
|
Filter:
|
|
60
|
-
<div
|
|
68
|
+
<div class="filter-input-wrapper">
|
|
61
69
|
<input type="text"
|
|
62
70
|
name="filter"
|
|
63
71
|
value="<%= filter %>"
|
|
64
72
|
placeholder="Enter filter text..."
|
|
65
73
|
data-log-streamer-target="filterInput"
|
|
66
74
|
data-text-selection-target="filterInput"
|
|
67
|
-
data-action="input->log-streamer#applyFilter"
|
|
68
|
-
style="padding-right: 1.5rem;">
|
|
75
|
+
data-action="input->log-streamer#applyFilter">
|
|
69
76
|
<button type="button"
|
|
70
77
|
data-action="click->log-streamer#clearFilter"
|
|
71
78
|
class="clear-filter-button"
|
|
@@ -75,18 +82,34 @@
|
|
|
75
82
|
</div>
|
|
76
83
|
</label>
|
|
77
84
|
</div>
|
|
85
|
+
<div class="range-slider-wrapper">
|
|
86
|
+
<%= render "onlylogs/shared/range_slider",
|
|
87
|
+
start_name: "start_position",
|
|
88
|
+
end_name: "end_position",
|
|
89
|
+
start_value: 0,
|
|
90
|
+
end_value: file_size,
|
|
91
|
+
min: 0,
|
|
92
|
+
max: file_size,
|
|
93
|
+
step: 1000,
|
|
94
|
+
label: "Range" %>
|
|
95
|
+
<button type="button"
|
|
96
|
+
data-action="click->log-streamer#resetRange"
|
|
97
|
+
class="reset-range-button"
|
|
98
|
+
title="Reset to full file">
|
|
99
|
+
↻
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
78
102
|
<div>
|
|
79
103
|
<button type="button"
|
|
80
104
|
data-log-streamer-target="stopButton"
|
|
105
|
+
class="stop-search-button hidden-button"
|
|
81
106
|
data-action="click->log-streamer#stopSearch"
|
|
82
|
-
|
|
83
|
-
title="Stop current search"
|
|
84
|
-
style="display: none;">
|
|
107
|
+
title="Stop current search">
|
|
85
108
|
⏹️ Stop
|
|
86
109
|
</button>
|
|
87
110
|
</div>
|
|
88
111
|
<div>
|
|
89
|
-
<span data-log-streamer-target="results"
|
|
112
|
+
<span data-log-streamer-target="results" class="results-text">Results: 0</span>
|
|
90
113
|
</div>
|
|
91
114
|
<div data-log-streamer-target="message"></div>
|
|
92
115
|
<% unless Onlylogs.ripgrep_enabled? %>
|
|
@@ -95,11 +118,11 @@
|
|
|
95
118
|
<div class="file-size" title="File size: <%= number_to_human_size(File.size(log_file_path)) %>">
|
|
96
119
|
<%= number_to_human_size(File.size(log_file_path)) %>
|
|
97
120
|
</div>
|
|
98
|
-
<div
|
|
121
|
+
<div class="clear-button-wrapper">
|
|
99
122
|
<button type="button"
|
|
100
123
|
data-log-streamer-target="clearButton"
|
|
101
|
-
data-action="click->log-streamer#clearLogs"
|
|
102
124
|
class="clear-logs-button"
|
|
125
|
+
data-action="click->log-streamer#clearLogs"
|
|
103
126
|
title="Clear all log lines">
|
|
104
127
|
🗑️ Clear
|
|
105
128
|
</button>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
height: var(--onlylogs-height, 100%); /* Take full height of parent container */
|
|
6
6
|
min-height: var(--onlylogs-min-height, 400px);
|
|
7
7
|
display: grid;
|
|
8
|
-
grid-template-rows: auto var(--onlylogs-toolbar-height,
|
|
8
|
+
grid-template-rows: auto var(--onlylogs-toolbar-height, 80px);
|
|
9
9
|
|
|
10
10
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
|
11
11
|
font-size: var(--onlylogs-font-size, 0.8rem);
|
|
@@ -24,8 +24,25 @@
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
&[data-log-streamer-mode-value="static"] .onlylogs-expand-btn {
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
padding: 0 0.5rem;
|
|
30
|
+
margin-right: 0.25rem;
|
|
31
|
+
background: none;
|
|
32
|
+
border: none;
|
|
33
|
+
color: #666;
|
|
34
|
+
font-weight: bold;
|
|
35
|
+
font-size: 1.1em;
|
|
36
|
+
line-height: 1;
|
|
37
|
+
|
|
38
|
+
&:hover {
|
|
39
|
+
color: #333;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
.clusterize-content {
|
|
28
44
|
outline: 0;
|
|
45
|
+
min-width: max-content;
|
|
29
46
|
}
|
|
30
47
|
|
|
31
48
|
.color-success {
|
|
@@ -76,6 +93,11 @@
|
|
|
76
93
|
margin: 0 !important;
|
|
77
94
|
padding: 0.2rem;
|
|
78
95
|
word-break: break-word; /* allow breaking long tokens like UUIDs/SQL */
|
|
96
|
+
white-space: pre;
|
|
97
|
+
|
|
98
|
+
&.highlighted-context-line {
|
|
99
|
+
background-color: rgba(255, 193, 7, 0.3);
|
|
100
|
+
}
|
|
79
101
|
}
|
|
80
102
|
|
|
81
103
|
a {
|
|
@@ -106,7 +128,11 @@
|
|
|
106
128
|
|
|
107
129
|
&:hover {
|
|
108
130
|
background-color: #bbdefb;
|
|
109
|
-
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.hidden-button {
|
|
135
|
+
display: none;
|
|
110
136
|
}
|
|
111
137
|
|
|
112
138
|
@keyframes spin {
|
|
@@ -116,20 +142,15 @@
|
|
|
116
142
|
|
|
117
143
|
.onlylogs-log-toolbar {
|
|
118
144
|
flex-shrink: 0;
|
|
119
|
-
padding:
|
|
120
|
-
display: flex;
|
|
121
|
-
|
|
122
|
-
|
|
145
|
+
padding: 0.5rem;
|
|
146
|
+
display: flex;
|
|
147
|
+
flex-wrap: wrap;
|
|
148
|
+
align-items: center;
|
|
149
|
+
gap: 0.75rem;
|
|
123
150
|
|
|
124
|
-
.live-mode-sticky {
|
|
125
|
-
opacity: 0.7;
|
|
126
|
-
cursor: not-allowed;
|
|
127
151
|
|
|
128
|
-
input[type="checkbox"] {
|
|
129
|
-
cursor: not-allowed;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
152
|
|
|
153
|
+
}
|
|
133
154
|
.clear-filter-button {
|
|
134
155
|
position: absolute;
|
|
135
156
|
right: 0.25rem;
|
|
@@ -217,6 +238,165 @@
|
|
|
217
238
|
border-radius: 0.25rem;
|
|
218
239
|
display: inline-block;
|
|
219
240
|
}
|
|
241
|
+
|
|
242
|
+
.range-slider {
|
|
243
|
+
--range-track-height: 0.25rem;
|
|
244
|
+
--range-thumb-size: 0.6rem;
|
|
245
|
+
--range-color: #2563eb;
|
|
246
|
+
--range-bg: #d1d5db;
|
|
247
|
+
--range-start-percent: 0%;
|
|
248
|
+
--range-end-percent: 100%;
|
|
249
|
+
|
|
250
|
+
display: contents;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* Toolbar utilities */
|
|
254
|
+
.toolbar-label {
|
|
255
|
+
margin-bottom: 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.filter-input-wrapper {
|
|
259
|
+
display: inline-flex;
|
|
260
|
+
align-items: center;
|
|
261
|
+
position: relative;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.filter-input-wrapper input {
|
|
265
|
+
padding-right: 1.5rem;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.clear-button-wrapper {
|
|
269
|
+
width: 67px;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.results-text {
|
|
273
|
+
color: #666;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.hidden-button {
|
|
277
|
+
display: none;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.range-slider-wrapper {
|
|
281
|
+
display: flex;
|
|
282
|
+
align-items: center;
|
|
283
|
+
gap: 0.5rem;
|
|
284
|
+
min-width: 250px;
|
|
285
|
+
flex-shrink: 0;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.range-slider__container {
|
|
289
|
+
display: flex;
|
|
290
|
+
align-items: center;
|
|
291
|
+
gap: 0.4rem;
|
|
292
|
+
flex: 1;
|
|
293
|
+
height: 1.5rem;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.range-slider__label {
|
|
297
|
+
font-size: 0.75rem;
|
|
298
|
+
font-weight: 500;
|
|
299
|
+
white-space: nowrap;
|
|
300
|
+
flex-shrink: 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.range-slider__slider {
|
|
304
|
+
display: grid;
|
|
305
|
+
gap: 0.15rem;
|
|
306
|
+
flex: 1;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.range-slider__values {
|
|
310
|
+
display: flex;
|
|
311
|
+
gap: 0.15rem;
|
|
312
|
+
font-size: 0.6rem;
|
|
313
|
+
font-variant-numeric: tabular-nums;
|
|
314
|
+
justify-content: center;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.range-slider__track {
|
|
318
|
+
position: relative;
|
|
319
|
+
height: var(--range-thumb-size);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.range-slider__track::before,
|
|
323
|
+
.range-slider__selected {
|
|
324
|
+
position: absolute;
|
|
325
|
+
top: 50%;
|
|
326
|
+
height: var(--range-track-height);
|
|
327
|
+
transform: translateY(-50%);
|
|
328
|
+
border-radius: 999px;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.range-slider__track::before {
|
|
332
|
+
content: "";
|
|
333
|
+
left: 0;
|
|
334
|
+
right: 0;
|
|
335
|
+
background: var(--range-bg);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.range-slider__selected {
|
|
339
|
+
left: var(--range-start-percent);
|
|
340
|
+
right: calc(100% - var(--range-end-percent));
|
|
341
|
+
background: var(--range-color);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.range-slider__input {
|
|
345
|
+
position: absolute;
|
|
346
|
+
inset: 0;
|
|
347
|
+
width: 100%;
|
|
348
|
+
appearance: none;
|
|
349
|
+
background: none;
|
|
350
|
+
pointer-events: none;
|
|
351
|
+
margin: 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.range-slider__input::-webkit-slider-thumb {
|
|
355
|
+
appearance: none;
|
|
356
|
+
width: var(--range-thumb-size);
|
|
357
|
+
height: var(--range-thumb-size);
|
|
358
|
+
border-radius: 50%;
|
|
359
|
+
background: var(--range-color);
|
|
360
|
+
cursor: pointer;
|
|
361
|
+
pointer-events: auto;
|
|
362
|
+
position: relative;
|
|
363
|
+
z-index: 2;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
.range-slider__input::-moz-range-thumb {
|
|
367
|
+
width: var(--range-thumb-size);
|
|
368
|
+
height: var(--range-thumb-size);
|
|
369
|
+
border: 0;
|
|
370
|
+
border-radius: 50%;
|
|
371
|
+
background: var(--range-color);
|
|
372
|
+
cursor: pointer;
|
|
373
|
+
pointer-events: auto;
|
|
374
|
+
position: relative;
|
|
375
|
+
z-index: 2;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.reset-range-button {
|
|
379
|
+
background-color: transparent;
|
|
380
|
+
color: #666;
|
|
381
|
+
border: 1px solid #ccc;
|
|
382
|
+
padding: 0.3rem 0.5rem;
|
|
383
|
+
border-radius: 2px;
|
|
384
|
+
cursor: pointer;
|
|
385
|
+
font-size: 0.8rem;
|
|
386
|
+
transition: background-color 0.2s;
|
|
387
|
+
height: 1.5rem;
|
|
388
|
+
display: flex;
|
|
389
|
+
align-items: center;
|
|
390
|
+
flex-shrink: 0;
|
|
391
|
+
|
|
392
|
+
&:hover {
|
|
393
|
+
background-color: #f0f0f0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
&:active {
|
|
397
|
+
background-color: #e0e0e0;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
220
400
|
}
|
|
221
401
|
}
|
|
222
402
|
</style>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<%# app/views/onlylogs/shared/_range_slider.html.erb %>
|
|
2
|
+
<%# locals: (start_name:, end_name:, start_value:, end_value:, min: 0, max: 100, step: 1, label: nil, show_reset: false) %>
|
|
3
|
+
|
|
4
|
+
<div class="range-slider range-slider--double"
|
|
5
|
+
data-log-streamer-target="rangeSliderContainer"
|
|
6
|
+
style="--range-min: <%= min %>; --range-max: <%= max %>; --range-start: <%= start_value %>; --range-end: <%= end_value %>;">
|
|
7
|
+
|
|
8
|
+
<div class="range-slider__container">
|
|
9
|
+
<% if label.present? %>
|
|
10
|
+
<label class="range-slider__label">
|
|
11
|
+
<%= label %>
|
|
12
|
+
</label>
|
|
13
|
+
<% end %>
|
|
14
|
+
|
|
15
|
+
<div class="range-slider__slider">
|
|
16
|
+
<div class="range-slider__track">
|
|
17
|
+
<div class="range-slider__selected"></div>
|
|
18
|
+
|
|
19
|
+
<input class="range-slider__input range-slider__input--start"
|
|
20
|
+
type="range"
|
|
21
|
+
name="<%= start_name %>"
|
|
22
|
+
min="<%= min %>"
|
|
23
|
+
max="<%= max %>"
|
|
24
|
+
step="<%= step %>"
|
|
25
|
+
value="<%= start_value %>"
|
|
26
|
+
data-log-streamer-target="startSlider"
|
|
27
|
+
data-action="input->log-streamer#updateRangeVisuals input->range-slider#updateVisuals change->log-streamer#updateRangeVisuals change->range-slider#updateVisuals">
|
|
28
|
+
|
|
29
|
+
<input class="range-slider__input range-slider__input--end"
|
|
30
|
+
type="range"
|
|
31
|
+
name="<%= end_name %>"
|
|
32
|
+
min="<%= min %>"
|
|
33
|
+
max="<%= max %>"
|
|
34
|
+
step="<%= step %>"
|
|
35
|
+
value="<%= end_value %>"
|
|
36
|
+
data-log-streamer-target="endSlider"
|
|
37
|
+
data-action="input->log-streamer#updateRangeVisuals input->range-slider#updateVisuals change->log-streamer#updateRangeVisuals change->range-slider#updateVisuals">
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div class="range-slider__values">
|
|
41
|
+
<output data-log-streamer-target="startOutput"><%= start_value %></output>
|
|
42
|
+
<span>–</span>
|
|
43
|
+
<output data-log-streamer-target="endOutput"><%= end_value %></output>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
data/bin/super_grep
CHANGED
|
@@ -5,7 +5,6 @@ export LC_ALL=C
|
|
|
5
5
|
max_matches=""
|
|
6
6
|
start_position=""
|
|
7
7
|
end_position=""
|
|
8
|
-
block_size="${BLOCK_SIZE:-8M}"
|
|
9
8
|
|
|
10
9
|
while [[ $# -gt 0 ]]; do
|
|
11
10
|
case "$1" in
|
|
@@ -64,26 +63,22 @@ fi
|
|
|
64
63
|
|
|
65
64
|
# Handle byte range if specified
|
|
66
65
|
if [ -n "$start_position" ] || [ -n "$end_position" ]; then
|
|
66
|
+
case "${start_position:-0}${end_position:-0}" in
|
|
67
|
+
*[!0-9]*) exit 1 ;;
|
|
68
|
+
esac
|
|
69
|
+
|
|
67
70
|
file_size=$(wc -c < "$file")
|
|
68
71
|
range_start=${start_position:-0}
|
|
69
72
|
range_end=${end_position:-$file_size}
|
|
70
73
|
range_size=$((range_end - range_start))
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if [ $range_start -lt 0 ] || [ $range_size -le 0 ] || [ $range_start -ge $file_size ]; then
|
|
74
|
+
|
|
75
|
+
if [ $range_size -le 0 ] || [ $range_start -ge $file_size ]; then
|
|
74
76
|
exit 0
|
|
75
77
|
fi
|
|
76
|
-
|
|
77
|
-
# Adjust if exceeds file size
|
|
78
|
+
|
|
78
79
|
[ $range_end -gt $file_size ] && range_end=$file_size && range_size=$((range_end - range_start))
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
start_mb=$((range_start / 1048576))
|
|
82
|
-
start_offset=$((range_start % 1048576))
|
|
83
|
-
count_mb=$(((range_size + 1048576 - 1) / 1048576))
|
|
84
|
-
|
|
85
|
-
dd if="$file" bs="$block_size" skip=$start_mb count=$count_mb 2>/dev/null | \
|
|
86
|
-
dd bs=1 skip=$start_offset count=$range_size 2>/dev/null | \
|
|
80
|
+
|
|
81
|
+
tail -c +$((range_start + 1)) "$file" | head -c $range_size | \
|
|
87
82
|
"${grep_cmd[@]}" "$query_regex"
|
|
88
83
|
else
|
|
89
84
|
# Search entire file
|