log_bench 0.1.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 +7 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +230 -0
- data/Rakefile +10 -0
- data/exe/log_bench +87 -0
- data/lib/log_bench/app/filter.rb +60 -0
- data/lib/log_bench/app/input_handler.rb +245 -0
- data/lib/log_bench/app/main.rb +96 -0
- data/lib/log_bench/app/monitor.rb +59 -0
- data/lib/log_bench/app/renderer/ansi.rb +176 -0
- data/lib/log_bench/app/renderer/details.rb +488 -0
- data/lib/log_bench/app/renderer/header.rb +99 -0
- data/lib/log_bench/app/renderer/main.rb +30 -0
- data/lib/log_bench/app/renderer/request_list.rb +211 -0
- data/lib/log_bench/app/renderer/scrollbar.rb +38 -0
- data/lib/log_bench/app/screen.rb +96 -0
- data/lib/log_bench/app/sort.rb +61 -0
- data/lib/log_bench/app/state.rb +175 -0
- data/lib/log_bench/json_formatter.rb +92 -0
- data/lib/log_bench/log/cache_entry.rb +82 -0
- data/lib/log_bench/log/call_line_entry.rb +60 -0
- data/lib/log_bench/log/collection.rb +98 -0
- data/lib/log_bench/log/entry.rb +108 -0
- data/lib/log_bench/log/file.rb +103 -0
- data/lib/log_bench/log/parser.rb +64 -0
- data/lib/log_bench/log/query_entry.rb +132 -0
- data/lib/log_bench/log/request.rb +90 -0
- data/lib/log_bench/railtie.rb +45 -0
- data/lib/log_bench/version.rb +5 -0
- data/lib/log_bench.rb +26 -0
- data/lib/tasks/log_bench.rake +97 -0
- data/logbench-preview.png +0 -0
- metadata +171 -0
@@ -0,0 +1,211 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LogBench
|
4
|
+
module App
|
5
|
+
module Renderer
|
6
|
+
class RequestList
|
7
|
+
include Curses
|
8
|
+
|
9
|
+
# Layout constants
|
10
|
+
HEADER_Y_OFFSET = 2
|
11
|
+
COLUMN_HEADER_Y = 1
|
12
|
+
MIN_FILTER_X_POSITION = 20
|
13
|
+
FILTER_X_MARGIN = 3
|
14
|
+
|
15
|
+
# Column widths
|
16
|
+
METHOD_WIDTH = 8
|
17
|
+
STATUS_WIDTH = 8
|
18
|
+
PATH_MARGIN = 27
|
19
|
+
|
20
|
+
# Color constants
|
21
|
+
HEADER_CYAN = 1
|
22
|
+
DEFAULT_WHITE = 2
|
23
|
+
WARNING_YELLOW = 4
|
24
|
+
SELECTION_HIGHLIGHT = 10
|
25
|
+
|
26
|
+
def initialize(screen, state, scrollbar)
|
27
|
+
self.screen = screen
|
28
|
+
self.state = state
|
29
|
+
self.scrollbar = scrollbar
|
30
|
+
end
|
31
|
+
|
32
|
+
def draw
|
33
|
+
log_win.erase
|
34
|
+
log_win.box(0, 0)
|
35
|
+
|
36
|
+
draw_header
|
37
|
+
draw_column_headers
|
38
|
+
draw_rows
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
attr_accessor :screen, :state, :scrollbar
|
44
|
+
|
45
|
+
def draw_header
|
46
|
+
log_win.setpos(0, HEADER_Y_OFFSET)
|
47
|
+
|
48
|
+
if state.left_pane_focused?
|
49
|
+
log_win.attron(color_pair(HEADER_CYAN) | A_BOLD) { log_win.addstr(" Request Logs ") }
|
50
|
+
else
|
51
|
+
log_win.attron(color_pair(DEFAULT_WHITE) | A_DIM) { log_win.addstr(" Request Logs ") }
|
52
|
+
end
|
53
|
+
|
54
|
+
show_filter_in_header if show_filter?
|
55
|
+
end
|
56
|
+
|
57
|
+
def show_filter?
|
58
|
+
state.main_filter.present? || state.main_filter.active?
|
59
|
+
end
|
60
|
+
|
61
|
+
def show_filter_in_header
|
62
|
+
filter_text = "Filter: #{state.main_filter.cursor_display}"
|
63
|
+
filter_x = log_win.maxx - filter_text.length - FILTER_X_MARGIN
|
64
|
+
|
65
|
+
if filter_x > MIN_FILTER_X_POSITION
|
66
|
+
log_win.setpos(0, filter_x)
|
67
|
+
log_win.attron(color_pair(WARNING_YELLOW)) { log_win.addstr(filter_text) }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def draw_column_headers
|
72
|
+
log_win.setpos(COLUMN_HEADER_Y, HEADER_Y_OFFSET)
|
73
|
+
log_win.attron(color_pair(HEADER_CYAN) | A_DIM) do
|
74
|
+
log_win.addstr("METHOD".ljust(METHOD_WIDTH))
|
75
|
+
log_win.addstr("PATH".ljust(screen.panel_width - PATH_MARGIN))
|
76
|
+
log_win.addstr("STATUS".ljust(STATUS_WIDTH))
|
77
|
+
log_win.addstr("TIME")
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def draw_rows
|
82
|
+
filtered_requests = state.filtered_requests
|
83
|
+
visible_height = log_win.maxy - 3
|
84
|
+
|
85
|
+
return draw_no_requests_message if filtered_requests.empty?
|
86
|
+
|
87
|
+
state.adjust_auto_scroll(visible_height)
|
88
|
+
state.adjust_scroll_bounds(visible_height)
|
89
|
+
|
90
|
+
visible_height.times do |i|
|
91
|
+
request_index = state.scroll_offset + i
|
92
|
+
break if request_index >= filtered_requests.size
|
93
|
+
|
94
|
+
draw_row(filtered_requests[request_index], request_index, i + 2)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Draw scrollbar if needed
|
98
|
+
if filtered_requests.size > visible_height
|
99
|
+
scrollbar.draw(log_win, visible_height, state.scroll_offset, filtered_requests.size)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def draw_no_requests_message
|
104
|
+
log_win.setpos(log_win.maxy / 2, 3)
|
105
|
+
log_win.attron(A_DIM) { log_win.addstr("No requests found") }
|
106
|
+
end
|
107
|
+
|
108
|
+
def draw_row(request, request_index, y_position)
|
109
|
+
log_win.setpos(y_position, 1)
|
110
|
+
|
111
|
+
if request_index == state.selected
|
112
|
+
log_win.attron(color_pair(10) | A_DIM) do
|
113
|
+
log_win.addstr(" " * (screen.panel_width - 4))
|
114
|
+
end
|
115
|
+
log_win.setpos(y_position, 1)
|
116
|
+
end
|
117
|
+
|
118
|
+
draw_method_badge(request, request_index)
|
119
|
+
draw_path_column(request, request_index)
|
120
|
+
draw_status_column(request, request_index)
|
121
|
+
draw_duration_column(request, request_index)
|
122
|
+
end
|
123
|
+
|
124
|
+
def draw_method_badge(request, request_index)
|
125
|
+
method_color = method_color_for(request.method)
|
126
|
+
|
127
|
+
if request_index == state.selected
|
128
|
+
log_win.attron(color_pair(10) | A_DIM) do
|
129
|
+
log_win.addstr(" #{request.method.ljust(7)} ")
|
130
|
+
end
|
131
|
+
else
|
132
|
+
log_win.attron(color_pair(method_color) | A_BOLD) do
|
133
|
+
log_win.addstr(" #{request.method.ljust(7)} ")
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def draw_path_column(request, request_index)
|
139
|
+
path_width = screen.panel_width - 27
|
140
|
+
path = request.path[0, path_width] || ""
|
141
|
+
|
142
|
+
if request_index == state.selected
|
143
|
+
log_win.attron(color_pair(10) | A_DIM) do
|
144
|
+
log_win.addstr(path.ljust(path_width))
|
145
|
+
end
|
146
|
+
else
|
147
|
+
log_win.addstr(path.ljust(path_width))
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def draw_status_column(request, request_index)
|
152
|
+
status_col_start = screen.panel_width - 14
|
153
|
+
|
154
|
+
if request.status
|
155
|
+
status_color = status_color_for(request.status)
|
156
|
+
status_text = request.status.to_s.rjust(3)
|
157
|
+
|
158
|
+
log_win.setpos(log_win.cury, status_col_start)
|
159
|
+
if request_index == state.selected
|
160
|
+
log_win.attron(color_pair(10) | A_DIM) { log_win.addstr(status_text + " ") }
|
161
|
+
else
|
162
|
+
log_win.attron(color_pair(status_color)) { log_win.addstr(status_text + " ") }
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def draw_duration_column(request, request_index)
|
168
|
+
duration_col_start = screen.panel_width - 9
|
169
|
+
|
170
|
+
if request.duration
|
171
|
+
duration_text = "#{request.duration.to_i}ms".ljust(6)
|
172
|
+
|
173
|
+
log_win.setpos(log_win.cury, duration_col_start)
|
174
|
+
if request_index == state.selected
|
175
|
+
log_win.attron(color_pair(10) | A_DIM) { log_win.addstr(duration_text + " ") }
|
176
|
+
else
|
177
|
+
log_win.attron(A_DIM) { log_win.addstr(duration_text + " ") }
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
def method_color_for(method)
|
183
|
+
case method
|
184
|
+
when "GET" then 3
|
185
|
+
when "POST" then 4
|
186
|
+
when "PUT" then 5
|
187
|
+
when "DELETE" then 6
|
188
|
+
else 2
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def status_color_for(status)
|
193
|
+
case status
|
194
|
+
when 200..299 then 3
|
195
|
+
when 300..399 then 4
|
196
|
+
when 400..599 then 6
|
197
|
+
else 2
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
def color_pair(n)
|
202
|
+
screen.color_pair(n)
|
203
|
+
end
|
204
|
+
|
205
|
+
def log_win
|
206
|
+
screen.log_win
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LogBench
|
4
|
+
module App
|
5
|
+
module Renderer
|
6
|
+
class Scrollbar
|
7
|
+
include Curses
|
8
|
+
|
9
|
+
def initialize(screen)
|
10
|
+
self.screen = screen
|
11
|
+
end
|
12
|
+
|
13
|
+
def draw(win, height, offset, total)
|
14
|
+
return if total <= height
|
15
|
+
|
16
|
+
scrollbar_height = [(height * height / total), 1].max
|
17
|
+
scrollbar_pos = offset * height / total
|
18
|
+
|
19
|
+
x = win.maxx - 2 # Position scrollbar closer to the border
|
20
|
+
height.times do |i|
|
21
|
+
win.setpos(i + 1, x)
|
22
|
+
if i >= scrollbar_pos && i < scrollbar_pos + scrollbar_height
|
23
|
+
win.attron(color_pair(1)) { win.addstr("█") } # Solid block for scrollbar thumb
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
attr_accessor :screen
|
31
|
+
|
32
|
+
def color_pair(n)
|
33
|
+
screen.color_pair(n)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LogBench
|
4
|
+
module App
|
5
|
+
class Screen
|
6
|
+
include Curses
|
7
|
+
|
8
|
+
# Layout constants
|
9
|
+
HEADER_HEIGHT = 5
|
10
|
+
PANEL_BORDER_WIDTH = 3
|
11
|
+
INPUT_TIMEOUT_MS = 200
|
12
|
+
|
13
|
+
# Color pairs
|
14
|
+
HEADER_CYAN = 1
|
15
|
+
DEFAULT_WHITE = 2
|
16
|
+
SUCCESS_GREEN = 3 # GET requests, 200 status
|
17
|
+
WARNING_YELLOW = 4 # POST requests, warnings
|
18
|
+
INFO_BLUE = 5 # PUT requests
|
19
|
+
ERROR_RED = 6 # DELETE requests, errors
|
20
|
+
BRIGHT_WHITE = 7
|
21
|
+
BLACK = 8
|
22
|
+
MAGENTA = 9
|
23
|
+
SELECTION_HIGHLIGHT = 10
|
24
|
+
|
25
|
+
attr_reader :header_win, :log_win, :panel_width, :detail_win
|
26
|
+
|
27
|
+
def setup
|
28
|
+
init_screen
|
29
|
+
setup_colors
|
30
|
+
clear_screen_immediately
|
31
|
+
setup_windows
|
32
|
+
end
|
33
|
+
|
34
|
+
def cleanup
|
35
|
+
close_screen
|
36
|
+
end
|
37
|
+
|
38
|
+
def refresh_all
|
39
|
+
header_win.refresh
|
40
|
+
log_win.refresh
|
41
|
+
detail_win.refresh
|
42
|
+
refresh
|
43
|
+
end
|
44
|
+
|
45
|
+
def height
|
46
|
+
lines
|
47
|
+
end
|
48
|
+
|
49
|
+
def width
|
50
|
+
cols
|
51
|
+
end
|
52
|
+
|
53
|
+
def color_pair(n)
|
54
|
+
Curses.color_pair(n)
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
attr_writer :header_win, :log_win, :panel_width, :detail_win
|
60
|
+
|
61
|
+
def clear_screen_immediately
|
62
|
+
clear
|
63
|
+
refresh
|
64
|
+
end
|
65
|
+
|
66
|
+
def setup_colors
|
67
|
+
start_color
|
68
|
+
cbreak
|
69
|
+
noecho
|
70
|
+
curs_set(0)
|
71
|
+
stdscr.keypad(true)
|
72
|
+
stdscr.timeout = INPUT_TIMEOUT_MS
|
73
|
+
|
74
|
+
# Define color pairs
|
75
|
+
init_pair(HEADER_CYAN, COLOR_CYAN, COLOR_BLACK) # Header/Cyan
|
76
|
+
init_pair(DEFAULT_WHITE, COLOR_WHITE, COLOR_BLACK) # Default/White
|
77
|
+
init_pair(SUCCESS_GREEN, COLOR_GREEN, COLOR_BLACK) # GET/Success/Green
|
78
|
+
init_pair(WARNING_YELLOW, COLOR_YELLOW, COLOR_BLACK) # POST/Warning/Yellow
|
79
|
+
init_pair(INFO_BLUE, COLOR_BLUE, COLOR_BLACK) # PUT/Blue
|
80
|
+
init_pair(ERROR_RED, COLOR_RED, COLOR_BLACK) # DELETE/Error/Red
|
81
|
+
init_pair(BRIGHT_WHITE, COLOR_WHITE, COLOR_BLACK) # Bold/Bright white
|
82
|
+
init_pair(BLACK, COLOR_BLACK, COLOR_BLACK) # Black
|
83
|
+
init_pair(MAGENTA, COLOR_MAGENTA, COLOR_BLACK) # Magenta
|
84
|
+
init_pair(SELECTION_HIGHLIGHT, COLOR_BLACK, COLOR_CYAN) # Selection highlighting
|
85
|
+
end
|
86
|
+
|
87
|
+
def setup_windows
|
88
|
+
self.panel_width = width / 2 - 2
|
89
|
+
|
90
|
+
self.header_win = Window.new(HEADER_HEIGHT, width, 0, 0)
|
91
|
+
self.log_win = Window.new(height - HEADER_HEIGHT, panel_width, HEADER_HEIGHT, 0)
|
92
|
+
self.detail_win = Window.new(height - HEADER_HEIGHT, width - panel_width - PANEL_BORDER_WIDTH, HEADER_HEIGHT, panel_width + PANEL_BORDER_WIDTH)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module LogBench
|
2
|
+
module App
|
3
|
+
class Sort
|
4
|
+
MODES = [:timestamp, :duration, :method, :status].freeze
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
self.mode = :timestamp
|
8
|
+
end
|
9
|
+
|
10
|
+
def cycle
|
11
|
+
current_index = MODES.index(mode)
|
12
|
+
next_index = (current_index + 1) % MODES.length
|
13
|
+
self.mode = MODES[next_index]
|
14
|
+
end
|
15
|
+
|
16
|
+
def display_name
|
17
|
+
case mode
|
18
|
+
when :timestamp then "TIMESTAMP"
|
19
|
+
when :duration then "DURATION"
|
20
|
+
when :method then "METHOD"
|
21
|
+
when :status then "STATUS"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def sort_requests(requests)
|
26
|
+
case mode
|
27
|
+
when :timestamp
|
28
|
+
requests.sort_by { |req| req.timestamp || Time.at(0) }
|
29
|
+
when :duration
|
30
|
+
requests.sort_by { |req| -(req.duration || 0) } # Descending (slowest first)
|
31
|
+
when :method
|
32
|
+
requests.sort_by { |req| req.method || "" }
|
33
|
+
when :status
|
34
|
+
requests.sort_by { |req| -(req.status || 0) } # Descending (errors first)
|
35
|
+
else
|
36
|
+
requests
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def timestamp?
|
41
|
+
mode == :timestamp
|
42
|
+
end
|
43
|
+
|
44
|
+
def duration?
|
45
|
+
mode == :duration
|
46
|
+
end
|
47
|
+
|
48
|
+
def method?
|
49
|
+
mode == :method
|
50
|
+
end
|
51
|
+
|
52
|
+
def status?
|
53
|
+
mode == :status
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
attr_accessor :mode
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,175 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LogBench
|
4
|
+
module App
|
5
|
+
class State
|
6
|
+
attr_reader :main_filter, :sort, :detail_filter
|
7
|
+
attr_accessor :requests, :auto_scroll, :scroll_offset, :selected, :detail_scroll_offset
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
self.requests = []
|
11
|
+
self.selected = 0
|
12
|
+
self.scroll_offset = 0
|
13
|
+
self.auto_scroll = true
|
14
|
+
self.running = true
|
15
|
+
self.focused_pane = :left
|
16
|
+
self.detail_scroll_offset = 0
|
17
|
+
self.main_filter = Filter.new
|
18
|
+
self.detail_filter = Filter.new
|
19
|
+
self.sort = Sort.new
|
20
|
+
end
|
21
|
+
|
22
|
+
def running?
|
23
|
+
running
|
24
|
+
end
|
25
|
+
|
26
|
+
def stop!
|
27
|
+
self.running = false
|
28
|
+
end
|
29
|
+
|
30
|
+
def toggle_auto_scroll
|
31
|
+
self.auto_scroll = !auto_scroll
|
32
|
+
end
|
33
|
+
|
34
|
+
def clear_filter
|
35
|
+
main_filter.clear
|
36
|
+
self.selected = 0
|
37
|
+
self.scroll_offset = 0
|
38
|
+
end
|
39
|
+
|
40
|
+
def clear_detail_filter
|
41
|
+
detail_filter.clear
|
42
|
+
self.detail_scroll_offset = 0
|
43
|
+
end
|
44
|
+
|
45
|
+
def cycle_sort_mode
|
46
|
+
sort.cycle
|
47
|
+
end
|
48
|
+
|
49
|
+
def switch_to_left_pane
|
50
|
+
self.focused_pane = :left
|
51
|
+
end
|
52
|
+
|
53
|
+
def switch_to_right_pane
|
54
|
+
self.focused_pane = :right
|
55
|
+
end
|
56
|
+
|
57
|
+
def left_pane_focused?
|
58
|
+
focused_pane == :left
|
59
|
+
end
|
60
|
+
|
61
|
+
def right_pane_focused?
|
62
|
+
focused_pane == :right
|
63
|
+
end
|
64
|
+
|
65
|
+
def enter_filter_mode
|
66
|
+
if left_pane_focused?
|
67
|
+
main_filter.enter_mode
|
68
|
+
else
|
69
|
+
detail_filter.enter_mode
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def exit_filter_mode
|
74
|
+
main_filter.exit_mode
|
75
|
+
detail_filter.exit_mode
|
76
|
+
end
|
77
|
+
|
78
|
+
def add_to_filter(char)
|
79
|
+
if main_filter.active?
|
80
|
+
main_filter.add_character(char)
|
81
|
+
elsif detail_filter.active?
|
82
|
+
detail_filter.add_character(char)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def backspace_filter
|
87
|
+
if main_filter.active?
|
88
|
+
main_filter.remove_character
|
89
|
+
elsif detail_filter.active?
|
90
|
+
detail_filter.remove_character
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def filter_mode
|
95
|
+
main_filter.active?
|
96
|
+
end
|
97
|
+
|
98
|
+
def detail_filter_mode
|
99
|
+
detail_filter.active?
|
100
|
+
end
|
101
|
+
|
102
|
+
def filtered_requests
|
103
|
+
# First apply filter - match exactly like original logtail
|
104
|
+
filtered = if main_filter.present?
|
105
|
+
requests.select do |req|
|
106
|
+
main_filter.matches?(req.path) ||
|
107
|
+
main_filter.matches?(req.method) ||
|
108
|
+
main_filter.matches?(req.controller) ||
|
109
|
+
main_filter.matches?(req.action) ||
|
110
|
+
main_filter.matches?(req.request_id)
|
111
|
+
end
|
112
|
+
else
|
113
|
+
requests
|
114
|
+
end
|
115
|
+
|
116
|
+
# Then apply sorting using Sort domain object
|
117
|
+
sort.sort_requests(filtered)
|
118
|
+
end
|
119
|
+
|
120
|
+
def current_request
|
121
|
+
filtered = filtered_requests
|
122
|
+
return nil if selected >= filtered.size || filtered.empty?
|
123
|
+
|
124
|
+
filtered[selected]
|
125
|
+
end
|
126
|
+
|
127
|
+
def navigate_up
|
128
|
+
if left_pane_focused?
|
129
|
+
self.selected = [selected - 1, 0].max
|
130
|
+
self.auto_scroll = false
|
131
|
+
else
|
132
|
+
self.detail_scroll_offset = [detail_scroll_offset - 1, 0].max
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def navigate_down
|
137
|
+
if left_pane_focused?
|
138
|
+
max_index = filtered_requests.size - 1
|
139
|
+
self.selected = [selected + 1, max_index].min
|
140
|
+
self.auto_scroll = false
|
141
|
+
else
|
142
|
+
self.detail_scroll_offset += 1
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def adjust_scroll_for_selection(visible_height)
|
147
|
+
return unless left_pane_focused?
|
148
|
+
|
149
|
+
if selected < scroll_offset
|
150
|
+
self.scroll_offset = selected
|
151
|
+
elsif selected >= scroll_offset + visible_height
|
152
|
+
self.scroll_offset = selected - visible_height + 1
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def adjust_auto_scroll(visible_height)
|
157
|
+
return unless auto_scroll && !filtered_requests.empty?
|
158
|
+
|
159
|
+
self.selected = filtered_requests.size - 1
|
160
|
+
self.scroll_offset = [selected - visible_height + 1, 0].max
|
161
|
+
end
|
162
|
+
|
163
|
+
def adjust_scroll_bounds(visible_height)
|
164
|
+
filtered = filtered_requests
|
165
|
+
max_offset = [filtered.size - visible_height, 0].max
|
166
|
+
self.scroll_offset = scroll_offset.clamp(0, max_offset)
|
167
|
+
end
|
168
|
+
|
169
|
+
private
|
170
|
+
|
171
|
+
attr_accessor :focused_pane, :running
|
172
|
+
attr_writer :main_filter, :detail_filter, :sort
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module LogBench
|
6
|
+
# A simple JSON formatter for Rails loggers that creates LogBench-compatible entries
|
7
|
+
# for non-request logging (background jobs, custom events, etc.)
|
8
|
+
#
|
9
|
+
# Usage:
|
10
|
+
# logger = Logger.new(Rails.root.join('log', 'custom.log'))
|
11
|
+
# logger.formatter = LogBench::JsonFormatter.new
|
12
|
+
# logger.info("Background job completed", job_id: 123)
|
13
|
+
#
|
14
|
+
class JsonFormatter < ::Logger::Formatter
|
15
|
+
def call(severity, timestamp, progname, message)
|
16
|
+
log_entry = build_log_entry(severity, timestamp, progname, message)
|
17
|
+
log_entry.to_json + "\n"
|
18
|
+
rescue
|
19
|
+
# Fallback to simple format if JSON generation fails
|
20
|
+
"#{timestamp} [#{severity}] #{progname}: #{message}\n"
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def build_log_entry(severity, timestamp, progname, message)
|
26
|
+
entry = message_to_hash(message)
|
27
|
+
entry = parse_lograge_message(entry[:message]) if lograge_message?(entry)
|
28
|
+
request_id = current_request_id
|
29
|
+
|
30
|
+
entry.merge!(
|
31
|
+
level: severity,
|
32
|
+
timestamp: timestamp.utc.iso8601(3),
|
33
|
+
time: timestamp.to_f,
|
34
|
+
request_id: request_id,
|
35
|
+
progname: progname
|
36
|
+
).compact
|
37
|
+
end
|
38
|
+
|
39
|
+
def message_to_hash(message)
|
40
|
+
case message
|
41
|
+
when String
|
42
|
+
{message: message}
|
43
|
+
when Hash
|
44
|
+
message.dup
|
45
|
+
when Exception
|
46
|
+
{
|
47
|
+
message: "#{message.class}: #{message.message}",
|
48
|
+
error_class: message.class.name,
|
49
|
+
error_message: message.message
|
50
|
+
}
|
51
|
+
else
|
52
|
+
{message: message.to_s}
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def lograge_message?(entry)
|
57
|
+
# Check if the message field contains lograge JSON
|
58
|
+
return false unless entry[:message].is_a?(String) && entry[:message].start_with?("{")
|
59
|
+
|
60
|
+
# Try to parse and check for lograge fields
|
61
|
+
begin
|
62
|
+
parsed = JSON.parse(entry[:message])
|
63
|
+
parsed.is_a?(Hash) && parsed["method"] && parsed["path"] && parsed["status"]
|
64
|
+
rescue JSON::ParserError
|
65
|
+
false
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def parse_lograge_message(message_string)
|
70
|
+
# Parse the lograge JSON string
|
71
|
+
JSON.parse(message_string)
|
72
|
+
rescue JSON::ParserError
|
73
|
+
nil
|
74
|
+
end
|
75
|
+
|
76
|
+
def current_request_id
|
77
|
+
# Try multiple ways to get the current request ID
|
78
|
+
request_id = nil
|
79
|
+
|
80
|
+
if defined?(Current) && Current.respond_to?(:request_id)
|
81
|
+
request_id = Current.request_id
|
82
|
+
elsif defined?(RequestStore) && RequestStore.exist?(:request_id)
|
83
|
+
request_id = RequestStore.read(:request_id)
|
84
|
+
elsif Thread.current[:request_id]
|
85
|
+
request_id = Thread.current[:request_id]
|
86
|
+
end
|
87
|
+
|
88
|
+
# Return nil if no request ID found - let caller decide whether to generate UUID
|
89
|
+
request_id
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|