log_bench 0.6.1 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: efceb488451ea80af50506acf233cdd12dd2a1c4ba3850fb736c3f4551b3315f
4
- data.tar.gz: 6be0bbdf3b939baf9983f46818080ce573073a1d13fe1b192172aecb40300a78
3
+ metadata.gz: 7db70766181d9cd210a188a6663ebcac7060c7ff1247c94f1fdc6df153c354c0
4
+ data.tar.gz: 2a8a6b811a20ab829d71c14fb7fb2e65c8a83cbe73f77a7901e7f99fac05f751
5
5
  SHA512:
6
- metadata.gz: 9440c08c8077022940adfc6978074b8eda98803253477df4f06aaaef00beb6d1e33f0955a196071f821839b9efcaa7db9cd2360ff7f6c873d2b97e55e5155ff0
7
- data.tar.gz: a9ec7faac49e2d133d2a71c9305090557c68c5c431ae9b645c39ca61b51f612f5a2c13a1439d5e6a1f0fb88acf7d012187740d2692e15c96d72504335c733564
6
+ metadata.gz: '096810750838d2fd337d2f11d91aa71c0be93fdf5dd44893e76025b9a620bd6dd8201e8d08d723e9a68035e8b4ea0e43f5b0e8a7871095c2ffb3871e275758ab'
7
+ data.tar.gz: fa2037148f4bb58d11eb5c018befb646f8f2e2ab658e213a8563df7763b58badef52939673874c0bea375d467235a48ca7899500f05be2f017685619f7015d62
@@ -16,6 +16,7 @@ module LogBench
16
16
  CTRL_R = 18 # Undo clear requests (restore)
17
17
  ESC = 27 # Escape
18
18
  BACKSPACE_KEYS = [127, 8, KEY_BACKSPACE].freeze
19
+ SHIFT_TAB_KEYS = [353, defined?(KEY_BTAB) && KEY_BTAB].compact.uniq.freeze
19
20
  EXIT_FILTER_MODE_KEYS = [27, 10, 13].freeze # Escape, Enter, Return
20
21
 
21
22
  # UI constants
@@ -66,6 +67,10 @@ module LogBench
66
67
  case ch
67
68
  when *EXIT_FILTER_MODE_KEYS
68
69
  state.exit_filter_mode
70
+ when KEY_LEFT, *SHIFT_TAB_KEYS
71
+ handle_filter_left_navigation
72
+ when KEY_RIGHT, TAB
73
+ handle_filter_right_navigation
69
74
  when KEY_UP
70
75
  state.exit_filter_mode
71
76
  state.navigate_up
@@ -79,6 +84,18 @@ module LogBench
79
84
  end
80
85
  end
81
86
 
87
+ def handle_filter_left_navigation
88
+ if state.filter_mode
89
+ state.previous_request_filter_column
90
+ end
91
+ end
92
+
93
+ def handle_filter_right_navigation
94
+ if state.filter_mode
95
+ state.next_request_filter_column
96
+ end
97
+ end
98
+
82
99
  def add_character_to_filter(ch)
83
100
  return unless printable_character?(ch)
84
101
 
@@ -5,9 +5,6 @@ module LogBench
5
5
  class MouseHandler
6
6
  include Curses
7
7
 
8
- # UI constants
9
- DEFAULT_VISIBLE_HEIGHT = 20
10
-
11
8
  def initialize(state, screen)
12
9
  self.state = state
13
10
  self.screen = screen
@@ -36,6 +33,16 @@ module LogBench
36
33
  # Switch to left pane if not already focused
37
34
  state.switch_to_left_pane unless state.left_pane_focused?
38
35
 
36
+ if click_on_column_header_row?(y)
37
+ handle_request_header_click(x)
38
+ return
39
+ end
40
+
41
+ if click_on_request_filter_row?(y)
42
+ handle_request_filter_click(x)
43
+ return
44
+ end
45
+
39
46
  # Convert click coordinates to request index
40
47
  request_index = click_to_request_index(y)
41
48
  return unless request_index
@@ -60,7 +67,7 @@ module LogBench
60
67
  # Header takes up first HEADER_HEIGHT lines
61
68
  # Request list starts at HEADER_HEIGHT + 1 (accounting for border)
62
69
  panel_width = screen.panel_width
63
- header_height = 5 # Screen::HEADER_HEIGHT
70
+ header_height = Screen::HEADER_HEIGHT
64
71
 
65
72
  x >= 0 && x < panel_width && y > header_height
66
73
  end
@@ -69,8 +76,8 @@ module LogBench
69
76
  # Right pane starts after left panel + border width
70
77
  # From Screen: panel_width + PANEL_BORDER_WIDTH
71
78
  panel_width = screen.panel_width
72
- border_width = 3 # Screen::PANEL_BORDER_WIDTH
73
- header_height = 5 # Screen::HEADER_HEIGHT
79
+ border_width = Screen::PANEL_BORDER_WIDTH
80
+ header_height = Screen::HEADER_HEIGHT
74
81
 
75
82
  right_pane_start = panel_width + border_width
76
83
 
@@ -78,11 +85,10 @@ module LogBench
78
85
  end
79
86
 
80
87
  def click_to_request_index(y)
81
- # Header takes up first 5 lines
82
- # Request list has 1 line border at top, then 1 line for column headers
83
- # So actual request rows start at y = 7 (5 header + 1 border + 1 column header)
84
- header_height = 5
85
- list_header_offset = 2 # border + column header
88
+ # Header takes up first 5 lines.
89
+ # Request list rows start at RequestList::ROWS_START_Y inside log_win.
90
+ header_height = screen_header_height
91
+ list_header_offset = Renderer::RequestList::ROWS_START_Y
86
92
 
87
93
  row_in_list = y - header_height - list_header_offset
88
94
  return nil if row_in_list < 0
@@ -91,9 +97,67 @@ module LogBench
91
97
  state.scroll_offset + row_in_list
92
98
  end
93
99
 
94
- def visible_height
95
- # Approximate visible height for calculations
96
- DEFAULT_VISIBLE_HEIGHT
100
+ def click_on_request_filter_row?(y)
101
+ y == screen_header_height + Renderer::RequestList::FILTER_ROW_Y
102
+ end
103
+
104
+ def click_on_column_header_row?(y)
105
+ y == screen_header_height + Renderer::RequestList::COLUMN_HEADER_Y
106
+ end
107
+
108
+ def handle_request_header_click(x)
109
+ selected_column = request_header_column_for_x(x)
110
+ state.toggle_request_sort(selected_column) if selected_column
111
+ end
112
+
113
+ def request_header_column_for_x(x)
114
+ start_x = Renderer::RequestList::HEADER_Y_OFFSET
115
+ method_width = Renderer::RequestList::METHOD_WIDTH
116
+ path_width = screen.panel_width - Renderer::RequestList::PATH_MARGIN
117
+ status_width = Renderer::RequestList::STATUS_WIDTH
118
+
119
+ method_start = start_x
120
+ path_start = method_start + method_width
121
+ status_start = path_start + path_width
122
+ time_start = status_start + status_width
123
+
124
+ ranges = [
125
+ [:method, method_start...(method_start + method_width)],
126
+ [nil, path_start...(path_start + path_width)],
127
+ [:status, status_start...(status_start + status_width)],
128
+ [:time, time_start...screen.panel_width]
129
+ ]
130
+
131
+ ranges.each do |column, range|
132
+ return column if range.cover?(x)
133
+ end
134
+
135
+ nil
136
+ end
137
+
138
+ def handle_request_filter_click(x)
139
+ selected_column = request_filter_column_for_x(x)
140
+ state.exit_filter_mode
141
+ state.select_request_filter_column(selected_column) if selected_column
142
+ state.enter_filter_mode
143
+ end
144
+
145
+ def request_filter_column_for_x(x)
146
+ ranges = Renderer::RequestFilterBar.layout(
147
+ screen.panel_width,
148
+ header_x_offset: Renderer::RequestList::HEADER_Y_OFFSET,
149
+ method_width: Renderer::RequestList::METHOD_WIDTH
150
+ ).slice(:method, :path, :status, :time)
151
+
152
+ ranges.each do |column, range|
153
+ return column if range.cover?(x)
154
+ end
155
+
156
+ nil
157
+ end
158
+
159
+ def screen_header_height
160
+ Screen::HEADER_HEIGHT
97
161
  end
