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,488 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module LogBench
6
+ module App
7
+ module Renderer
8
+ class Details
9
+ include Curses
10
+
11
+ def initialize(screen, state, scrollbar, ansi_renderer)
12
+ self.screen = screen
13
+ self.state = state
14
+ self.scrollbar = scrollbar
15
+ self.ansi_renderer = ansi_renderer
16
+ end
17
+
18
+ def draw
19
+ detail_win.erase
20
+ detail_win.box(0, 0)
21
+
22
+ draw_header
23
+ draw_request_details
24
+ end
25
+
26
+ private
27
+
28
+ attr_accessor :screen, :state, :scrollbar, :ansi_renderer
29
+
30
+ def draw_header
31
+ detail_win.setpos(0, 2)
32
+
33
+ if state.right_pane_focused?
34
+ detail_win.attron(color_pair(1) | A_BOLD) { detail_win.addstr(" Request Details ") }
35
+ else
36
+ detail_win.attron(color_pair(2) | A_DIM) { detail_win.addstr(" Request Details ") }
37
+ end
38
+
39
+ # Show detail filter to the right of the title (always visible when active)
40
+ if state.detail_filter.present? || state.detail_filter.active?
41
+ filter_text = "Filter: #{state.detail_filter.cursor_display}"
42
+
43
+ # Position filter text to the right, leaving some space
44
+ filter_x = detail_win.maxx - filter_text.length - 3
45
+ if filter_x > 20 # Only show if there's enough space
46
+ detail_win.setpos(0, filter_x)
47
+ detail_win.attron(color_pair(4)) { detail_win.addstr(filter_text) }
48
+ end
49
+ end
50
+ end
51
+
52
+ def draw_request_details
53
+ request = state.current_request
54
+ return unless request
55
+
56
+ lines = build_detail_lines(request)
57
+ visible_height = detail_win.maxy - 2
58
+
59
+ adjust_detail_scroll(lines.size, visible_height)
60
+
61
+ visible_lines = lines[state.detail_scroll_offset, visible_height] || []
62
+ visible_lines.each_with_index do |line_data, i|
63
+ y = i + 1 # Start at row 1 (after border)
64
+ detail_win.setpos(y, 2)
65
+
66
+ # Handle multi-segment lines (for mixed colors)
67
+ if line_data.is_a?(Hash) && line_data[:segments]
68
+ line_data[:segments].each do |segment|
69
+ if segment[:color]
70
+ detail_win.attron(segment[:color]) { detail_win.addstr(segment[:text]) }
71
+ else
72
+ detail_win.addstr(segment[:text])
73
+ end
74
+ end
75
+ elsif line_data.is_a?(Hash) && line_data[:raw_ansi]
76
+ # Handle lines with raw ANSI codes (like colorized SQL)
77
+ ansi_renderer.parse_and_render(line_data[:text], detail_win)
78
+ elsif line_data.is_a?(Hash)
79
+ # Handle single-color lines
80
+ if line_data[:color]
81
+ detail_win.attron(line_data[:color]) { detail_win.addstr(line_data[:text]) }
82
+ else
83
+ detail_win.addstr(line_data[:text])
84
+ end
85
+ else
86
+ # Simple string
87
+ detail_win.addstr(line_data.to_s)
88
+ end
89
+ end
90
+
91
+ # Draw scrollbar if needed
92
+ if lines.size > visible_height
93
+ scrollbar.draw(detail_win, visible_height, state.detail_scroll_offset, lines.size)
94
+ end
95
+ end
96
+
97
+ def build_detail_lines(request)
98
+ lines = []
99
+ max_width = detail_win.maxx - 6 # Leave margin for borders and scrollbar
100
+
101
+ # Convert request to log format for compatibility with original implementation
102
+ log = request_to_log_format(request)
103
+
104
+ # Method - separate label and value colors
105
+ method_color = case log[:method]
106
+ when "GET" then color_pair(3) | A_BOLD
107
+ when "POST" then color_pair(4) | A_BOLD
108
+ when "PUT" then color_pair(5) | A_BOLD
109
+ when "DELETE" then color_pair(6) | A_BOLD
110
+ else color_pair(2) | A_BOLD
111
+ end
112
+
113
+ lines << {text: "", color: nil} # Empty line
114
+ lines << {
115
+ text: "Method: #{log[:method]}",
116
+ color: nil,
117
+ segments: [
118
+ {text: "Method: ", color: color_pair(1)},
119
+ {text: log[:method], color: method_color}
120
+ ]
121
+ }
122
+
123
+ # Path - allow multiple lines with proper color separation
124
+ add_path_lines(lines, log, max_width)
125
+ add_status_duration_lines(lines, log)
126
+ add_controller_lines(lines, log)
127
+ add_request_id_lines(lines, log)
128
+ add_related_logs_section(lines, log)
129
+
130
+ lines
131
+ end
132
+
133
+ def request_to_log_format(request)
134
+ {
135
+ method: request.method,
136
+ path: request.path,
137
+ status: request.status,
138
+ duration: request.duration,
139
+ controller: request.controller,
140
+ action: request.action,
141
+ request_id: request.request_id,
142
+ related_logs: build_related_logs(request)
143
+ }
144
+ end
145
+
146
+ def build_related_logs(request)
147
+ related = []
148
+
149
+ # Add all related logs from the request
150
+ request.related_logs.each do |log|
151
+ content = case log
152
+ when LogBench::Log::QueryEntry, LogBench::Log::CacheEntry, LogBench::Log::CallLineEntry
153
+ log.content
154
+ else
155
+ log.raw_line # Fallback to raw line for other entry types
156
+ end
157
+
158
+ timing = case log
159
+ when LogBench::Log::QueryEntry, LogBench::Log::CacheEntry
160
+ log.timing
161
+ end
162
+
163
+ related << {
164
+ type: log.type,
165
+ content: content,
166
+ timing: timing,
167
+ timestamp: log.timestamp
168
+ }
169
+ end
170
+
171
+ related
172
+ end
173
+
174
+ def add_path_lines(lines, log, max_width)
175
+ path_prefix = "Path: "
176
+ remaining_path = log[:path]
177
+
178
+ # First line starts after "Path: " (6 characters)
179
+ first_line_width = max_width - path_prefix.length
180
+ if remaining_path.length <= first_line_width
181
+ lines << {
182
+ text: path_prefix + remaining_path,
183
+ color: nil,
184
+ segments: [
185
+ {text: path_prefix, color: color_pair(1)},
186
+ {text: remaining_path, color: nil} # Default white color
187
+ ]
188
+ }
189
+ else
190
+ # Split into multiple lines
191
+ first_chunk = remaining_path[0, first_line_width]
192
+ lines << {
193
+ text: path_prefix + first_chunk,
194
+ color: nil,
195
+ segments: [
196
+ {text: path_prefix, color: color_pair(1)},
197
+ {text: first_chunk, color: nil} # Default white color
198
+ ]
199
+ }
200
+ remaining_path = remaining_path[first_line_width..]
201
+
202
+ # Continue on subsequent lines
203
+ while remaining_path.length > 0
204
+ line_chunk = remaining_path[0, max_width]
205
+ lines << {text: line_chunk, color: nil} # Default white color
206
+ remaining_path = remaining_path[max_width..] || ""
207
+ end
208
+ end
209
+ end
210
+
211
+ def add_status_duration_lines(lines, log)
212
+ if log[:status]
213
+ # Add status color coding
214
+ status_color = case log[:status]
215
+ when 200..299 then color_pair(3) # Green
216
+ when 300..399 then color_pair(4) # Yellow
217
+ when 400..599 then color_pair(6) # Red
218
+ else color_pair(2) # Default
219
+ end
220
+
221
+ # Build segments for mixed coloring
222
+ segments = [
223
+ {text: "Status: ", color: color_pair(1)},
224
+ {text: log[:status].to_s, color: status_color}
225
+ ]
226
+
227
+ if log[:duration]
228
+ segments << {text: " | Duration: ", color: color_pair(1)}
229
+ segments << {text: "#{log[:duration]}ms", color: nil} # Default white color
230
+ end
231
+
232
+ status_text = segments.map { |s| s[:text] }.join
233
+ lines << {
234
+ text: status_text,
235
+ color: nil,
236
+ segments: segments
237
+ }
238
+ end
239
+ end
240
+
241
+ def add_controller_lines(lines, log)
242
+ if log[:controller]
243
+ controller_value = "#{log[:controller]}##{log[:action]}"
244
+ lines << {
245
+ text: "Controller: #{controller_value}",
246
+ color: nil,
247
+ segments: [
248
+ {text: "Controller: ", color: color_pair(1)},
249
+ {text: controller_value, color: nil} # Default white color
250
+ ]
251
+ }
252
+ end
253
+ end
254
+
255
+ def add_request_id_lines(lines, log)
256
+ if log[:request_id]
257
+ lines << {
258
+ text: "Request ID: #{log[:request_id]}",
259
+ color: nil,
260
+ segments: [
261
+ {text: "Request ID: ", color: color_pair(1)},
262
+ {text: log[:request_id], color: nil} # Default white color
263
+ ]
264
+ }
265
+ end
266
+ end
267
+
268
+ def color_pair(n)
269
+ screen.color_pair(n)
270
+ end
271
+
272
+ def detail_win
273
+ screen.detail_win
274
+ end
275
+
276
+ def add_related_logs_section(lines, log)
277
+ # Related Logs (grouped by request_id) - only show non-HTTP request logs
278
+ if log[:request_id] && log[:related_logs] && !log[:related_logs].empty?
279
+ related_logs = log[:related_logs]
280
+
281
+ # Sort by timestamp
282
+ related_logs.sort_by! { |l| l[:timestamp] || Time.at(0) }
283
+
284
+ # Apply detail filter to related logs
285
+ filtered_related_logs = filter_related_logs(related_logs)
286
+
287
+ # Calculate query statistics (use original logs for stats)
288
+ query_stats = calculate_query_stats(related_logs)
289
+
290
+ # Add query summary
291
+ lines << {text: "", color: nil} # Empty line
292
+
293
+ # Show filter status in summary if filtering is active
294
+ summary_title = "Query Summary:"
295
+ lines << {text: summary_title, color: color_pair(1) | A_BOLD}
296
+
297
+ if query_stats[:total_queries] > 0
298
+ summary_line = " #{query_stats[:total_queries]} queries"
299
+ if query_stats[:total_time] > 0
300
+ summary_line += " (#{query_stats[:total_time]}ms total"
301
+ if query_stats[:cached_queries] > 0
302
+ summary_line += ", #{query_stats[:cached_queries]} cached"
303
+ end
304
+ summary_line += ")"
305
+ elsif query_stats[:cached_queries] > 0
306
+ summary_line += " (#{query_stats[:cached_queries]} cached)"
307
+ end
308
+ lines << {text: summary_line, color: color_pair(2)}
309
+
310
+ # Breakdown by operation type
311
+ breakdown_parts = []
312
+ breakdown_parts << "#{query_stats[:select]} SELECT" if query_stats[:select] > 0
313
+ breakdown_parts << "#{query_stats[:insert]} INSERT" if query_stats[:insert] > 0
314
+ breakdown_parts << "#{query_stats[:update]} UPDATE" if query_stats[:update] > 0
315
+ breakdown_parts << "#{query_stats[:delete]} DELETE" if query_stats[:delete] > 0
316
+ breakdown_parts << "#{query_stats[:transaction]} TRANSACTION" if query_stats[:transaction] > 0
317
+ breakdown_parts << "#{query_stats[:cache]} CACHE" if query_stats[:cache] > 0
318
+
319
+ if !breakdown_parts.empty?
320
+ breakdown_line = " " + breakdown_parts.join(", ")
321
+ lines << {text: breakdown_line, color: color_pair(2)}
322
+ end
323
+ end
324
+
325
+ lines << {text: "", color: nil} # Empty line
326
+
327
+ # Show filtered logs section
328
+ if state.detail_filter.present?
329
+ count_text = "(#{filtered_related_logs.size}/#{related_logs.size} shown)"
330
+ logs_title_text = "Related Logs #{count_text}:"
331
+ lines << {
332
+ text: logs_title_text,
333
+ color: nil,
334
+ segments: [
335
+ {text: "Related Logs ", color: color_pair(1) | A_BOLD},
336
+ {text: count_text, color: A_DIM},
337
+ {text: ":", color: color_pair(1) | A_BOLD}
338
+ ]
339
+ }
340
+ else
341
+ lines << {text: "Related Logs:", color: color_pair(1) | A_BOLD}
342
+ end
343
+
344
+ # Use filtered logs for display
345
+ filtered_related_logs.each do |related|
346
+ case related[:type]
347
+ when :sql, :cache
348
+ render_padded_text_with_spacing(related[:content], lines, extra_empty_lines: 0)
349
+ else
350
+ render_padded_text_with_spacing(related[:content], lines, extra_empty_lines: 1)
351
+ end
352
+ end
353
+ end
354
+ end
355
+
356
+ def calculate_query_stats(related_logs)
357
+ stats = {
358
+ total_queries: 0,
359
+ total_time: 0.0,
360
+ select: 0,
361
+ insert: 0,
362
+ update: 0,
363
+ delete: 0,
364
+ transaction: 0,
365
+ cache: 0,
366
+ cached_queries: 0
367
+ }
368
+
369
+ related_logs.each do |log|
370
+ next unless [:sql, :cache].include?(log[:type])
371
+
372
+ stats[:total_queries] += 1
373
+
374
+ # Extract timing from the content
375
+ if log[:timing]
376
+ # Parse timing like "(1.2ms)" or "1.2ms"
377
+ timing_str = log[:timing].gsub(/[()ms]/, "")
378
+ timing_value = timing_str.to_f
379
+ stats[:total_time] += timing_value
380
+ end
381
+
382
+ # Categorize by SQL operation and check for cache
383
+ content = log[:content].upcase
384
+ if content.include?("CACHE")
385
+ stats[:cached_queries] += 1
386
+ # Still categorize cached queries by their operation type
387
+ if content.include?("SELECT")
388
+ stats[:select] += 1
389
+ elsif content.include?("INSERT")
390
+ stats[:insert] += 1
391
+ elsif content.include?("UPDATE")
392
+ stats[:update] += 1
393
+ elsif content.include?("DELETE")
394
+ stats[:delete] += 1
395
+ elsif content.include?("TRANSACTION") || content.include?("BEGIN") || content.include?("COMMIT") || content.include?("ROLLBACK")
396
+ stats[:transaction] += 1
397
+ end
398
+ elsif content.include?("SELECT")
399
+ stats[:select] += 1
400
+ elsif content.include?("INSERT")
401
+ stats[:insert] += 1
402
+ elsif content.include?("UPDATE")
403
+ stats[:update] += 1
404
+ elsif content.include?("DELETE")
405
+ stats[:delete] += 1
406
+ elsif content.include?("TRANSACTION") || content.include?("BEGIN") || content.include?("COMMIT") || content.include?("ROLLBACK") || content.include?("SAVEPOINT")
407
+ stats[:transaction] += 1
408
+ end
409
+ end
410
+
411
+ # Round total time to 1 decimal place
412
+ stats[:total_time] = stats[:total_time].round(1)
413
+
414
+ stats
415
+ end
416
+
417
+ def filter_related_logs(related_logs)
418
+ # Filter related logs (SQL, cache, etc.) in the detail pane
419
+ return related_logs unless state.detail_filter.present?
420
+
421
+ matched_indices = Set.new
422
+
423
+ # First pass: find direct matches
424
+ related_logs.each_with_index do |log, index|
425
+ if log[:content] && state.detail_filter.matches?(log[:content])
426
+ matched_indices.add(index)
427
+
428
+ # Add context lines based on log type
429
+ case log[:type]
430
+ when :sql_call_line
431
+ # If match is a sql_call_line, include the line below (the actual SQL query)
432
+ if index + 1 < related_logs.size
433
+ matched_indices.add(index + 1)
434
+ end
435
+ when :sql, :cache
436
+ # If match is a sql or cache, include the line above (the call stack line)
437
+ if index > 0 && related_logs[index - 1][:type] == :sql_call_line
438
+ matched_indices.add(index - 1)
439
+ end
440
+ end
441
+ end
442
+ end
443
+
444
+ # Return logs in original order
445
+ matched_indices.to_a.sort.map { |index| related_logs[index] }
446
+ end
447
+
448
+ def render_padded_text_with_spacing(text, lines, extra_empty_lines: 1)
449
+ # Helper function that renders text with padding, breaking long text into multiple lines
450
+ content_width = detail_win.maxx - 8 # Account for padding (4 spaces each side)
451
+
452
+ # Automatically detect if text contains ANSI codes
453
+ has_ansi = ansi_renderer.has_ansi_codes?(text)
454
+
455
+ text_chunks = if has_ansi
456
+ # For ANSI text, break it into properly sized chunks
457
+ ansi_renderer.wrap_ansi_text(text, content_width)
458
+ else
459
+ # For plain text, break it into chunks
460
+ ansi_renderer.wrap_plain_text(text, content_width)
461
+ end
462
+
463
+ # Render each chunk as a separate line with padding
464
+ text_chunks.each do |chunk|
465
+ lines << if has_ansi
466
+ {text: " #{chunk} ", color: nil, raw_ansi: true}
467
+ else
468
+ {text: " #{chunk} ", color: nil}
469
+ end
470
+ end
471
+
472
+ # Add extra empty lines after all chunks
473
+ extra_empty_lines.times do
474
+ lines << {text: "", color: nil}
475
+ end
476
+
477
+ text_chunks.length
478
+ end
479
+
480
+ def adjust_detail_scroll(total_lines, visible_height)
481
+ max_scroll = [total_lines - visible_height, 0].max
482
+ state.detail_scroll_offset = [state.detail_scroll_offset, max_scroll].min
483
+ state.detail_scroll_offset = [state.detail_scroll_offset, 0].max
484
+ end
485
+ end
486
+ end
487
+ end
488
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module App
5
+ module Renderer
6
+ class Header
7
+ include Curses
8
+
9
+ # Application info
10
+ APP_NAME = "LogBench"
11
+ APP_SUBTITLE = "Rails Log Viewer"
12
+ DEFAULT_LOG_FILENAME = "development.log"
13
+
14
+ # Layout constants
15
+ TITLE_X_OFFSET = 2
16
+ FILENAME_X_OFFSET = 15
17
+
18
+ # Color constants
19
+ HEADER_CYAN = 1
20
+ SUCCESS_GREEN = 3
21
+
22
+ def initialize(screen, state)
23
+ self.screen = screen
24
+ self.state = state
25
+ end
26
+
27
+ def draw
28
+ header_win.erase
29
+ header_win.box(0, 0)
30
+
31
+ draw_title
32
+ draw_file_name
33
+ draw_stats
34
+ draw_help_text
35
+ end
36
+
37
+ private
38
+
39
+ attr_accessor :screen, :state
40
+
41
+ def draw_title
42
+ header_win.setpos(1, TITLE_X_OFFSET)
43
+ header_win.attron(color_pair(HEADER_CYAN) | A_BOLD) { header_win.addstr(APP_NAME) }
44
+ header_win.addstr(" - #{APP_SUBTITLE}")
45
+ end
46
+
47
+ def draw_file_name
48
+ header_win.setpos(1, screen.width / 2 - FILENAME_X_OFFSET)
49
+ header_win.attron(color_pair(SUCCESS_GREEN)) { header_win.addstr(DEFAULT_LOG_FILENAME) }
50
+ end
51
+
52
+ def draw_stats
53
+ filtered_requests = state.filtered_requests
54
+ total_requests = state.requests.size
55
+
56
+ if state.main_filter.present?
57
+ # Filter active - show "X found (Y total)"
58
+ stats_text = "#{filtered_requests.size} found (#{total_requests} total)"
59
+ header_win.setpos(1, screen.width - stats_text.length - 2)
60
+ header_win.attron(color_pair(3)) { header_win.addstr(filtered_requests.size.to_s) }
61
+ header_win.addstr(" found (")
62
+ header_win.attron(color_pair(3)) { header_win.addstr(total_requests.to_s) }
63
+ header_win.addstr(" total)")
64
+ else
65
+ # No filter active - show simple count
66
+ stats_text = "Requests: #{total_requests}"
67
+ header_win.setpos(1, screen.width - stats_text.length - 2)
68
+ header_win.addstr("Requests: ")
69
+ header_win.attron(color_pair(3)) { header_win.addstr(total_requests.to_s) }
70
+ end
71
+ end
72
+
73
+ def draw_help_text
74
+ header_win.setpos(2, 2)
75
+ header_win.attron(A_DIM) do
76
+ header_win.addstr("a:Auto-scroll(")
77
+ header_win.attron(color_pair(3)) { header_win.addstr(state.auto_scroll ? "ON" : "OFF") }
78
+ header_win.addstr(") | f:Filter | c:Clear | s:Sort(")
79
+ header_win.attron(color_pair(3)) { header_win.addstr(state.sort.display_name) }
80
+ header_win.addstr(") | q:Quit")
81
+ end
82
+
83
+ header_win.setpos(3, 2)
84
+ header_win.attron(A_DIM) do
85
+ header_win.addstr("←→/hl:Switch Pane | ↑↓/jk:Navigate | g/G:Top/End")
86
+ end
87
+ end
88
+
89
+ def color_pair(n)
90
+ screen.color_pair(n)
91
+ end
92
+
93
+ def header_win
94
+ screen.header_win
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module App
5
+ module Renderer
6
+ class Main
7
+ def initialize(screen, state)
8
+ self.screen = screen
9
+ self.state = state
10
+ self.scrollbar = Scrollbar.new(screen)
11
+ self.ansi_renderer = Ansi.new(screen)
12
+ self.header = Header.new(screen, state)
13
+ self.request_list = RequestList.new(screen, state, scrollbar)
14
+ self.details = Details.new(screen, state, scrollbar, ansi_renderer)
15
+ end
16
+
17
+ def draw
18
+ header.draw
19
+ request_list.draw
20
+ details.draw
21
+ screen.refresh_all
22
+ end
23
+
24
+ private
25
+
26
+ attr_accessor :screen, :state, :header, :scrollbar, :request_list, :ansi_renderer, :details
27
+ end
28
+ end
29
+ end
30
+ end