log_bench 0.2.6 → 0.2.9

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.
@@ -8,6 +8,7 @@ module LogBench
8
8
  class Details
9
9
  include Curses
10
10
  EMPTY_LINE = {text: "", color: nil}
11
+ SEPARATOR_LINE = {text: "", color: nil, separator: true}
11
12
 
12
13
  def initialize(screen, state, scrollbar, ansi_renderer)
13
14
  self.screen = screen
@@ -26,6 +27,20 @@ module LogBench
26
27
  draw_request_details
27
28
  end
28
29
 
30
+ def get_cached_detail_lines(request)
31
+ current_cache_key = build_cache_key(request)
32
+
33
+ # Return cached lines if cache is still valid
34
+ if cached_lines && cache_key == current_cache_key
35
+ return cached_lines
36
+ end
37
+
38
+ # Cache is invalid, rebuild lines
39
+ self.cached_lines = build_detail_lines(request)
40
+ self.cache_key = current_cache_key
41
+ cached_lines
42
+ end
43
+
29
44
  private
30
45
 
31
46
  attr_accessor :screen, :state, :scrollbar, :ansi_renderer, :cached_lines, :cache_key
@@ -60,16 +75,34 @@ module LogBench
60
75
  visible_height = detail_win.maxy - 2
61
76
 
62
77
  adjust_detail_scroll(lines.size, visible_height)
78
+ state.adjust_detail_scroll_for_entry_selection(visible_height, lines)
79
+
80
+ # Find all unique entry IDs and determine selected entry, excluding separator lines
81
+ entry_ids = lines.reject { |line| line[:separator] }.map { |line| line[:entry_id] }.compact.uniq
82
+ selected_entry_id = entry_ids[state.detail_selected_entry] if state.detail_selected_entry < entry_ids.size
63
83
 
64
84
  visible_lines = lines[state.detail_scroll_offset, visible_height] || []
65
85
  visible_lines.each_with_index do |line_data, i|
66
86
  y = i + 1 # Start at row 1 (after border)
87
+ # Don't highlight separator lines
88
+ is_selected = !line_data[:separator] && line_data[:entry_id] == selected_entry_id && state.right_pane_focused?
89
+
90
+ # Draw highlight background if selected
91
+ if is_selected
92
+ detail_win.setpos(y, 1)
93
+ detail_win.attron(color_pair(10) | A_DIM) do
94
+ detail_win.addstr(" " * (detail_win.maxx - 2))
95
+ end
96
+ end
97
+
67
98
  detail_win.setpos(y, 2)
68
99
 
69
100
  # Handle multi-segment lines (for mixed colors)
70
101
  if line_data.is_a?(Hash) && line_data[:segments]
71
102
  line_data[:segments].each do |segment|
72
- if segment[:color]
103
+ if is_selected
104
+ detail_win.attron(color_pair(10) | A_DIM) { detail_win.addstr(segment[:text]) }
105
+ elsif segment[:color]
73
106
  detail_win.attron(segment[:color]) { detail_win.addstr(segment[:text]) }
74
107
  else
75
108
  detail_win.addstr(segment[:text])
@@ -77,16 +110,26 @@ module LogBench
77
110
  end
78
111
  elsif line_data.is_a?(Hash) && line_data[:raw_ansi]
79
112
  # Handle lines with raw ANSI codes (like colorized SQL)