98
162
 
99
163
  def with_warnings_suppressed
@@ -5,6 +5,25 @@ module LogBench
5
5
  module Renderer
6
6
  class Ansi
7
7
  include Curses
8
+ BRIGHT_WHITE = Screen::BRIGHT_WHITE
9
+ BLACK = Screen::BLACK
10
+ ERROR_RED = Screen::ERROR_RED
11
+ SUCCESS_GREEN = Screen::SUCCESS_GREEN
12
+ WARNING_YELLOW = Screen::WARNING_YELLOW
13
+ INFO_BLUE = Screen::INFO_BLUE
14
+ MAGENTA = Screen::MAGENTA
15
+ HEADER_CYAN = Screen::HEADER_CYAN
16
+
17
+ ANSI_RESET = 0
18
+ ANSI_BOLD = 1
19
+ ANSI_BLACK = 30
20
+ ANSI_RED = 31
21
+ ANSI_GREEN = 32
22
+ ANSI_YELLOW = 33
23
+ ANSI_BLUE = 34
24
+ ANSI_MAGENTA = 35
25
+ ANSI_CYAN = 36
26
+ ANSI_WHITE = 37
8
27
 
9
28
  def initialize(screen)
10
29
  self.screen = screen
@@ -166,20 +185,20 @@ module LogBench
166
185
 
167
186
  def ansi_to_curses_color(codes)
168
187
  # Convert ANSI color codes to curses color pairs
169
- return nil if codes.empty? || codes == [0]
188
+ return nil if codes.empty? || codes == [ANSI_RESET]
170
189
 
171
190
  # Handle common ANSI codes
172
191
  codes.each do |code|
173
192
  case code
174
- when 1 then return color_pair(7) | A_BOLD # Bold/bright
175
- when 30 then return color_pair(8) # Black
176
- when 31 then return color_pair(6) # Red
177
- when 32 then return color_pair(3) # Green
178
- when 33 then return color_pair(4) # Yellow
179
- when 34 then return color_pair(5) # Blue
180
- when 35 then return color_pair(9) # Magenta
181
- when 36 then return color_pair(1) # Cyan
182
- when 37 then return nil # White (default)
193
+ when ANSI_BOLD then return color_pair(BRIGHT_WHITE) | A_BOLD
194
+ when ANSI_BLACK then return color_pair(BLACK)
195
+ when ANSI_RED then return color_pair(ERROR_RED)
196
+ when ANSI_GREEN then return color_pair(SUCCESS_GREEN)
197
+ when ANSI_YELLOW then return color_pair(WARNING_YELLOW)
198
+ when ANSI_BLUE then return color_pair(INFO_BLUE)
199
+ when ANSI_MAGENTA then return color_pair(MAGENTA)
200
+ when ANSI_CYAN then return color_pair(HEADER_CYAN)
201
+ when ANSI_WHITE then return nil
183
202
  end
184
203
  end
185
204
 
@@ -9,6 +9,13 @@ module LogBench
9
9
  include Curses
10
10
  EMPTY_LINE = {text: "", color: nil}
11
11
  SEPARATOR_LINE = {text: "", color: nil, separator: true}
12
+ HEADER_CYAN = Screen::HEADER_CYAN
13
+ DEFAULT_WHITE = Screen::DEFAULT_WHITE
14
+ SUCCESS_GREEN = Screen::SUCCESS_GREEN
15
+ WARNING_YELLOW = Screen::WARNING_YELLOW
16
+ INFO_BLUE = Screen::INFO_BLUE
17
+ ERROR_RED = Screen::ERROR_RED
18
+ SELECTION_HIGHLIGHT = Screen::SELECTION_HIGHLIGHT
12
19
 
13
20
  def initialize(screen, state, scrollbar, ansi_renderer)
14
21
  self.screen = screen
@@ -54,9 +61,9 @@ module LogBench
54
61
  detail_win.setpos(0, 2)
55
62
 
56
63
  if state.right_pane_focused?
57
- detail_win.attron(color_pair(1) | A_BOLD) { detail_win.addstr(" Request Details ") }
64
+ detail_win.attron(color_pair(HEADER_CYAN) | A_BOLD) { detail_win.addstr(" Request Details ") }
58
65
  else
59
- detail_win.attron(color_pair(2) | A_DIM) { detail_win.addstr(" Request Details ") }
66
+ detail_win.attron(color_pair(DEFAULT_WHITE) | A_DIM) { detail_win.addstr(" Request Details ") }
60
67
  end
61
68
 
62
69
  # Show detail filter to the right of the title (always visible when active)
@@ -67,7 +74,7 @@ module LogBench
67
74
  filter_x = detail_win.maxx - filter_text.length - 3
68
75
  if filter_x > 20 # Only show if there's enough space
69
76
  detail_win.setpos(0, filter_x)
70
- detail_win.attron(color_pair(4)) { detail_win.addstr(filter_text) }
77
+ detail_win.attron(color_pair(WARNING_YELLOW)) { detail_win.addstr(filter_text) }
71
78
  end
72
79
  end
73
80
  end
@@ -95,7 +102,7 @@ module LogBench
95
102
  # Draw highlight background if selected
96
103
  if is_selected
97
104
  detail_win.setpos(y, 1)
98
- detail_win.attron(color_pair(10) | A_DIM) do
105
+ detail_win.attron(color_pair(SELECTION_HIGHLIGHT) | A_DIM) do
99
106
  detail_win.addstr(" " * (detail_win.maxx - 2))
100
107
  end
101
108
  end
@@ -106,7 +113,7 @@ module LogBench
106
113
  if line_data.is_a?(Hash) && line_data[:segments]
107
114
  line_data[:segments].each do |segment|
108
115
  if is_selected
109
- detail_win.attron(color_pair(10) | A_DIM) { detail_win.addstr(segment[:text]) }
116
+ detail_win.attron(color_pair(SELECTION_HIGHLIGHT) | A_DIM) { detail_win.addstr(segment[:text]) }
110
117
  elsif segment[:color]
111
118
  detail_win.attron(segment[:color]) { detail_win.addstr(segment[:text]) }
112
119
  else
@@ -118,14 +125,14 @@ module LogBench
118
125
  if is_selected
119
126
  # For selected ANSI lines, render without ANSI codes to maintain highlight
