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.
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module App
5
+ class InputHandler
6
+ include Curses
7
+
8
+ # Key codes
9
+ TAB = 9
10
+ CTRL_F = 6 # Page down
11
+ CTRL_B = 2 # Page up
12
+ CTRL_D = 4 # Half page down
13
+ CTRL_U = 21 # Half page up
14
+ CTRL_C = 3 # Quit
15
+ ESC = 27 # Escape
16
+
17
+ # UI constants
18
+ DEFAULT_VISIBLE_HEIGHT = 20
19
+
20
+ def initialize(state)
21
+ self.state = state
22
+ end
23
+
24
+ def handle_input
25
+ ch = getch
26
+ return if ch == -1 || ch.nil?
27
+
28
+ if filter_mode_active?
29
+ handle_filter_input(ch)
30
+ else
31
+ handle_navigation_input(ch)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ attr_accessor :state
38
+
39
+ def filter_mode_active?
40
+ state.filter_mode || state.detail_filter_mode
41
+ end
42
+
43
+ def handle_filter_input(ch)
44
+ case ch
45
+ when 10, 13 # Enter - exit filter mode but keep filter text
46
+ if state.filter_mode
47
+ state.filter_mode = false
48
+ elsif state.detail_filter_mode
49
+ state.detail_filter_mode = false
50
+ end
51
+ when 27 # ESC - exit filter mode and clear filter
52
+ state.exit_filter_mode
53
+ when Curses::KEY_UP, "k", "K"
54
+ state.exit_filter_mode
55
+ state.navigate_up
56
+ when Curses::KEY_DOWN, "j", "J"
57
+ state.exit_filter_mode
58
+ state.navigate_down
59
+ when 127, 8 # Backspace
60
+ state.backspace_filter
61
+ else
62
+ add_character_to_filter(ch)
63
+ end
64
+ end
65
+
66
+ def add_character_to_filter(ch)
67
+ return unless printable_character?(ch)
68
+
69
+ char_to_add = if ch.is_a?(String)
70
+ ch
71
+ else
72
+ ch.chr
73
+ end
74
+
75
+ state.add_to_filter(char_to_add)
76
+ reset_selection_if_main_filter
77
+ end
78
+
79
+ def printable_character?(ch)
80
+ if ch.is_a?(String)
81
+ ch.length == 1 && ch.ord >= 32 && ch.ord <= 126
82
+ elsif ch.is_a?(Integer)
83
+ ch.between?(32, 126)
84
+ else
85
+ false
86
+ end
87
+ end
88
+
89
+ def reset_selection_if_main_filter
90
+ return unless state.filter_mode
91
+
92
+ state.selected = 0
93
+ state.scroll_offset = 0
94
+ end
95
+
96
+ def handle_navigation_input(ch)
97
+ case ch
98
+ when Curses::KEY_LEFT, "h", "H"
99
+ state.switch_to_left_pane
100
+ when Curses::KEY_RIGHT, "l", "L"
101
+ state.switch_to_right_pane
102
+ when TAB
103
+ toggle_pane_focus
104
+ when Curses::KEY_UP, "k", "K"
105
+ handle_up_navigation
106
+ when Curses::KEY_DOWN, "j", "J"
107
+ handle_down_navigation
108
+ when CTRL_F
109
+ handle_page_down
110
+ when CTRL_B
111
+ handle_page_up
112
+ when CTRL_D
113
+ handle_half_page_down
114
+ when CTRL_U
115
+ handle_half_page_up
116
+ when "g"
117
+ handle_go_to_top
118
+ when "G"
119
+ handle_go_to_bottom
120
+ when "a", "A"
121
+ state.toggle_auto_scroll
122
+ when "f", "F", "/"
123
+ state.enter_filter_mode
124
+ when "c", "C"
125
+ if state.left_pane_focused?
126
+ state.clear_filter
127
+ else
128
+ state.clear_detail_filter
129
+ end
130
+ when "s", "S"
131
+ state.cycle_sort_mode
132
+ when "q", "Q", CTRL_C
133
+ state.stop!
134
+ when ESC
135
+ handle_escape
136
+ end
137
+ end
138
+
139
+ def toggle_pane_focus
140
+ if state.left_pane_focused?
141
+ state.switch_to_right_pane
142
+ else
143
+ state.switch_to_left_pane
144
+ end
145
+ end
146
+
147
+ def handle_up_navigation
148
+ state.navigate_up
149
+ state.adjust_scroll_for_selection(visible_height) if state.left_pane_focused?
150
+ end
151
+
152
+ def handle_down_navigation
153
+ if state.left_pane_focused?
154
+ max_index = state.filtered_requests.size - 1
155
+ state.selected = [state.selected + 1, max_index].min
156
+ state.auto_scroll = false
157
+ state.adjust_scroll_for_selection(visible_height)
158
+ else
159
+ state.navigate_down
160
+ end
161
+ end
162
+
163
+ def handle_page_down
164
+ if state.left_pane_focused?
165
+ page_size = visible_height
166
+ max_index = state.filtered_requests.size - 1
167
+ state.selected = [state.selected + page_size, max_index].min
168
+ state.auto_scroll = false
169
+ state.adjust_scroll_for_selection(visible_height)
170
+ else
171
+ state.detail_scroll_offset += visible_height
172
+ end
173
+ end
174
+
175
+ def handle_page_up
176
+ if state.left_pane_focused?
177
+ page_size = visible_height
178
+ state.selected = [state.selected - page_size, 0].max
179
+ state.auto_scroll = false
180
+ state.adjust_scroll_for_selection(visible_height)
181
+ else
182
+ state.detail_scroll_offset = [state.detail_scroll_offset - visible_height, 0].max
183
+ end
184
+ end
185
+
186
+ def handle_half_page_down
187
+ if state.left_pane_focused?
188
+ half_page = visible_height / 2
189
+ max_index = state.filtered_requests.size - 1
190
+ state.selected = [state.selected + half_page, max_index].min
191
+ state.auto_scroll = false
192
+ state.adjust_scroll_for_selection(visible_height)
193
+ else
194
+ state.detail_scroll_offset += visible_height / 2
195
+ end
196
+ end
197
+
198
+ def handle_half_page_up
199
+ if state.left_pane_focused?
200
+ half_page = visible_height / 2
201
+ state.selected = [state.selected - half_page, 0].max
202
+ state.auto_scroll = false
203
+ state.adjust_scroll_for_selection(visible_height)
204
+ else
205
+ state.detail_scroll_offset = [state.detail_scroll_offset - visible_height / 2, 0].max
206
+ end
207
+ end
208
+
209
+ def handle_go_to_top
210
+ if state.left_pane_focused?
211
+ state.selected = 0
212
+ state.auto_scroll = false
213
+ state.adjust_scroll_for_selection(visible_height)
214
+ else
215
+ state.detail_scroll_offset = 0
216
+ end
217
+ end
218
+
219
+ def handle_go_to_bottom
220
+ if state.left_pane_focused?
221
+ max_index = state.filtered_requests.size - 1
222
+ state.selected = [max_index, 0].max
223
+ state.auto_scroll = false
224
+ state.adjust_scroll_for_selection(visible_height)
225
+ else
226
+ # Calculate max scroll for detail pane
227
+ state.detail_scroll_offset = 999 # Will be adjusted by renderer
228
+ end
229
+ end
230
+
231
+ def handle_escape
232
+ if state.filter_mode || state.detail_filter_mode
233
+ state.exit_filter_mode
234
+ else
235
+ state.clear_filter
236
+ end
237
+ end
238
+
239
+ def visible_height
240
+ # Approximate visible height for calculations
241
+ DEFAULT_VISIBLE_HEIGHT
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "curses"
4
+
5
+ module LogBench
6
+ module App
7
+ class Main
8
+ include Curses
9
+
10
+ # Default log file paths
11
+ DEFAULT_LOG_PATHS = %w[log/development.log].freeze
12
+
13
+ # Timing
14
+ MAIN_LOOP_SLEEP_INTERVAL = 0.05
15
+
16
+ # Error messages
17
+ LOG_FILE_NOT_FOUND = "Error: No log file found at %s!"
18
+ RAILS_PROJECT_HINT = "Please run from a Rails project directory or specify a valid log file"
19
+
20
+ def initialize(log_file_path = "log/development.log")
21
+ self.log_file_path = find_log_file(log_file_path)
22
+ self.state = State.new
23
+ validate_log_file!
24
+ end
25
+
26
+ def run
27
+ setup_screen
28
+ setup_components
29
+ load_initial_data
30
+ initial_draw
31
+ start_monitoring
32
+ main_loop
33
+ ensure
34
+ cleanup
35
+ end
36
+
37
+ private
38
+
39
+ def find_log_file(path)
40
+ candidates = [path] + DEFAULT_LOG_PATHS
41
+ candidates.find { |candidate| File.exist?(candidate) } || path
42
+ end
43
+
44
+ def validate_log_file!
45
+ unless File.exist?(log_file_path)
46
+ puts LOG_FILE_NOT_FOUND % log_file_path
47
+ puts RAILS_PROJECT_HINT
48
+ exit 1
49
+ end
50
+ end
51
+
52
+ def setup_screen
53
+ self.screen = Screen.new
54
+ screen.setup
55
+ end
56
+
57
+ def setup_components
58
+ self.input_handler = InputHandler.new(state)
59
+ self.renderer = Renderer::Main.new(screen, state)
60
+ end
61
+
62
+ def load_initial_data
63
+ log_file = Log::File.new(log_file_path)
64
+ state.requests = log_file.requests
65
+ end
66
+
67
+ def initial_draw
68
+ renderer.draw
69
+ end
70
+
71
+ def start_monitoring
72
+ self.monitor = Monitor.new(log_file_path, state)
73
+ monitor.start
74
+ end
75
+
76
+ def main_loop
77
+ loop do
78
+ break unless state.running?
79
+
80
+ renderer.draw
81
+ input_handler.handle_input
82
+ sleep MAIN_LOOP_SLEEP_INTERVAL
83
+ end
84
+ end
85
+
86
+ def cleanup
87
+ monitor&.stop
88
+ screen&.cleanup
89
+ end
90
+
91
+ private
92
+
93
+ attr_accessor :log_file_path, :state, :screen, :monitor, :input_handler, :renderer
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module App
5
+ class Monitor
6
+ def initialize(log_file_path, state)
7
+ self.log_file_path = log_file_path
8
+ self.state = state
9
+ self.running = false
10
+ end
11
+
12
+ def start
13
+ return if running
14
+
15
+ self.running = true
16
+ self.thread = Thread.new { monitor_loop }
17
+ end
18
+
19
+ def stop
20
+ self.running = false
21
+ thread&.kill
22
+ end
23
+
24
+ private
25
+
26
+ attr_accessor :log_file_path, :state, :thread, :running
27
+
28
+ def monitor_loop
29
+ log_file = Log::File.new(log_file_path)
30
+
31
+ loop do
32
+ break unless running
33
+
34
+ begin
35
+ log_file.watch do |new_collection|
36
+ add_new_requests(new_collection.requests)
37
+ end
38
+ rescue
39
+ # Log error but continue monitoring
40
+ # In a real app, you might want to handle this differently
41
+ sleep 1
42
+ end
43
+ end
44
+ end
45
+
46
+ def add_new_requests(new_requests)
47
+ return if new_requests.empty?
48
+
49
+ state.requests.concat(new_requests)
50
+ keep_recent_requests
51
+ end
52
+
53
+ def keep_recent_requests
54
+ # Keep only the last 1000 requests to prevent memory issues
55
+ state.requests = state.requests.last(1000) if state.requests.size > 1000
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module App
5
+ module Renderer
6
+ class Ansi
7
+ include Curses
8
+
9
+ def initialize(screen)
10
+ self.screen = screen
11
+ end
12
+
13
+ def has_ansi_codes?(text)
14
+ text.match?(/\e\[[0-9;]*m/)
15
+ end
16
+
17
+ def parse_and_render(text, win)
18
+ # Parse ANSI escape codes and render with proper colors
19
+ parts = text.split(/(\e\[[0-9;]*m)/)
20
+ current_color = nil
21
+
22
+ parts.each do |part|
23
+ if part =~ /\e\[([0-9;]*)m/
24
+ # ANSI escape code
25
+ codes = $1.split(";").map(&:to_i)
26
+ current_color = ansi_to_curses_color(codes)
27
+ elsif current_color && !part.empty?
28
+ # Text content
29
+ win.attron(current_color) { win.addstr(part) }
30
+ elsif !part.empty?
31
+ win.addstr(part)
32
+ end
33
+ end
34
+ end
35
+
36
+ def wrap_ansi_text(text, max_width)
37
+ # For ANSI text, we need to preserve color state across chunks
38
+ clean_text = text.gsub(/\e\[[0-9;]*m/, "")
39
+
40
+ if clean_text.length <= max_width
41
+ [text]
42
+ else
43
+ chunks = []
44
+ remaining = text
45
+ active_colors = []
46
+
47
+ # Extract initial color state
48
+ text.scan(/\e\[[0-9;]*m/) do |ansi_code|
49
+ if /\e\[0m/.match?(ansi_code)
50
+ active_colors.clear
51
+ else
52
+ active_colors << ansi_code
53
+ end
54
+ end
55
+
56
+ while remaining.length > 0
57
+ clean_remaining = remaining.gsub(/\e\[[0-9;]*m/, "")
58
+
59
+ if clean_remaining.length <= max_width
60
+ # Last chunk
61
+ chunks << if active_colors.any? && !remaining.start_with?(*active_colors)
62
+ active_colors.join("") + remaining
63
+ else
64
+ remaining
65
+ end
66
+ break
67
+ else
68
+ # Find break point and preserve color state
69
+ break_point = max_width
70
+ original_pos = 0
71
+ clean_pos = 0
72
+ chunk_colors = active_colors.dup
73
+
74
+ remaining.each_char.with_index do |char, idx|
75
+ if /^\e\[[0-9;]*m/.match?(remaining[idx..])
76
+ # Found ANSI sequence
77
+ ansi_match = remaining[idx..].match(/^(\e\[[0-9;]*m)/)
78
+ ansi_code = ansi_match[1]
79
+
80
+ if /\e\[0m/.match?(ansi_code)
81
+ chunk_colors.clear
82
+ active_colors.clear
83
+ else
84
+ chunk_colors << ansi_code unless chunk_colors.include?(ansi_code)
85
+ active_colors << ansi_code unless active_colors.include?(ansi_code)
86
+ end
87
+
88
+ original_pos += ansi_code.length
89
+ idx + ansi_code.length - 1
90
+ else
91
+ clean_pos += 1
92
+ original_pos += 1
93
+
94
+ if clean_pos >= break_point
95
+ break
96
+ end
97
+ end
98
+ end
99
+
100
+ chunk_text = remaining[0...original_pos]
101
+ chunks << if active_colors.any? && !chunk_text.start_with?(*active_colors)
102
+ active_colors.join("") + chunk_text
103
+ else
104
+ chunk_text
105
+ end
106
+
107
+ remaining = remaining[original_pos..]
108
+ end
109
+ end
110
+
111
+ chunks
112
+ end
113
+ end
114
+
115
+ def wrap_plain_text(text, max_width)
116
+ # Simple text wrapping for plain text
117
+ if text.length <= max_width
118
+ [text]
119
+ else
120
+ chunks = []
121
+ remaining = text
122
+
123
+ while remaining.length > 0
124
+ if remaining.length <= max_width
125
+ chunks << remaining
126
+ break
127
+ else
128
+ # Find a good break point (try to break on spaces)
129
+ break_point = max_width
130
+ if remaining[0...max_width].include?(" ")
131
+ # Find the last space within the limit
132
+ break_point = remaining[0...max_width].rindex(" ") || max_width
133
+ end
134
+
135
+ chunks << remaining[0...break_point]
136
+ remaining = remaining[break_point..].lstrip
137
+ end
138
+ end
139
+
140
+ chunks
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ attr_accessor :screen
147
+
148
+ def ansi_to_curses_color(codes)
149
+ # Convert ANSI color codes to curses color pairs
150
+ return nil if codes.empty? || codes == [0]
151
+
152
+ # Handle common ANSI codes
153
+ codes.each do |code|
154
+ case code
155
+ when 1 then return color_pair(7) | A_BOLD # Bold/bright
156
+ when 30 then return color_pair(8) # Black
157
+ when 31 then return color_pair(6) # Red
158
+ when 32 then return color_pair(3) # Green
159
+ when 33 then return color_pair(4) # Yellow
160
+ when 34 then return color_pair(5) # Blue
161
+ when 35 then return color_pair(9) # Magenta
162
+ when 36 then return color_pair(1) # Cyan
163
+ when 37 then return nil # White (default)
164
+ end
165
+ end
166
+
167
+ nil
168
+ end
169
+
170
+ def color_pair(n)
171
+ screen.color_pair(n)
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end