80
- ansi_renderer.parse_and_render(line_data[:text], detail_win)
113
+ if is_selected
114
+ # For selected ANSI lines, render without ANSI codes to maintain highlight
115
+ plain_text = line_data[:text].gsub(/\e\[[0-9;]*m/, "")
116
+ detail_win.attron(color_pair(10) | A_DIM) { detail_win.addstr(plain_text) }
117
+ else
118
+ ansi_renderer.parse_and_render(line_data[:text], detail_win)
119
+ end
81
120
  elsif line_data.is_a?(Hash)
82
121
  # Handle single-color lines
83
- if line_data[:color]
122
+ if is_selected
123
+ detail_win.attron(color_pair(10) | A_DIM) { detail_win.addstr(line_data[:text]) }
124
+ elsif line_data[:color]
84
125
  detail_win.attron(line_data[:color]) { detail_win.addstr(line_data[:text]) }
85
126
  else
86
127
  detail_win.addstr(line_data[:text])
87
128
  end
88
- else
129
+ elsif is_selected
89
130
  # Simple string
131
+ detail_win.attron(color_pair(10) | A_DIM) { detail_win.addstr(line_data.to_s) }
132
+ else
90
133
  detail_win.addstr(line_data.to_s)
91
134
  end
92
135
  end
@@ -97,20 +140,6 @@ module LogBench
97
140
  end
98
141
  end
99
142
 
100
- def get_cached_detail_lines(request)
101
- current_cache_key = build_cache_key(request)
102
-
103
- # Return cached lines if cache is still valid
104
- if cached_lines && cache_key == current_cache_key
105
- return cached_lines
106
- end
107
-
108
- # Cache is invalid, rebuild lines
109
- self.cached_lines = build_detail_lines(request)
110
- self.cache_key = current_cache_key
111
- cached_lines
112
- end
113
-
114
143
  def build_cache_key(request)
115
144
  # Cache key includes factors that affect the rendered output
116
145
  [
@@ -123,6 +152,7 @@ module LogBench
123
152
 
124
153
  def build_detail_lines(request)
125
154
  lines = []
155
+ entry_id = 0 # Track logical log entries
126
156
  # Cache window width to avoid repeated method calls
127
157
  window_width = detail_win.maxx
128
158
  max_width = window_width - 6 # Leave margin for borders and scrollbar
@@ -136,10 +166,12 @@ module LogBench
136
166
  else color_pair(2) | A_BOLD
137
167
  end
138
168
 
139
- lines << EMPTY_LINE
169
+ lines << EMPTY_LINE.merge(entry_id: entry_id)
170
+ entry_id += 1
140
171
  lines << {
141
172
  text: "Method: #{request.method}",
142
173
  color: nil,
174
+ entry_id: entry_id,
143
175
  segments: [
144
176
  {text: "Method: ", color: color_pair(1)},
145
177
  {text: request.method, color: method_color}
@@ -147,17 +179,23 @@ module LogBench
147
179
  }
148
180
 
149
181
  # Path - allow multiple lines with proper color separation
150
- add_path_lines(lines, request, max_width)
151
- add_status_duration_lines(lines, request)
152
- add_controller_lines(lines, request)
153
- add_request_id_lines(lines, request)
154
- add_params_lines(lines, request, max_width)
155
- add_related_logs_section(lines, request)
182
+ entry_id += 1
183
+ add_path_lines(lines, request, max_width, entry_id)
184
+ entry_id += 1
185
+ add_status_duration_lines(lines, request, entry_id)
186
+ entry_id += 1
187
+ add_controller_lines(lines, request, entry_id)
188
+ entry_id += 1
189
+ add_request_id_lines(lines, request, entry_id)
190
+ entry_id += 1
191
+ add_params_lines(lines, request, max_width, entry_id)
192
+ entry_id += 1
193
+ add_related_logs_section(lines, request, entry_id)
156
194
 
157
195
  lines
158
196
  end
159
197
 
160
- def add_path_lines(lines, request, max_width)
198
+ def add_path_lines(lines, request, max_width, entry_id)
161
199
  path_prefix = "Path: "
162
200
  remaining_path = request.path
163
201
 
@@ -167,6 +205,7 @@ module LogBench
167
205
  lines << {
168
206
  text: path_prefix + remaining_path,
169
207
  color: nil,
208
+ entry_id: entry_id,
170
209
  segments: [
171
210
  {text: path_prefix, color: color_pair(1)},
172
211
  {text: remaining_path, color: nil} # Default white color
@@ -178,6 +217,7 @@ module LogBench
178
217
  lines << {
179
218
  text: path_prefix + first_chunk,
180
219
  color: nil,
220
+ entry_id: entry_id,
181
221
  segments: [
182
222
  {text: path_prefix, color: color_pair(1)},
183
223
  {text: first_chunk, color: nil} # Default white color
@@ -188,13 +228,13 @@ module LogBench
188
228
  # Continue on subsequent lines
189
229
  while remaining_path.length > 0
190
230
  line_chunk = remaining_path[0, max_width]
191
- lines << {text: line_chunk, color: nil} # Default white color
231
+ lines << {text: line_chunk, color: nil, entry_id: entry_id} # Default white color
192
232
  remaining_path = remaining_path[max_width..] || ""
193
233
  end
194
234
  end
195
235
  end
196
236
 
197
- def add_status_duration_lines(lines, request)
237
+ def add_status_duration_lines(lines, request, entry_id)
198
238
  if request.status
199
239
  # Add status color coding
200
240
  status_color = case request.status
@@ -219,17 +259,19 @@ module LogBench
219
259
  lines << {
220
260
  text: status_text,
221
261
  color: nil,
262
+ entry_id: entry_id,
222
263
  segments: segments
223
264
  }
224
265
  end
225
266
  end
226
267
 
227
- def add_controller_lines(lines, request)
268
+ def add_controller_lines(lines, request, entry_id)
228
269
  if request.controller
229
270
  controller_value = "#{request.controller}##{request.action}"
230
271
  lines << {
231
272
  text: "Controller: #{controller_value}",
232
273
  color: nil,
274
+ entry_id: entry_id,
233
275
  segments: [
234
276
  {text: "Controller: ", color: color_pair(1)},
235
277
  {text: controller_value, color: nil} # Default white color
@@ -238,13 +280,14 @@ module LogBench
238
280
  end
239
281
  end
240
282
 
241
- def add_params_lines(lines, request, max_width)
283
+ def add_params_lines(lines, request, max_width, entry_id)
242
284
  return unless request.params
243
285
 
244
- lines << EMPTY_LINE
286
+ lines << EMPTY_LINE.merge(entry_id: entry_id)
245
287
  lines << {
246
288
  text: "Params:",
247
289
  color: nil,
290
+ entry_id: entry_id,
248
291
  segments: [
249
292
  {text: "Params:", color: color_pair(1) | A_BOLD}
250
293
  ]
@@ -259,7 +302,7 @@ module LogBench
259
302
 
260
303
  while remaining_text && remaining_text.length > 0
261
304
  line_chunk = remaining_text[0, line_width]
262
- lines << {text: indent + line_chunk, color: nil}
305
+ lines << {text: indent + line_chunk, color: nil, entry_id: entry_id}
263
306
  remaining_text = remaining_text[line_width..] || ""
264
307
  end
265
308
  end
@@ -312,11 +355,12 @@ module LogBench
312
355
  end
313
356
  end
314
357
 
315
- def add_request_id_lines(lines, request)
358
+ def add_request_id_lines(lines, request, entry_id)
316
359
  if request.request_id
317
360
  lines << {
318
361
  text: "Request ID: #{request.request_id}",
319
362
  color: nil,
363
+ entry_id: entry_id,
320
364
  segments: [
321
365
  {text: "Request ID: ", color: color_pair(1)},
322
366
  {text: request.request_id, color: nil} # Default white color
@@ -333,7 +377,7 @@ module LogBench
333
377
  screen.detail_win
334
378
  end
335
379
 
336
- def add_related_logs_section(lines, request)
380
+ def add_related_logs_section(lines, request, entry_id)
337
381
  # Related Logs (grouped by request_id) - only show non-HTTP request logs
338
382
  if request.request_id && request.related_logs && !request.related_logs.empty?
339
383
  related_logs = request.related_logs
@@ -341,45 +385,30 @@ module LogBench
341
385
  # Apply detail filter to related logs
342
386
  filtered_related_logs = filter_related_logs(related_logs)
343
387
 
344
- # Use memoized query statistics from request object
345
- query_stats = build_query_stats_from_request(request)
388
+ # Use QuerySummary for consistent formatting
389
+ query_summary = QuerySummary.new(request)
390
+ query_stats = query_summary.build_stats
346
391
 
347
392
  # Add query summary
348
- lines << EMPTY_LINE
393
+ lines << EMPTY_LINE.merge(entry_id: entry_id)
349
394
 
350
395
  # Show filter status in summary if filtering is active
351
396
  summary_title = "Query Summary:"
352
- lines << {text: summary_title, color: color_pair(1) | A_BOLD}
397
+ lines << {text: summary_title, color: color_pair(1) | A_BOLD, entry_id: entry_id}
353
398
 
354
399
  if query_stats[:total_queries] > 0
355
- # Build summary line with string interpolation
356
- summary_parts = ["#{query_stats[:total_queries]} queries"]
357
-
358
- if query_stats[:total_time] > 0
359
- time_part = "#{query_stats[:total_time].round(1)}ms total"
360
- time_part += ", #{query_stats[:cached_queries]} cached" if query_stats[:cached_queries] > 0
361
- summary_parts << "(#{time_part})"
362
- elsif query_stats[:cached_queries] > 0
363
- summary_parts << "(#{query_stats[:cached_queries]} cached)"
364
- end
365
-
366
- lines << {text: " #{summary_parts.join(" ")}", color: color_pair(2)}
367
-
368
- # Breakdown by operation type - build array efficiently
369
- breakdown_parts = [
370
- ("#{query_stats[:select]} SELECT" if query_stats[:select] > 0),
371
- ("#{query_stats[:insert]} INSERT" if query_stats[:insert] > 0),
372
- ("#{query_stats[:update]} UPDATE" if query_stats[:update] > 0),
373
- ("#{query_stats[:delete]} DELETE" if query_stats[:delete] > 0),
374
- ("#{query_stats[:transaction]} TRANSACTION" if query_stats[:transaction] > 0)
375
- ].compact
400
+ # Use QuerySummary methods for consistent formatting
401
+ summary_line = query_summary.build_summary_line(query_stats)
402
+ lines << {text: " #{summary_line}", color: color_pair(2), entry_id: entry_id}
376
403
 
377
- unless breakdown_parts.empty?
378
- lines << {text: " #{breakdown_parts.join(", ")}", color: color_pair(2)}
404
+ breakdown_line = query_summary.build_breakdown_line(query_stats)
405
+ unless breakdown_line.empty?
406
+ lines << {text: " #{breakdown_line}", color: color_pair(2), entry_id: entry_id}
379
407
  end
380
408
  end
381
409
 
382
- lines << EMPTY_LINE
410
+ entry_id += 1
411
+ lines << EMPTY_LINE.merge(entry_id: entry_id)
383
412
 
384
413
  # Show filtered logs section
385
414
  if state.detail_filter.present?
@@ -388,6 +417,7 @@ module LogBench
388
417
  lines << {
389
418
  text: logs_title_text,
390
419
  color: nil,
420
+ entry_id: entry_id,
391
421
  segments: [
392
422
  {text: "Related Logs ", color: color_pair(1) | A_BOLD},
393
423
  {text: count_text, color: A_DIM},
@@ -395,61 +425,37 @@ module LogBench
395
425
  ]
396
426
  }
397
427
  else
398
- lines << {text: "Related Logs:", color: color_pair(1) | A_BOLD}
428
+ lines << {text: "Related Logs:", color: color_pair(1) | A_BOLD, entry_id: entry_id}
399
429
  end
400
430
 
401
- # Use filtered logs for display
402
- filtered_related_logs.each do |related|
403
- case related.type
404
- when :sql, :cache
405
- render_padded_text_with_spacing(related.content, lines, extra_empty_lines: 0)
431
+ # Use filtered logs for display - group SQL queries with their call source lines
432
+ i = 0
433
+ while i < filtered_related_logs.size
434
+ current_log = filtered_related_logs[i]
435
+ next_log = filtered_related_logs[i + 1] if i + 1 < filtered_related_logs.size
436
+
437
+ entry_id += 1
438
+
439
+ # Check if current log is a SQL/cache query followed by a call source line
440
+ if [:sql, :cache].include?(current_log.type) && next_log && next_log.type == :sql_call_line
441
+ # Group the query and call source together with the same entry_id
442
+ render_padded_text_with_spacing(current_log.content, lines, entry_id, extra_empty_lines: 0)
443
+ render_padded_text_with_spacing(next_log.content, lines, entry_id, extra_empty_lines: 1, use_separator: true)
444
+ i += 2 # Skip the next log since we processed it
406
445
  else
407
- render_padded_text_with_spacing(related.content, lines, extra_empty_lines: 1)
446
+ # Handle standalone logs
447
+ case current_log.type
448
+ when :sql, :cache
449
+ render_padded_text_with_spacing(current_log.content, lines, entry_id, extra_empty_lines: 0)
450
+ else
451
+ render_padded_text_with_spacing(current_log.content, lines, entry_id, extra_empty_lines: 1)
452
+ end
453
+ i += 1
408
454
  end
409
455
  end
410
456
  end
411
457
  end
412
458
 
413
- def build_query_stats_from_request(request)
414
- # Use memoized methods from request object for better performance
415
- stats = {
416
- total_queries: request.query_count,
417
- total_time: request.total_query_time,
418
- cached_queries: request.cached_query_count,
419
- select: 0,
420
- insert: 0,
421
- update: 0,
422
- delete: 0,
423
- transaction: 0
424
- }
425
-
426
- # Categorize by operation type for breakdown
427
- request.related_logs.each do |log|
428
- next unless [:sql, :cache].include?(log.type)
429
-
430
- categorize_sql_operation(log, stats)
431
- end
432
-
433
- stats
434
- end
435
-
436
- def categorize_sql_operation(log, stats)
437
- # Use unified QueryEntry for both SQL and CACHE entries
438
- return unless log.is_a?(LogBench::Log::QueryEntry)
439
-
440
- if log.select?
441
- stats[:select] += 1
442
- elsif log.insert?
443
- stats[:insert] += 1
444
- elsif log.update?
445
- stats[:update] += 1
446
- elsif log.delete?
447
- stats[:delete] += 1
448
- elsif log.transaction? || log.begin? || log.commit? || log.rollback? || log.savepoint?
449
- stats[:transaction] += 1
450
- end
451
- end
452
-
453
459
  def filter_related_logs(related_logs)
454
460
  # Filter related logs (SQL, cache, etc.) in the detail pane
455
461
  return related_logs unless state.detail_filter.present?
@@ -479,7 +485,7 @@ module LogBench
479
485
  matched_indices.sort.map { |index| related_logs[index] }
480
486
  end
481
487
 
482
- def render_padded_text_with_spacing(text, lines, extra_empty_lines: 1)
488
+ def render_padded_text_with_spacing(text, lines, entry_id, extra_empty_lines: 1, use_separator: false)
483
489
  # Helper function that renders text with padding, breaking long text into multiple lines
484
490
  content_width = detail_win.maxx - 8 # Account for padding (4 spaces each side)
485
491
 
@@ -497,14 +503,18 @@ module LogBench
497
503
  # Render each chunk as a separate line with padding
498
504
  text_chunks.each do |chunk|
499
505
  lines << if has_ansi
500
- {text: " #{chunk} ", color: nil, raw_ansi: true}
506
+ {text: " #{chunk} ", color: nil, raw_ansi: true, entry_id: entry_id}
501
507
  else
502
- {text: " #{chunk} ", color: nil}
508
+ {text: " #{chunk} ", color: nil, entry_id: entry_id}
503
509
  end
504
510
  end
505
511
 
506
512
  # Add extra empty lines after all chunks
507
- extra_empty_lines.times { lines << EMPTY_LINE }
513
+ if use_separator
514
+ extra_empty_lines.times { lines << SEPARATOR_LINE }
515
+ else
516
+ extra_empty_lines.times { lines << EMPTY_LINE.merge(entry_id: entry_id) }
517
+ end
508
518
  end
509
519
 
510
520
  def adjust_detail_scroll(total_lines, visible_height)
@@ -84,7 +84,7 @@ module LogBench
84
84
 
85
85
  header_win.setpos(3, 2)
86
86
  header_win.attron(A_DIM) do
87
- header_win.addstr("←→/hl:Switch Pane | ↑↓/jk/Click:Navigate | g/G:Top/End")
87
+ header_win.addstr("←→/hl:Switch Pane | ↑↓/jk/Click:Navigate | g/G:Top/End | y:Copy highlighted")
88
88
  end
89
89
  end
90
90
 
@@ -30,6 +30,10 @@ module LogBench
30
30
  update_modal.handle_input(ch)
31
31
  end
32
32
 
33
+ def get_cached_detail_lines(request)
34
+ details.get_cached_detail_lines(request)
35
+ end
36
+
33
37
  private
34
38
 
35
39
  attr_accessor :screen, :state, :header, :scrollbar, :request_list, :ansi_renderer, :details, :update_modal
@@ -4,7 +4,7 @@ module LogBench
4
4
  module App
5
5
  class State
6
6
  attr_reader :main_filter, :sort, :detail_filter
7
- attr_accessor :requests, :auto_scroll, :scroll_offset, :selected, :detail_scroll_offset, :text_selection_mode, :update_available, :update_version
7
+ attr_accessor :requests, :auto_scroll, :scroll_offset, :selected, :detail_scroll_offset, :detail_selected_entry, :text_selection_mode, :update_available, :update_version
8
8
 
9
9
  def initialize
10
10
  self.requests = []
@@ -14,6 +14,7 @@ module LogBench
14
14
  self.running = true
15
15
  self.focused_pane = :left
16
16
  self.detail_scroll_offset = 0
17
+ self.detail_selected_entry = 0
17
18
  self.text_selection_mode = false
18
19
  self.main_filter = Filter.new
19
20
  self.detail_filter = Filter.new
@@ -65,6 +66,7 @@ module LogBench
65
66
  def clear_detail_filter
66
67
  detail_filter.clear
67
68
  self.detail_scroll_offset = 0
69
+ self.detail_selected_entry = 0
68
70
  end
69
71
 
70
72
  def cycle_sort_mode
@@ -153,7 +155,7 @@ module LogBench
153
155
  self.selected = [selected - 1, 0].max
154
156
  self.auto_scroll = false
155
157
  else
156
- self.detail_scroll_offset = [detail_scroll_offset - 1, 0].max
158
+ self.detail_selected_entry = [detail_selected_entry - 1, 0].max
157
159
  end
158
160
  end
159
161
 
@@ -163,10 +165,15 @@ module LogBench
163
165
  self.selected = [selected + 1, max_index].min
164
166
  self.auto_scroll = false
165
167
  else
166
- self.detail_scroll_offset += 1
168
+ self.detail_selected_entry += 1
167
169
  end
168
170
  end
169
171
 
172
+ def reset_detail_selection
173
+ self.detail_selected_entry = 0
174
+ self.detail_scroll_offset = 0
175
+ end
176
+
170
177
  def adjust_scroll_for_selection(visible_height)
171
178
  return unless left_pane_focused?
172
179
 
@@ -190,6 +197,41 @@ module LogBench
190
197
  self.scroll_offset = scroll_offset.clamp(0, max_offset)
191
198
  end
192
199
 
200
+ def adjust_detail_scroll_for_entry_selection(visible_height, lines)
201
+ return unless right_pane_focused?
202
+
203
+ # Find all unique entry IDs, excluding separator lines
204
+ entry_ids = lines.reject { |line| line[:separator] }.map { |line| line[:entry_id] }.compact.uniq
205
+ max_entry_index = [entry_ids.size - 1, 0].max
206
+
207
+ # Ensure detail_selected_entry is within bounds
208
+ self.detail_selected_entry = detail_selected_entry.clamp(0, max_entry_index)
209
+
210
+ # Find the first and last line of the selected entry
211
+ selected_entry_id = entry_ids[detail_selected_entry]
212
+ return unless selected_entry_id
213
+
214
+ first_line_index = lines.find_index { |line| line[:entry_id] == selected_entry_id }
215
+ return unless first_line_index
216
+
217
+ # Find the last line of the selected entry (including any separator lines that follow)
218
+ last_line_index = first_line_index
219
+ (first_line_index + 1...lines.size).each do |i|
220
+ if lines[i][:entry_id] == selected_entry_id || lines[i][:separator]
221
+ last_line_index = i
222
+ else
223
+ break
224
+ end
225
+ end
226
+
227
+ # Adjust scroll to keep the entire selected entry visible
228
+ if first_line_index < detail_scroll_offset
229
+ self.detail_scroll_offset = first_line_index
230
+ elsif last_line_index >= detail_scroll_offset + visible_height
231
+ self.detail_scroll_offset = last_line_index - visible_height + 1
232
+ end
233
+ end
234
+
193
235
  private
194
236
 
195
237
  attr_accessor :focused_pane, :running
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LogBench
4
- VERSION = "0.2.6"
4
+ VERSION = "0.2.9"
5
5
  end