120
127
  plain_text = line_data[:text].gsub(/\e\[[0-9;]*m/, "")
121
- detail_win.attron(color_pair(10) | A_DIM) { detail_win.addstr(plain_text) }
128
+ detail_win.attron(color_pair(SELECTION_HIGHLIGHT) | A_DIM) { detail_win.addstr(plain_text) }
122
129
  else
123
130
  ansi_renderer.parse_and_render(line_data[:text], detail_win)
124
131
  end
125
132
  elsif line_data.is_a?(Hash)
126
133
  # Handle single-color lines
127
134
  if is_selected
128
- detail_win.attron(color_pair(10) | A_DIM) { detail_win.addstr(line_data[:text]) }
135
+ detail_win.attron(color_pair(SELECTION_HIGHLIGHT) | A_DIM) { detail_win.addstr(line_data[:text]) }
129
136
  elsif line_data[:color]
130
137
  detail_win.attron(line_data[:color]) { detail_win.addstr(line_data[:text]) }
131
138
  else
@@ -133,7 +140,7 @@ module LogBench
133
140
  end
134
141
  elsif is_selected
135
142
  # Simple string
136
- detail_win.attron(color_pair(10) | A_DIM) { detail_win.addstr(line_data.to_s) }
143
+ detail_win.attron(color_pair(SELECTION_HIGHLIGHT) | A_DIM) { detail_win.addstr(line_data.to_s) }
137
144
  else
138
145
  detail_win.addstr(line_data.to_s)
139
146
  end
@@ -164,11 +171,11 @@ module LogBench
164
171
 
165
172
  # Method - separate label and value colors
166
173
  method_color = case request.method
167
- when "GET" then color_pair(3) | A_BOLD
168
- when "POST" then color_pair(4) | A_BOLD
169
- when "PUT" then color_pair(5) | A_BOLD
170
- when "DELETE" then color_pair(6) | A_BOLD
171
- else color_pair(2) | A_BOLD
174
+ when "GET" then color_pair(SUCCESS_GREEN) | A_BOLD
175
+ when "POST" then color_pair(WARNING_YELLOW) | A_BOLD
176
+ when "PUT" then color_pair(INFO_BLUE) | A_BOLD
177
+ when "DELETE" then color_pair(ERROR_RED) | A_BOLD
178
+ else color_pair(DEFAULT_WHITE) | A_BOLD
172
179
  end
173
180
 
174
181
  lines << EMPTY_LINE.merge(entry_id: entry_id)
@@ -178,7 +185,7 @@ module LogBench
178
185
  color: nil,
179
186
  entry_id: entry_id,
180
187
  segments: [
181
- {text: "Method: ", color: color_pair(1)},
188
+ {text: "Method: ", color: color_pair(HEADER_CYAN)},
182
189
  {text: request.method, color: method_color}
183
190
  ]
184
191
  }
@@ -214,7 +221,7 @@ module LogBench
214
221
  color: nil,
215
222
  entry_id: entry_id,
216
223
  segments: [
217
- {text: path_prefix, color: color_pair(1)},
224
+ {text: path_prefix, color: color_pair(HEADER_CYAN)},
218
225
  {text: remaining_path, color: nil} # Default white color
219
226
  ]
220
227
  }
@@ -226,7 +233,7 @@ module LogBench
226
233
  color: nil,
227
234
  entry_id: entry_id,
228
235
  segments: [
229
- {text: path_prefix, color: color_pair(1)},
236
+ {text: path_prefix, color: color_pair(HEADER_CYAN)},
230
237
  {text: first_chunk, color: nil} # Default white color
231
238
  ]
232
239
  }
@@ -245,20 +252,20 @@ module LogBench
245
252
  if request.status
246
253
  # Add status color coding
247
254
  status_color = case request.status
248
- when 200..299 then color_pair(3) # Green
249
- when 300..399 then color_pair(4) # Yellow
250
- when 400..599 then color_pair(6) # Red
251
- else color_pair(2) # Default
255
+ when 200..299 then color_pair(SUCCESS_GREEN)
256
+ when 300..399 then color_pair(WARNING_YELLOW)
257
+ when 400..599 then color_pair(ERROR_RED)
258
+ else color_pair(DEFAULT_WHITE)
252
259
  end
253
260
 
254
261
  # Build segments for mixed coloring
255
262
  segments = [
256
- {text: "Status: ", color: color_pair(1)},
263
+ {text: "Status: ", color: color_pair(HEADER_CYAN)},
257
264
  {text: request.status.to_s, color: status_color}
258
265
  ]
259
266
 
260
267
  if request.duration
261
- segments << {text: " | Duration: ", color: color_pair(1)}
268
+ segments << {text: " | Duration: ", color: color_pair(HEADER_CYAN)}
262
269
  segments << {text: "#{request.duration}ms", color: nil} # Default white color
263
270
  end
264
271
 
@@ -280,7 +287,7 @@ module LogBench
280
287
  color: nil,
281
288
  entry_id: entry_id,
282
289
  segments: [
283
- {text: "Controller: ", color: color_pair(1)},
290
+ {text: "Controller: ", color: color_pair(HEADER_CYAN)},
284
291
  {text: controller_value, color: nil} # Default white color
285
292
  ]
286
293
  }
@@ -296,7 +303,7 @@ module LogBench
296
303
  color: nil,
297
304
  entry_id: entry_id,
298
305
  segments: [
299
- {text: "Params:", color: color_pair(1) | A_BOLD}
306
+ {text: "Params:", color: color_pair(HEADER_CYAN) | A_BOLD}
300
307
  ]
301
308
  }
302
309
 
@@ -369,7 +376,7 @@ module LogBench
369
376
  color: nil,
370
377
  entry_id: entry_id,
371
378
  segments: [
372
- {text: "Request ID: ", color: color_pair(1)},
379
+ {text: "Request ID: ", color: color_pair(HEADER_CYAN)},
373
380
  {text: request.request_id, color: nil} # Default white color
374
381
  ]
375
382
  }
@@ -387,7 +394,7 @@ module LogBench
387
394
  color: nil,
388
395
  entry_id: entry_id,
389
396
  segments: [
390
- {text: "Timestamp: ", color: color_pair(1)},
397
+ {text: "Timestamp: ", color: color_pair(HEADER_CYAN)},
391
398
  {text: request.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC"), color: nil} # Default white color
392
399
  ]
393
400
  }
@@ -415,16 +422,16 @@ module LogBench
415
422
 
416
423
  # Show filter status in summary if filtering is active
417
424
  summary_title = "Query Summary:"
418
- lines << {text: summary_title, color: color_pair(1) | A_BOLD, entry_id: entry_id}
425
+ lines << {text: summary_title, color: color_pair(HEADER_CYAN) | A_BOLD, entry_id: entry_id}
419
426
 
420
427
  if query_stats[:total_queries] > 0
421
428
  # Use QuerySummary methods for consistent formatting
422
429
  summary_line = query_summary.build_summary_line(query_stats)
423
- lines << {text: " #{summary_line}", color: color_pair(2), entry_id: entry_id}
430
+ lines << {text: " #{summary_line}", color: color_pair(DEFAULT_WHITE), entry_id: entry_id}
424
431
 
425
432
  breakdown_line = query_summary.build_breakdown_line(query_stats)
426
433
  unless breakdown_line.empty?
427
- lines << {text: " #{breakdown_line}", color: color_pair(2), entry_id: entry_id}
434
+ lines << {text: " #{breakdown_line}", color: color_pair(DEFAULT_WHITE), entry_id: entry_id}
428
435
  end
429
436
  end
430
437
 
@@ -440,13 +447,13 @@ module LogBench
440
447
  color: nil,
441
448
  entry_id: entry_id,
442
449
  segments: [
443
- {text: "Related Logs ", color: color_pair(1) | A_BOLD},
450
+ {text: "Related Logs ", color: color_pair(HEADER_CYAN) | A_BOLD},
444
451
  {text: count_text, color: A_DIM},
445
- {text: ":", color: color_pair(1) | A_BOLD}
452
+ {text: ":", color: color_pair(HEADER_CYAN) | A_BOLD}
446
453
  ]
447
454
  }
448
455
  else
449
- lines << {text: "Related Logs:", color: color_pair(1) | A_BOLD, entry_id: entry_id}
456
+ lines << {text: "Related Logs:", color: color_pair(HEADER_CYAN) | A_BOLD, entry_id: entry_id}
450
457
  end
451
458
 
452
459
  # Use filtered logs for display - group SQL queries with their call source lines
@@ -15,8 +15,8 @@ module LogBench
15
15
  TITLE_X_OFFSET = 2
16
16
 
17
17
  # Color constants
18
- HEADER_CYAN = 1
19
- SUCCESS_GREEN = 3
18
+ HEADER_CYAN = Screen::HEADER_CYAN
19
+ SUCCESS_GREEN = Screen::SUCCESS_GREEN
20
20
 
21
21
  def initialize(screen, state, log_file_name)
22
22
  self.screen = screen
@@ -50,7 +50,7 @@ module LogBench
50
50
  end
51
51
 
52
52
  def draw_stats
53
- if state.main_filter.present?
53
+ if state.request_filters_present?
54
54
  draw_filtered_stats
55
55
  else
56
56
  draw_stats_panel
@@ -63,9 +63,9 @@ module LogBench
63
63
  total_requests = state.requests.size
64
64
  stats_text = "#{filtered_requests.size} found (#{total_requests} total)"
65
65
  header_win.setpos(1, screen.width - stats_text.length - 2)
66
- header_win.attron(color_pair(3)) { header_win.addstr(filtered_requests.size.to_s) }
66
+ header_win.attron(color_pair(SUCCESS_GREEN)) { header_win.addstr(filtered_requests.size.to_s) }
67
67
  header_win.addstr(" found (")
68
- header_win.attron(color_pair(3)) { header_win.addstr(total_requests.to_s) }
68
+ header_win.attron(color_pair(SUCCESS_GREEN)) { header_win.addstr(total_requests.to_s) }
69
69
  header_win.addstr(" total)")
70
70
  end
71
71
 
@@ -120,18 +120,18 @@ module LogBench
120
120
  header_win.attron(A_DIM) do
121
121
  help_line_1 = "a:Auto-scroll("
122
122
  header_win.addstr(help_line_1)
123
- header_win.attron(color_pair(3)) { header_win.addstr(state.auto_scroll ? "ON" : "OFF") }
124
- header_win.addstr(") | f:Filter | c:Clear filter | s:Sort(")
125
- header_win.attron(color_pair(3)) { header_win.addstr(state.sort.display_name) }
123
+ header_win.attron(color_pair(SUCCESS_GREEN)) { header_win.addstr(state.auto_scroll ? "ON" : "OFF") }
124
+ header_win.addstr(") | f:Filter cols | c:Clear filter | s:Sort(")
125
+ header_win.attron(color_pair(SUCCESS_GREEN)) { header_win.addstr(state.sort.display_name) }
126
126
  header_win.addstr(") | t:Text selection(")
127
- header_win.attron(color_pair(3)) { header_win.addstr(state.text_selection_mode? ? "ON" : "OFF") }
127
+ header_win.attron(color_pair(SUCCESS_GREEN)) { header_win.addstr(state.text_selection_mode? ? "ON" : "OFF") }
128
128
  header_win.addstr(") | q:Quit")
129
129
  end
130
130
 
131
131
  header_win.setpos(3, 2)
132
132
  header_win.attron(A_DIM) do
133
133
  header_win.addstr("←→/hl:Pane | ↑↓/jk:Navigate | g/G:Top/End | y:Copy highlighted | Ctrl+L:Clear | Ctrl+R:Restore(")
134
- header_win.attron(color_pair(3)) { header_win.addstr(state.can_undo_clear? ? "READY" : "N/A") }
134
+ header_win.attron(color_pair(SUCCESS_GREEN)) { header_win.addstr(state.can_undo_clear? ? "READY" : "N/A") }
135
135
  header_win.addstr(")")
136
136
  end
137
137
  end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module App
5
+ module Renderer
6
+ class RequestFilterBar
7
+ include Curses
8
+
9
+ FILTER_HINT_TEXT = "Press f to start filtering, operators allowed: > >= < <= 50-100"
10
+ FILTER_RIGHT_EDGE_OFFSET = 2
11
+ FILTER_STATUS_WIDTH = 7
12
+ FILTER_TIME_WIDTH = 8
13
+ CURSOR_BLINK_INTERVAL_SECONDS = 0.5
14
+
15
+ DEFAULT_WHITE = Screen::DEFAULT_WHITE
16
+ FILTER_CELL_BACKGROUND = Screen::FILTER_CELL_BACKGROUND
17
+
18
+ def initialize(screen, state, header_x_offset:, row_y:, method_width:)
19
+ self.screen = screen
20
+ self.state = state
21
+ self.header_x_offset = header_x_offset
22
+ self.row_y = row_y
23
+ self.method_width = method_width
24
+ end
25
+
26
+ def draw
27
+ if show_filter_cells?
28
+ draw_filter_cells_row
29
+ else
30
+ draw_filter_hint_row
31
+ end
32
+ end
33
+
34
+ def self.layout(panel_width, header_x_offset:, method_width:)
35
+ filter_right_edge = panel_width - FILTER_RIGHT_EDGE_OFFSET
36
+ time_start = filter_right_edge - FILTER_TIME_WIDTH
37
+ status_start = time_start - FILTER_STATUS_WIDTH
38
+ path_start = header_x_offset + method_width
39
+ path_width = [status_start - path_start, 1].max
40
+
41
+ {
42
+ method: (header_x_offset...(header_x_offset + method_width)),
43
+ path: (path_start...(path_start + path_width)),
44
+ status: (status_start...(status_start + FILTER_STATUS_WIDTH)),
45
+ time: (time_start...(time_start + FILTER_TIME_WIDTH)),
46
+ path_width: path_width,
47
+ status_start: status_start,
48
+ time_start: time_start,
49
+ filter_right_edge: filter_right_edge
50
+ }
51
+ end
52
+
53
+ private
54
+
55
+ attr_accessor :screen, :state, :header_x_offset, :row_y, :method_width
56
+
57
+ def show_filter_cells?
58
+ state.filter_mode || state.request_filters_present?
59
+ end
60
+
61
+ def draw_filter_hint_row
62
+ log_win.setpos(row_y, header_x_offset)
63
+ consumed = draw_filter_hint_prefix(filter_row_width)
64
+ fill_remaining_filter_row(consumed)
65
+ end
66
+
67
+ def draw_filter_hint_prefix(width)
68
+ return 0 if width <= 0
69
+
70
+ hint_text = FILTER_HINT_TEXT[0, width].ljust(width)
71
+ log_win.attron(color_pair(DEFAULT_WHITE) | A_DIM) { log_win.addstr(hint_text) }
72
+ hint_text.length
73
+ end
74
+
75
+ def fill_remaining_filter_row(consumed_width)
76
+ remaining = filter_row_width - consumed_width
77
+ return if remaining <= 0
78
+
79
+ log_win.attron(color_pair(DEFAULT_WHITE) | A_DIM) { log_win.addstr(" " * remaining) }
80
+ end
81
+
82
+ def draw_filter_cells_row
83
+ current_layout = layout
84
+
85
+ log_win.setpos(row_y, header_x_offset)
86
+ draw_filter_cell(:method, method_width)
87
+ draw_filter_cell(:path, current_layout[:path_width])
88
+
89
+ log_win.setpos(row_y, current_layout[:status_start])
90
+ draw_filter_cell(:status, FILTER_STATUS_WIDTH)
91
+
92
+ log_win.setpos(row_y, current_layout[:time_start])
93
+ draw_filter_cell(:time, FILTER_TIME_WIDTH)
94
+ end
95
+
96
+ def draw_filter_cell(column, width)
97
+ filter_text = filter_text_for(column, width)
98
+ log_win.attron(filter_cell_attributes(column)) { log_win.addstr(filter_text) }
99
+ end
100
+
101
+ def filter_text_for(column, width)
102
+ filter = state.request_filter_for(column)
103
+ text = filter.display_text.to_s
104
+ text = "#{text}#{active_filter_cursor}" if active_filter_column?(column)
105
+
106
+ align_filter_text(column, text, width)
107
+ end
108
+
109
+ def align_filter_text(column, text, width)
110
+ if column == :status
111
+ visible_text = text[0, width]
112
+ visible_text.rjust(width - 1).ljust(width)
113
+ elsif column == :time
114
+ visible_text = text[0, width - 1]
115
+ " #{visible_text}".ljust(width)
116
+ else
117
+ text[0, width].ljust(width)
118
+ end
119
+ end
120
+
121
+ def active_filter_column?(column)
122
+ state.filter_mode && state.active_request_filter_column == column
123
+ end
124
+
125
+ def filter_cell_attributes(column)
126
+ base = color_pair(FILTER_CELL_BACKGROUND) | A_DIM
127
+ active_filter_column?(column) ? color_pair(FILTER_CELL_BACKGROUND) : base
128
+ end
129
+
130
+ def active_filter_cursor
131
+ cursor_visible? ? "█" : " "
132
+ end
133
+
134
+ def cursor_visible?
135
+ blink_tick = (Process.clock_gettime(Process::CLOCK_MONOTONIC) / CURSOR_BLINK_INTERVAL_SECONDS).to_i
136
+ blink_tick.even?
137
+ end
138
+
139
+ def layout
140
+ self.class.layout(
141
+ screen.panel_width,
142
+ header_x_offset: header_x_offset,
143
+ method_width: method_width
144
+ )
145
+ end
146
+
147
+ def filter_row_width
148
+ layout[:filter_right_edge] - header_x_offset
149
+ end
150
+
151
+ def color_pair(n)
152
+ screen.color_pair(n)
153
+ end
154
+
155
+ def log_win
156
+ screen.log_win
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -9,24 +9,36 @@ module LogBench
9
9
  # Layout constants
10
10
  HEADER_Y_OFFSET = 2
11
11
  COLUMN_HEADER_Y = 1
12
- MIN_FILTER_X_POSITION = 20
13
- FILTER_X_MARGIN = 3
12
+ FILTER_ROW_Y = 2
13
+ ROWS_START_Y = 3
14
14
 
15
15
  # Column widths
16
16
  METHOD_WIDTH = 8
17
17
  STATUS_WIDTH = 8
18
+ TIME_WIDTH = 6
19
+ STATUS_RENDER_WIDTH = 4
18
20
  PATH_MARGIN = 27
19
21
 
20
22
  # Color constants
21
- HEADER_CYAN = 1
22
- DEFAULT_WHITE = 2
23
- WARNING_YELLOW = 4
24
- SELECTION_HIGHLIGHT = 10
23
+ HEADER_CYAN = Screen::HEADER_CYAN
24
+ DEFAULT_WHITE = Screen::DEFAULT_WHITE
25
+ SUCCESS_GREEN = Screen::SUCCESS_GREEN
26
+ WARNING_YELLOW = Screen::WARNING_YELLOW
27
+ INFO_BLUE = Screen::INFO_BLUE
28
+ ERROR_RED = Screen::ERROR_RED
29
+ SELECTION_HIGHLIGHT = Screen::SELECTION_HIGHLIGHT
25
30
 
26
31
  def initialize(screen, state, scrollbar)
27
32
  self.screen = screen
28
33
  self.state = state
29
34
  self.scrollbar = scrollbar
35
+ self.filter_bar = RequestFilterBar.new(
36
+ screen,
37
+ state,
38
+ header_x_offset: HEADER_Y_OFFSET,
39
+ row_y: FILTER_ROW_Y,
40
+ method_width: METHOD_WIDTH
41
+ )
30
42
  end
31
43
 
32
44
  def draw
@@ -35,12 +47,13 @@ module LogBench
35
47
 
36
48
  draw_header
37
49
  draw_column_headers
50
+ draw_filter_row
38
51
  draw_rows
39
52
  end
40
53
 
41
54
  private
42
55
 
43
- attr_accessor :screen, :state, :scrollbar
56
+ attr_accessor :screen, :state, :scrollbar, :filter_bar
44
57
 
45
58
  def draw_header
46
59
  log_win.setpos(0, HEADER_Y_OFFSET)
@@ -50,37 +63,30 @@ module LogBench
50
63
  else
51
64
  log_win.attron(color_pair(DEFAULT_WHITE) | A_DIM) { log_win.addstr(" Request Logs ") }
52
65
  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
66
  end
70
67
 
71
68
  def draw_column_headers
72
69
  log_win.setpos(COLUMN_HEADER_Y, HEADER_Y_OFFSET)
73
70
  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")
71
+ log_win.addstr(column_header_text("METHOD", :method).ljust(METHOD_WIDTH))
72
+ log_win.addstr("PATH".ljust(path_column_width))
73
+ log_win.addstr(column_header_text("STATUS", :status).ljust(STATUS_WIDTH))
74
+ log_win.addstr(column_header_text("TIME", :time))
78
75
  end
79
76
  end
80
77
 
78
+ def column_header_text(label, column)
79
+ sort_arrow = state.sort_arrow_for_column(column)
80
+ sort_arrow ? "#{label}#{sort_arrow}" : label
81
+ end
82
+
83
+ def draw_filter_row
84
+ filter_bar.draw
85
+ end
86
+
81
87
  def draw_rows
82
88
  filtered_requests = state.filtered_requests
83
- visible_height = log_win.maxy - 3
89
+ visible_height = log_win.maxy - 4
84
90
 
85
91
  return draw_no_requests_message if filtered_requests.empty?
86
92
 
@@ -91,7 +97,7 @@ module LogBench
91
97
  request_index = state.scroll_offset + i
92
98
  break if request_index >= filtered_requests.size
93
99
 
94
- draw_row(filtered_requests[request_index], request_index, i + 2)
100
+ draw_row(filtered_requests[request_index], request_index, i + ROWS_START_Y)
95
101
  end
96
102
 
97
103
  # Draw scrollbar if needed
@@ -110,7 +116,7 @@ module LogBench
110
116
  is_selected = request_index == state.selected
111
117
 
112
118
  if is_selected
113
- log_win.attron(color_pair(10) | A_DIM) do
119
+ log_win.attron(color_pair(SELECTION_HIGHLIGHT) | A_DIM) do
114
120
  log_win.addstr(" " * (screen.panel_width - 4))
115
121
  end
116
122
  log_win.setpos(y_position, 1)
@@ -126,7 +132,7 @@ module LogBench
126
132
  method_text = " #{request.method.ljust(7)} "
127
133
 
128
134
  if is_selected
129
- log_win.attron(color_pair(10) | A_DIM) { log_win.addstr(method_text) }
135
+ log_win.attron(color_pair(SELECTION_HIGHLIGHT) | A_DIM) { log_win.addstr(method_text) }
130
136
  else
131
137
  method_color = method_color_for(request.method)
132
138
  log_win.attron(color_pair(method_color) | A_BOLD) { log_win.addstr(method_text) }
@@ -134,12 +140,12 @@ module LogBench
134
140
  end
135
141
 
136
142
  def draw_path_column(request, is_selected)
137
- path_width = screen.panel_width - 27
138
- path = request.path[0, path_width] || ""
143
+ path = request.path[0, path_column_width] || ""
144
+ path_width = path_column_width
139
145
  path_text = path.ljust(path_width)
140
146
 
141
147
  if is_selected
142
- log_win.attron(color_pair(10) | A_DIM) { log_win.addstr(path_text) }
148
+ log_win.attron(color_pair(SELECTION_HIGHLIGHT) | A_DIM) { log_win.addstr(path_text) }
143
149
  else
144
150
  log_win.addstr(path_text)
145
151
  end
@@ -148,12 +154,11 @@ module LogBench
148
154
  def draw_status_column(request, is_selected)
149
155
  return unless request.status
150
156
 
151
- status_col_start = screen.panel_width - 14
152
157
  status_text = "#{request.status.to_s.rjust(3)} "
153
158
 
154
159
  log_win.setpos(log_win.cury, status_col_start)
155
160
  if is_selected
156
- log_win.attron(color_pair(10) | A_DIM) { log_win.addstr(status_text) }
161
+ log_win.attron(color_pair(SELECTION_HIGHLIGHT) | A_DIM) { log_win.addstr(status_text) }
157
162
  else
158
163
  status_color = status_color_for(request.status)
159
164
  log_win.attron(color_pair(status_color)) { log_win.addstr(status_text) }
@@ -163,12 +168,11 @@ module LogBench
163
168
  def draw_duration_column(request, is_selected)
164
169
  return unless request.duration
165
170
 
166
- duration_col_start = screen.panel_width - 9
167
171
  duration_text = "#{request.duration.to_i}ms".ljust(6) + " "
168
172
 
169
173
  log_win.setpos(log_win.cury, duration_col_start)
170
174
  if is_selected
171
- log_win.attron(color_pair(10) | A_DIM) { log_win.addstr(duration_text) }
175
+ log_win.attron(color_pair(SELECTION_HIGHLIGHT) | A_DIM) { log_win.addstr(duration_text) }
172
176
  else
173
177
  log_win.attron(A_DIM) { log_win.addstr(duration_text) }
174
178
  end
@@ -176,23 +180,35 @@ module LogBench
176
180
 
177
181
  def method_color_for(method)
178
182
  case method
179
- when "GET" then 3
180
- when "POST" then 4
181
- when "PUT" then 5
182
- when "DELETE" then 6
183
- else 2
183
+ when "GET" then SUCCESS_GREEN
184
+ when "POST" then WARNING_YELLOW
185
+ when "PUT" then INFO_BLUE
186
+ when "DELETE" then ERROR_RED
187
+ else DEFAULT_WHITE
184
188
  end
185
189
  end
186
190
 
187
191
  def status_color_for(status)
188
192
  case status
189
- when 200..299 then 3
190
- when 300..399 then 4
191
- when 400..599 then 6
192
- else 2
193
+ when 200..299 then SUCCESS_GREEN
194
+ when 300..399 then WARNING_YELLOW
195
+ when 400..599 then ERROR_RED
196
+ else DEFAULT_WHITE
193
197
  end
194
198
  end
195
199
 
200
+ def path_column_width
201
+ screen.panel_width - PATH_MARGIN
202
+ end
203
+
204
+ def status_col_start
205
+ screen.panel_width - 14
206
+ end
207
+
208
+ def duration_col_start
209
+ screen.panel_width - 9
210
+ end
211
+
196
212
  def color_pair(n)
197
213
  screen.color_pair(n)
198
214
  end
@@ -5,6 +5,7 @@ module LogBench
5
5
  module Renderer
6
6
  class Scrollbar
7
7
  include Curses
8
+ SCROLLBAR_THUMB_COLOR = Screen::HEADER_CYAN
8
9
 
9
10
  def initialize(screen)
10
11
  self.screen = screen
@@ -20,7 +21,7 @@ module LogBench
20
21
  height.times do |i|
21
22
  win.setpos(i + 1, x)
22
23
  if i >= scrollbar_pos && i < scrollbar_pos + scrollbar_height
23
- win.attron(color_pair(1)) { win.addstr("█") } # Solid block for scrollbar thumb
24
+ win.attron(color_pair(SCROLLBAR_THUMB_COLOR)) { win.addstr("█") } # Solid block for scrollbar thumb
24
25
  end
25
26
  end
26
27
  end
@@ -9,8 +9,11 @@ module LogBench
9
9
  HEADER_HEIGHT = 5
10
10
  PANEL_BORDER_WIDTH = 3
11
11
  INPUT_TIMEOUT_MS = 200
12
+ TRANSPARENT_BACKGROUND = -1
13
+ EXTENDED_COLOR_THRESHOLD = 253
14
+ NO_COLOR_SUPPORT = 0
12
15
 
13
- # Color pairs
16
+ # Color pairs (identifiers)
14
17
  HEADER_CYAN = 1
15
18
  DEFAULT_WHITE = 2
16
19
  SUCCESS_GREEN = 3 # GET requests, 200 status
@@ -21,6 +24,7 @@ module LogBench
21
24
  BLACK = 8
22
25
  MAGENTA = 9
23
26
  SELECTION_HIGHLIGHT = 10
27
+ FILTER_CELL_BACKGROUND = 11
24
28
 
25
29
  attr_reader :header_win, :log_win, :panel_width, :detail_win
26
30
 
@@ -89,17 +93,35 @@ module LogBench
89
93
  stdscr.keypad(true)
90
94
  stdscr.timeout = INPUT_TIMEOUT_MS
91
95
 
92
- # Define color pairs with transparent background (-1)
93
- init_pair(HEADER_CYAN, COLOR_CYAN, -1) # Header/Cyan
94
- init_pair(DEFAULT_WHITE, COLOR_WHITE, -1) # Default/White
95
- init_pair(SUCCESS_GREEN, COLOR_GREEN, -1) # GET/Success/Green
96
- init_pair(WARNING_YELLOW, COLOR_YELLOW, -1) # POST/Warning/Yellow
97
- init_pair(INFO_BLUE, COLOR_BLUE, -1) # PUT/Blue
98
- init_pair(ERROR_RED, COLOR_RED, -1) # DELETE/Error/Red
99
- init_pair(BRIGHT_WHITE, COLOR_WHITE, -1) # Bold/Bright white
100
- init_pair(BLACK, COLOR_BLACK, -1) # Black
101
- init_pair(MAGENTA, COLOR_MAGENTA, -1) # Magenta
96
+ # Define color pairs with transparent background.
97
+ init_pair(HEADER_CYAN, COLOR_CYAN, TRANSPARENT_BACKGROUND) # Header/Cyan
98
+ init_pair(DEFAULT_WHITE, COLOR_WHITE, TRANSPARENT_BACKGROUND) # Default/White
99
+ init_pair(SUCCESS_GREEN, COLOR_GREEN, TRANSPARENT_BACKGROUND) # GET/Success/Green
100
+ init_pair(WARNING_YELLOW, COLOR_YELLOW, TRANSPARENT_BACKGROUND) # POST/Warning/Yellow
101
+ init_pair(INFO_BLUE, COLOR_BLUE, TRANSPARENT_BACKGROUND) # PUT/Blue
102
+ init_pair(ERROR_RED, COLOR_RED, TRANSPARENT_BACKGROUND) # DELETE/Error/Red
103
+ init_pair(BRIGHT_WHITE, COLOR_WHITE, TRANSPARENT_BACKGROUND) # Bold/Bright white
104
+ init_pair(BLACK, COLOR_BLACK, TRANSPARENT_BACKGROUND) # Black
105
+ init_pair(MAGENTA, COLOR_MAGENTA, TRANSPARENT_BACKGROUND) # Magenta
102
106
  init_pair(SELECTION_HIGHLIGHT, COLOR_BLACK, COLOR_CYAN) # Selection highlighting
107
+ if terminal_colors_count >= EXTENDED_COLOR_THRESHOLD
108
+ # Keep filter cell text readable on rich-color terminals.
109
+ init_pair(FILTER_CELL_BACKGROUND, COLOR_YELLOW, TRANSPARENT_BACKGROUND)
110
+ else
111
+ init_pair(FILTER_CELL_BACKGROUND, COLOR_BLACK, COLOR_WHITE)
112
+ end
113
+ end
114
+
115
+ def terminal_colors_count
116
+ if Curses.respond_to?(:colors)
117
+ Curses.colors.to_i
118
+ elsif defined?(COLORS)
119
+ COLORS.to_i
120
+ else
121
+ NO_COLOR_SUPPORT
122
+ end
123
+ rescue
124
+ NO_COLOR_SUPPORT
103
125
  end
104
126
 
105
127
  def cleanup_windows
@@ -2,39 +2,83 @@ module LogBench
2
2
  module App
3
3
  class Sort
4
4
  MODES = [:timestamp, :duration, :method, :status].freeze
5
+ REQUEST_COLUMN_TO_MODE = {
6
+ method: :method,
7
+ status: :status,
8
+ time: :duration
9
+ }.freeze
10
+ DEFAULT_DIRECTIONS = {
11
+ timestamp: :asc,
12
+ duration: :desc,
13
+ method: :asc,
14
+ status: :desc
15
+ }.freeze
16
+ ASC_ARROW = "↑"
17
+ DESC_ARROW = "↓"
5
18
 
6
19
  def initialize
7
20
  self.mode = :timestamp
21
+ self.direction = DEFAULT_DIRECTIONS.fetch(mode)
8
22
  end
9
23
 
10
24
  def cycle
11
25
  current_index = MODES.index(mode)
12
26
  next_index = (current_index + 1) % MODES.length
13
27
  self.mode = MODES[next_index]
28
+ self.direction = DEFAULT_DIRECTIONS.fetch(mode)
29
+ end
30
+
31
+ def toggle_column(column)
32
+ target_mode = REQUEST_COLUMN_TO_MODE[column]
33
+ return false unless target_mode
34
+
35
+ if mode == target_mode
36
+ if direction == DEFAULT_DIRECTIONS.fetch(mode)
37
+ toggle_direction
38
+ else
39
+ reset_to_default_sort
40
+ end
41
+ else
42
+ self.mode = target_mode
43
+ self.direction = DEFAULT_DIRECTIONS.fetch(mode)
44
+ end
45
+
46
+ true
47
+ end
48
+
49
+ def sort_arrow_for_column(column)
50
+ target_mode = REQUEST_COLUMN_TO_MODE[column]
51
+ return nil unless target_mode == mode
52
+
53
+ (direction == :asc) ? ASC_ARROW : DESC_ARROW
14
54
  end
15
55
 
16
56
  def display_name
17
- case mode
57
+ mode_name = case mode
18
58
  when :timestamp then "TIMESTAMP"
19
59
  when :duration then "DURATION"
20
60
  when :method then "METHOD"
21
61
  when :status then "STATUS"
22
62
  end
63
+
64
+ "#{mode_name} #{direction.to_s.upcase}"
23
65
  end
24
66
 
25
67
  def sort_requests(requests)
26
- case mode
68
+ sorted = case mode
27
69
  when :timestamp
28
70
  requests.sort_by { |req| req.timestamp || Time.at(0) }
29
71
  when :duration
30
- requests.sort_by { |req| -(req.duration || 0) } # Descending (slowest first)
72
+ requests.sort_by { |req| req.duration || 0 }
31
73
  when :method
32
74
  requests.sort_by { |req| req.method || "" }
33
75
  when :status
34
- requests.sort_by { |req| -(req.status || 0) } # Descending (errors first)
76
+ requests.sort_by { |req| req.status || 0 }
35
77
  else
36
78
  requests
37
79
  end
80
+
81
+ (direction == :desc) ? sorted.reverse : sorted
38
82
  end
39
83
 
40
84
  def timestamp?
@@ -55,7 +99,16 @@ module LogBench
55
99
 
56
100
  private
57
101
 
58
- attr_accessor :mode
102
+ attr_accessor :mode, :direction
103
+
104
+ def toggle_direction
105
+ self.direction = (direction == :asc) ? :desc : :asc
106
+ end
107
+
108
+ def reset_to_default_sort
109
+ self.mode = :timestamp
110
+ self.direction = DEFAULT_DIRECTIONS.fetch(mode)
111
+ end
59
112
  end
60
113
  end
61
114
  end
@@ -7,7 +7,14 @@ module LogBench
7
7
  class State
8
8
  include Singleton
9
9
 
10
- attr_reader :main_filter, :sort, :detail_filter, :cleared_requests, :start_time, :stats, :total_queries
10
+ REQUEST_FILTER_COLUMNS = %i[method path status time].freeze
11
+ NUMERIC_COMPARATOR_REGEX = /\A(<=|>=|<|>)\s*(-?\d+(?:\.\d+)?)\z/
12
+ NUMERIC_COMPARATOR_ONLY_REGEX = /\A(<=|>=|<|>)\s*\z/
13
+ NUMERIC_RANGE_REGEX = /\A(-?\d+(?:\.\d+)?)\s*-\s*(-?\d+(?:\.\d+)?)\z/
14
+ NUMERIC_VALUE_REGEX = /\A-?\d+(?:\.\d+)?\z/
15
+ NUMERIC_COMPARISON_EPSILON = 0.0001
16
+
17
+ attr_reader :main_filter, :sort, :detail_filter, :cleared_requests, :start_time, :stats, :total_queries, :active_request_filter_column
11
18
  attr_accessor :requests, :orphan_requests, :auto_scroll, :scroll_offset, :selected, :detail_scroll_offset, :detail_selected_entry, :text_selection_mode, :update_available, :update_version
12
19
 
13
20
  def initialize
@@ -27,6 +34,8 @@ module LogBench
27
34
  self.text_selection_mode = false
28
35
  self.main_filter = Filter.new
29
36
  self.detail_filter = Filter.new
37
+ self.request_filters = build_request_filters
38
+ self.active_request_filter_column = :path
30
39
  self.sort = Sort.new
31
40
  self.update_available = false
32
41
  self.update_version = nil
@@ -81,6 +90,7 @@ module LogBench
81
90
 
82
91
  def clear_requests_filter
83
92
  main_filter.clear
93
+ request_filters.each_value(&:clear)
84
94
  self.selected = 0
85
95
  self.scroll_offset = 0
86
96
  end
@@ -137,6 +147,18 @@ module LogBench
137
147
  sort.cycle
138
148
  end
139
149
 
150
+ def toggle_request_sort(column)
151
+ return unless sort.toggle_column(column)
152
+
153
+ self.auto_scroll = false
154
+ self.selected = 0
155
+ self.scroll_offset = 0
156
+ end
157
+
158
+ def sort_arrow_for_column(column)
159
+ sort.sort_arrow_for_column(column)
160
+ end
161
+
140
162
  def switch_to_left_pane
141
163
  self.focused_pane = :left
142
164
  end
@@ -156,6 +178,7 @@ module LogBench
156
178
  def enter_filter_mode
157
179
  if left_pane_focused?
158
180
  main_filter.enter_mode
181
+ self.active_request_filter_column ||= :path
159
182
  else
160
183
  detail_filter.enter_mode
161
184
  end
@@ -168,7 +191,7 @@ module LogBench
168
191
 
169
192
  def add_to_filter(char)
170
193
  if main_filter.active?
171
- main_filter.add_character(char)
194
+ active_request_filter.add_character(char)
172
195
  elsif detail_filter.active?
173
196
  detail_filter.add_character(char)
174
197
  end
@@ -176,7 +199,7 @@ module LogBench
176
199
 
177
200
  def backspace_filter
178
201
  if main_filter.active?
179
- main_filter.remove_character
202
+ active_request_filter.remove_character
180
203
  elsif detail_filter.active?
181
204
  detail_filter.remove_character
182
205
  end
@@ -190,19 +213,34 @@ module LogBench
190
213
  detail_filter.active?
191
214
  end
192
215
 
216
+ def request_filter_columns
217
+ REQUEST_FILTER_COLUMNS
218
+ end
219
+
220
+ def request_filter_for(column)
221
+ request_filters[column]
222
+ end
223
+
224
+ def select_request_filter_column(column)
225
+ return unless request_filter_columns.include?(column)
226
+
227
+ self.active_request_filter_column = column
228
+ end
229
+
230
+ def next_request_filter_column
231
+ switch_request_filter_column(1)
232
+ end
233
+
234
+ def previous_request_filter_column
235
+ switch_request_filter_column(-1)
236
+ end
237
+
238
+ def request_filters_present?
239
+ request_filters.values.any?(&:present?) || main_filter.present?
240
+ end
241
+
193
242
  def filtered_requests
194
- filtered = if main_filter.present?
195
- requests.select do |req|
196
- main_filter.matches?(req.path) ||
197
- main_filter.matches?(req.method) ||
198
- main_filter.matches?(req.controller) ||
199
- main_filter.matches?(req.action) ||
200
- main_filter.matches?(req.status) ||
201
- main_filter.matches?(req.request_id)
202
- end
203
- else
204
- requests
205
- end
243
+ filtered = requests.select { |req| request_matches_filters?(req) }
206
244
 
207
245
  sort.sort_requests(filtered)
208
246
  end
@@ -336,8 +374,89 @@ module LogBench
336
374
 
337
375
  private
338
376
 
377
+ attr_reader :request_filters
339
378
  attr_accessor :focused_pane, :running, :job_ids_map
340
- attr_writer :main_filter, :detail_filter, :sort, :cleared_requests, :start_time, :stats, :total_queries
379
+ attr_writer :main_filter, :detail_filter, :sort, :cleared_requests, :start_time, :stats, :total_queries, :request_filters, :active_request_filter_column
380
+
381
+ def build_request_filters
382
+ REQUEST_FILTER_COLUMNS.to_h { |column| [column, Filter.new] }
383
+ end
384
+
385
+ def active_request_filter
386
+ request_filter_for(active_request_filter_column) || request_filter_for(:path)
387
+ end
388
+
389
+ def switch_request_filter_column(direction)
390
+ return unless request_filter_columns.include?(active_request_filter_column)
391
+
392
+ current_index = request_filter_columns.index(active_request_filter_column)
393
+ next_index = (current_index + direction) % request_filter_columns.length
394
+ self.active_request_filter_column = request_filter_columns[next_index]
395
+ end
396
+
397
+ def request_matches_filters?(request)
398
+ matches_legacy_main_filter?(request) && matches_column_filters?(request)
399
+ end
400
+
401
+ def matches_legacy_main_filter?(request)
402
+ return true unless main_filter.present?
403
+
404
+ main_filter.matches?(request.path) ||
405
+ main_filter.matches?(request.method) ||
406
+ main_filter.matches?(request.controller) ||
407
+ main_filter.matches?(request.action) ||
408
+ main_filter.matches?(request.status) ||
409
+ main_filter.matches?(request.request_id)
410
+ end
411
+
412
+ def matches_column_filters?(request)
413
+ request_filters.all? do |column, filter|
414
+ next true unless filter.present?
415
+
416
+ case column
417
+ when :method
418
+ filter.matches?(request.method)
419
+ when :path
420
+ filter.matches?(request.path)
421
+ when :status
422
+ numeric_filter_matches?(request.status, filter.display_text)
423
+ when :time
424
+ numeric_filter_matches?(request.duration, filter.display_text)
425
+ else
426
+ true
427
+ end
428
+ end
429
+ end
430
+
431
+ def numeric_filter_matches?(value, filter_text)
432
+ return true if filter_text.nil?
433
+
434
+ expression = filter_text.strip
435
+ return true if expression.empty?
436
+ return true if expression.match?(NUMERIC_COMPARATOR_ONLY_REGEX)
437
+ return false if value.nil?
438
+
439
+ if (comparison = expression.match(NUMERIC_COMPARATOR_REGEX))
440
+ compare_numeric(value.to_f, comparison[1], comparison[2].to_f)
441
+ elsif (range = expression.match(NUMERIC_RANGE_REGEX))
442
+ min_value, max_value = [range[1].to_f, range[2].to_f].minmax
443
+ value.to_f.between?(min_value, max_value)
444
+ elsif expression.match?(NUMERIC_VALUE_REGEX)
445
+ (value.to_f - expression.to_f).abs <= NUMERIC_COMPARISON_EPSILON
446
+ else
447
+ value.to_s.downcase.include?(expression.downcase)
448
+ end
449
+ end
450
+
451
+ def compare_numeric(value, operator, threshold)
452
+ case operator
453
+ when "<" then value < threshold
454
+ when "<=" then value <= threshold
455
+ when ">" then value > threshold
456
+ when ">=" then value >= threshold
457
+ else false
458
+ end
459
+ end
341
460
  end
342
461
  end
343
462
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LogBench
4
- VERSION = "0.6.1"
4
+ VERSION = "0.7.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: log_bench
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benjamín Silva
@@ -136,6 +136,7 @@ files:
136
136
  - lib/log_bench/app/renderer/details.rb
137
137
  - lib/log_bench/app/renderer/header.rb
138
138
  - lib/log_bench/app/renderer/main.rb
139
+ - lib/log_bench/app/renderer/request_filter_bar.rb
139
140
  - lib/log_bench/app/renderer/request_list.rb
140
141
  - lib/log_bench/app/renderer/scrollbar.rb
141
142
  - lib/log_bench/app/renderer/update_modal.rb