rails_pulse 0.1.1 → 0.1.3

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.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +79 -177
  3. data/Rakefile +77 -2
  4. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  5. data/app/assets/images/rails_pulse/request.png +0 -0
  6. data/app/assets/stylesheets/rails_pulse/application.css +28 -17
  7. data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
  8. data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
  9. data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
  10. data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
  11. data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
  12. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
  13. data/app/controllers/concerns/chart_table_concern.rb +21 -4
  14. data/app/controllers/concerns/response_range_concern.rb +6 -3
  15. data/app/controllers/concerns/time_range_concern.rb +5 -10
  16. data/app/controllers/concerns/zoom_range_concern.rb +32 -1
  17. data/app/controllers/rails_pulse/application_controller.rb +13 -5
  18. data/app/controllers/rails_pulse/dashboard_controller.rb +12 -0
  19. data/app/controllers/rails_pulse/queries_controller.rb +111 -51
  20. data/app/controllers/rails_pulse/requests_controller.rb +37 -12
  21. data/app/controllers/rails_pulse/routes_controller.rb +98 -24
  22. data/app/helpers/rails_pulse/application_helper.rb +0 -1
  23. data/app/helpers/rails_pulse/chart_formatters.rb +3 -3
  24. data/app/helpers/rails_pulse/chart_helper.rb +21 -9
  25. data/app/helpers/rails_pulse/status_helper.rb +10 -4
  26. data/app/javascript/rails_pulse/application.js +34 -3
  27. data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
  28. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
  29. data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
  30. data/app/javascript/rails_pulse/controllers/index_controller.js +353 -39
  31. data/app/javascript/rails_pulse/controllers/pagination_controller.js +17 -27
  32. data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
  33. data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
  34. data/app/jobs/rails_pulse/backfill_summaries_job.rb +41 -0
  35. data/app/jobs/rails_pulse/summary_job.rb +53 -0
  36. data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +28 -7
  37. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +18 -22
  38. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +18 -7
  39. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +34 -41
  40. data/app/models/rails_pulse/operation.rb +1 -1
  41. data/app/models/rails_pulse/queries/cards/average_query_times.rb +49 -25
  42. data/app/models/rails_pulse/queries/cards/execution_rate.rb +40 -28
  43. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +37 -43
  44. data/app/models/rails_pulse/queries/charts/average_query_times.rb +23 -97
  45. data/app/models/rails_pulse/queries/tables/index.rb +74 -0
  46. data/app/models/rails_pulse/query.rb +47 -0
  47. data/app/models/rails_pulse/requests/charts/average_response_times.rb +23 -84
  48. data/app/models/rails_pulse/route.rb +1 -6
  49. data/app/models/rails_pulse/routes/cards/average_response_times.rb +45 -25
  50. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +43 -45
  51. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +36 -44
  52. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +37 -27
  53. data/app/models/rails_pulse/routes/charts/average_response_times.rb +23 -100
  54. data/app/models/rails_pulse/routes/tables/index.rb +57 -40
  55. data/app/models/rails_pulse/summary.rb +143 -0
  56. data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
  57. data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
  58. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
  59. data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
  60. data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
  61. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +146 -0
  62. data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
  63. data/app/services/rails_pulse/query_analysis_service.rb +125 -0
  64. data/app/services/rails_pulse/summary_service.rb +199 -0
  65. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
  66. data/app/views/layouts/rails_pulse/application.html.erb +4 -6
  67. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
  68. data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
  69. data/app/views/rails_pulse/components/_empty_state.html.erb +11 -0
  70. data/app/views/rails_pulse/components/_metric_card.html.erb +37 -28
  71. data/app/views/rails_pulse/components/_panel.html.erb +1 -1
  72. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
  73. data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
  74. data/app/views/rails_pulse/dashboard/index.html.erb +55 -37
  75. data/app/views/rails_pulse/operations/show.html.erb +17 -15
  76. data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
  77. data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
  78. data/app/views/rails_pulse/queries/_analysis_results.html.erb +87 -0
  79. data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
  80. data/app/views/rails_pulse/queries/_show_table.html.erb +2 -2
  81. data/app/views/rails_pulse/queries/_table.html.erb +11 -13
  82. data/app/views/rails_pulse/queries/index.html.erb +32 -28
  83. data/app/views/rails_pulse/queries/show.html.erb +45 -34
  84. data/app/views/rails_pulse/requests/_operations.html.erb +38 -45
  85. data/app/views/rails_pulse/requests/_table.html.erb +3 -3
  86. data/app/views/rails_pulse/requests/index.html.erb +33 -28
  87. data/app/views/rails_pulse/routes/_table.html.erb +14 -14
  88. data/app/views/rails_pulse/routes/index.html.erb +34 -29
  89. data/app/views/rails_pulse/routes/show.html.erb +43 -36
  90. data/config/initializers/rails_pulse.rb +0 -12
  91. data/config/routes.rb +5 -1
  92. data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +54 -0
  93. data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +13 -0
  94. data/db/rails_pulse_schema.rb +130 -0
  95. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +65 -0
  96. data/lib/generators/rails_pulse/install_generator.rb +94 -4
  97. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +60 -0
  98. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +22 -0
  99. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
  100. data/lib/generators/rails_pulse/templates/rails_pulse.rb +0 -12
  101. data/lib/generators/rails_pulse/upgrade_generator.rb +225 -0
  102. data/lib/rails_pulse/configuration.rb +0 -11
  103. data/lib/rails_pulse/engine.rb +0 -1
  104. data/lib/rails_pulse/version.rb +1 -1
  105. data/lib/tasks/rails_pulse.rake +77 -0
  106. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  107. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  108. data/public/rails-pulse-assets/rails-pulse.js +53 -53
  109. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  110. data/public/rails-pulse-assets/search.svg +43 -0
  111. metadata +48 -14
  112. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  113. data/app/assets/images/rails_pulse/routes.png +0 -0
  114. data/app/controllers/rails_pulse/caches_controller.rb +0 -115
  115. data/app/helpers/rails_pulse/cached_component_helper.rb +0 -73
  116. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
  117. data/app/models/rails_pulse/component_cache_key.rb +0 -33
  118. data/app/views/rails_pulse/caches/show.html.erb +0 -9
  119. data/db/migrate/20250227235904_create_routes.rb +0 -12
  120. data/db/migrate/20250227235915_create_requests.rb +0 -19
  121. data/db/migrate/20250228000000_create_queries.rb +0 -14
  122. data/db/migrate/20250228000056_create_operations.rb +0 -24
  123. data/lib/rails_pulse/migration.rb +0 -29
@@ -7,60 +7,52 @@ module RailsPulse
7
7
  end
8
8
 
9
9
  def to_metric_card
10
- requests = if @route
11
- RailsPulse::Request.where(route: @route)
12
- else
13
- RailsPulse::Request.all
14
- end
10
+ last_7_days = 7.days.ago.beginning_of_day
11
+ previous_7_days = 14.days.ago.beginning_of_day
15
12
 
16
- requests = requests.where("occurred_at >= ?", 2.weeks.ago.beginning_of_day)
13
+ # Single query to get all P95 metrics with conditional aggregation
14
+ base_query = RailsPulse::Summary.where(
15
+ summarizable_type: "RailsPulse::Route",
16
+ period_type: "day",
17
+ period_start: 2.weeks.ago.beginning_of_day..Time.current
18
+ )
19
+ base_query = base_query.where(summarizable_id: @route.id) if @route
17
20
 
18
- # Calculate overall 95th percentile response time
19
- count = requests.count
20
- percentile_95th = if count > 0
21
- requests.select("duration").order("duration").limit(1).offset((count * 0.95).floor).pluck(:duration).first.round(0) || 0
22
- else
23
- 0
24
- end
21
+ metrics = base_query.select(
22
+ "AVG(p95_duration) AS overall_p95",
23
+ "AVG(CASE WHEN period_start >= '#{last_7_days.strftime('%Y-%m-%d %H:%M:%S')}' THEN p95_duration ELSE NULL END) AS current_p95",
24
+ "AVG(CASE WHEN period_start >= '#{previous_7_days.strftime('%Y-%m-%d %H:%M:%S')}' AND period_start < '#{last_7_days.strftime('%Y-%m-%d %H:%M:%S')}' THEN p95_duration ELSE NULL END) AS previous_p95"
25
+ ).take
25
26
 
26
- # Calculate trend by comparing last 7 days vs previous 7 days for 95th percentile
27
- last_7_days = 7.days.ago.beginning_of_day
28
- previous_7_days = 14.days.ago.beginning_of_day
27
+ # Calculate metrics from single query result
28
+ p95_response_time = (metrics.overall_p95 || 0).round(0)
29
+ current_period_p95 = metrics.current_p95 || 0
30
+ previous_period_p95 = metrics.previous_p95 || 0
29
31
 
30
- current_period = requests.where("occurred_at >= ?", last_7_days)
31
- current_count = current_period.count
32
- current_period_95th = if current_count > 0
33
- current_period.select("duration").order("duration").limit(1).offset((current_count * 0.95).floor).pluck(:duration).first || 0
34
- else
35
- 0
36
- end
32
+ percentage = previous_period_p95.zero? ? 0 : ((previous_period_p95 - current_period_p95) / previous_period_p95 * 100).abs.round(1)
33
+ trend_icon = percentage < 0.1 ? "move-right" : current_period_p95 < previous_period_p95 ? "trending-down" : "trending-up"
34
+ trend_amount = previous_period_p95.zero? ? "0%" : "#{percentage}%"
37
35
 
38
- previous_period = requests.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days)
39
- previous_count = previous_period.count
40
- previous_period_95th = if previous_count > 0
41
- previous_period.select("duration").order("duration").limit(1).offset((previous_count * 0.95).floor).pluck(:duration).first || 0
42
- else
43
- 0
44
- end
36
+ # Sparkline data by day with zero-filled days over the last 14 days
37
+ grouped_daily = base_query
38
+ .group_by_day(:period_start, time_zone: "UTC")
39
+ .average(:p95_duration)
45
40
 
46
- percentage = previous_period_95th.zero? ? 0 : ((previous_period_95th - current_period_95th) / previous_period_95th * 100).abs.round(1)
47
- trend_icon = percentage < 0.1 ? "move-right" : current_period_95th < previous_period_95th ? "trending-down" : "trending-up"
48
- trend_amount = previous_period_95th.zero? ? "0%" : "#{percentage}%"
41
+ start_day = 2.weeks.ago.beginning_of_day.to_date
42
+ end_day = Time.current.to_date
49
43
 
50
- sparkline_data = requests
51
- .group_by_week(:occurred_at, time_zone: "UTC")
52
- .average(:duration)
53
- .each_with_object({}) do |(date, avg), hash|
54
- formatted_date = date.strftime("%b %-d")
55
- value = avg&.round(0) || 0
56
- hash[formatted_date] = {
57
- value: value
58
- }
59
- end
44
+ sparkline_data = {}
45
+ (start_day..end_day).each do |day|
46
+ avg = grouped_daily[day]&.round(0) || 0
47
+ label = day.strftime("%b %-d")
48
+ sparkline_data[label] = { value: avg }
49
+ end
60
50
 
61
51
  {
52
+ id: "percentile_response_times",
53
+ context: "routes",
62
54
  title: "95th Percentile Response Time",
63
- summary: "#{percentile_95th} ms",
55
+ summary: "#{p95_response_time} ms",
64
56
  line_chart_data: sparkline_data,
65
57
  trend_icon: trend_icon,
66
58
  trend_amount: trend_amount,
@@ -7,44 +7,54 @@ module RailsPulse
7
7
  end
8
8
 
9
9
  def to_metric_card
10
- requests = if @route
11
- RailsPulse::Request.where(route: @route)
12
- else
13
- RailsPulse::Request.all
14
- end
10
+ last_7_days = 7.days.ago.beginning_of_day
11
+ previous_7_days = 14.days.ago.beginning_of_day
15
12
 
16
- requests = requests.where("occurred_at >= ?", 2.weeks.ago.beginning_of_day)
13
+ # Single query to get all count metrics with conditional aggregation
14
+ base_query = RailsPulse::Summary.where(
15
+ summarizable_type: "RailsPulse::Route",
16
+ period_type: "day",
17
+ period_start: 2.weeks.ago.beginning_of_day..Time.current
18
+ )
19
+ base_query = base_query.where(summarizable_id: @route.id) if @route
17
20
 
18
- # Calculate total request count
19
- total_request_count = requests.count
21
+ metrics = base_query.select(
22
+ "SUM(count) AS total_count",
23
+ "SUM(CASE WHEN period_start >= '#{last_7_days.strftime('%Y-%m-%d %H:%M:%S')}' THEN count ELSE 0 END) AS current_count",
24
+ "SUM(CASE WHEN period_start >= '#{previous_7_days.strftime('%Y-%m-%d %H:%M:%S')}' AND period_start < '#{last_7_days.strftime('%Y-%m-%d %H:%M:%S')}' THEN count ELSE 0 END) AS previous_count"
25
+ ).take
20
26
 
21
- # Calculate trend by comparing last 7 days vs previous 7 days
22
- last_7_days = 7.days.ago.beginning_of_day
23
- previous_7_days = 14.days.ago.beginning_of_day
24
- current_period_count = requests.where("occurred_at >= ?", last_7_days).count
25
- previous_period_count = requests.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days).count
27
+ # Calculate metrics from single query result
28
+ total_request_count = metrics.total_count || 0
29
+ current_period_count = metrics.current_count || 0
30
+ previous_period_count = metrics.previous_count || 0
26
31
 
27
32
  percentage = previous_period_count.zero? ? 0 : ((previous_period_count - current_period_count) / previous_period_count.to_f * 100).abs.round(1)
28
33
  trend_icon = percentage < 0.1 ? "move-right" : current_period_count < previous_period_count ? "trending-down" : "trending-up"
29
34
  trend_amount = previous_period_count.zero? ? "0%" : "#{percentage}%"
30
35
 
31
- sparkline_data = requests
32
- .group_by_week(:occurred_at, time_zone: "UTC")
33
- .count
34
- .each_with_object({}) do |(date, count), hash|
35
- formatted_date = date.strftime("%b %-d")
36
- hash[formatted_date] = {
37
- value: count
38
- }
39
- end
40
-
41
- # Calculate average requests per minute
42
- min_time = requests.minimum(:occurred_at)
43
- max_time = requests.maximum(:occurred_at)
44
- total_minutes = min_time && max_time && min_time != max_time ? (max_time - min_time) / 60.0 : 1
36
+ # Sparkline data by day with zero-filled days over the last 14 days
37
+ grouped_daily = base_query
38
+ .group_by_day(:period_start, time_zone: "UTC")
39
+ .sum(:count)
40
+
41
+ start_day = 2.weeks.ago.beginning_of_day.to_date
42
+ end_day = Time.current.to_date
43
+
44
+ sparkline_data = {}
45
+ (start_day..end_day).each do |day|
46
+ total = grouped_daily[day] || 0
47
+ label = day.strftime("%b %-d")
48
+ sparkline_data[label] = { value: total }
49
+ end
50
+
51
+ # Calculate average requests per minute over 2-week period
52
+ total_minutes = 2.weeks / 1.minute
45
53
  average_requests_per_minute = total_request_count / total_minutes
46
54
 
47
55
  {
56
+ id: "request_count_totals",
57
+ context: "routes",
48
58
  title: "Request Count Total",
49
59
  summary: "#{average_requests_per_minute.round(2)} / min",
50
60
  line_chart_data: sparkline_data,
@@ -2,112 +2,35 @@ module RailsPulse
2
2
  module Routes
3
3
  module Charts
4
4
  class AverageResponseTimes
5
- def initialize(ransack_query:, group_by: :group_by_day, route: nil)
5
+ def initialize(ransack_query:, period_type: nil, route: nil, start_time: nil, end_time: nil, start_duration: nil)
6
6
  @ransack_query = ransack_query
7
- @group_by = group_by
7
+ @period_type = period_type
8
8
  @route = route
9
+ @start_time = start_time
10
+ @end_time = end_time
11
+ @start_duration = start_duration
9
12
  end
10
13
 
11
14
  def to_rails_chart
12
- # Get actual data using existing logic
13
- actual_data = if @route
14
- # These are the requests for the specific route so it will just be a collection of Requests that we can
15
- # filter and sort using the attributes on each Request
16
- @ransack_query.result(distinct: false)
17
- .public_send(@group_by, "occurred_at", series: true, time_zone: "UTC")
18
- .average(:duration)
19
- else
20
- # Use the existing query structure with left_joins from ransack
21
- @ransack_query.result(distinct: false)
22
- .left_joins(:requests)
23
- .public_send(
24
- @group_by,
25
- "rails_pulse_requests.occurred_at",
26
- series: true,
27
- time_zone: "UTC"
28
- )
29
- .average("rails_pulse_requests.duration")
30
- end
31
-
32
- # Create full time range and fill in missing periods
33
- fill_missing_periods(actual_data)
34
- end
35
-
36
- private
37
-
38
- def fill_missing_periods(actual_data)
39
- # Extract actual time range from ransack query conditions
40
- start_time, end_time = extract_time_range_from_ransack
41
-
42
- # Create time range based on grouping type
43
- case @group_by
44
- when :group_by_hour
45
- time_range = generate_hour_range(start_time, end_time)
46
- else # :group_by_day
47
- time_range = generate_day_range(start_time, end_time)
48
- end
49
-
50
- # Fill in all periods with zero values for missing periods
51
- time_range.each_with_object({}) do |period, result|
52
- occurred_at = period.is_a?(String) ? Time.parse(period) : period
53
- occurred_at = (occurred_at.is_a?(Time) || occurred_at.is_a?(Date)) ? occurred_at : Time.current
54
-
55
- normalized_occurred_at =
56
- case @group_by
57
- when :group_by_hour
58
- occurred_at&.beginning_of_hour || occurred_at
59
- when :group_by_day
60
- occurred_at&.beginning_of_day || occurred_at
61
- else
62
- occurred_at
63
- end
64
-
65
- # Use actual data if available, otherwise default to 0
66
- average_duration = actual_data[period] || 0
67
- result[normalized_occurred_at.to_i] = {
68
- value: average_duration.to_f
69
- }
70
- end
71
- end
72
-
73
- def generate_day_range(start_time, end_time)
74
- (start_time.to_date..end_time.to_date).map(&:beginning_of_day)
75
- end
76
-
77
- def generate_hour_range(start_time, end_time)
78
- current = start_time
79
- hours = []
80
- while current <= end_time
81
- hours << current
82
- current += 1.hour
83
- end
84
- hours
85
- end
86
-
87
- def extract_time_range_from_ransack
88
- # Extract time range from ransack conditions
89
- conditions = @ransack_query.conditions
90
-
91
- if @route
92
- # For specific route queries, look for occurred_at conditions
93
- start_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "gteq" }
94
- end_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "lt" }
95
- else
96
- # For general route queries, look for requests_occurred_at conditions
97
- start_condition = conditions.find { |c| c.a.first == "requests_occurred_at" && c.p == "gteq" }
98
- end_condition = conditions.find { |c| c.a.first == "requests_occurred_at" && c.p == "lt" }
99
- end
100
-
101
- start_time = start_condition&.v || 2.weeks.ago
102
- end_time = end_condition&.v || Time.current
103
-
104
- # Normalize time boundaries based on grouping
105
- case @group_by
106
- when :group_by_hour
107
- [ start_time.beginning_of_hour, end_time.beginning_of_hour ]
108
- else
109
- [ start_time.beginning_of_day, end_time.beginning_of_day ]
15
+ summaries = @ransack_query.result(distinct: false).where(
16
+ summarizable_type: "RailsPulse::Route",
17
+ period_type: @period_type
18
+ )
19
+
20
+ summaries = summaries.where(summarizable_id: @route.id) if @route
21
+ summaries = summaries
22
+ .group(:period_start)
23
+ .having("AVG(avg_duration) > ?", @start_duration || 0)
24
+ .average(:avg_duration)
25
+ .transform_keys(&:to_i)
26
+
27
+ # Pad missing data points with zeros
28
+ step = @period_type == :hour ? 1.hour : 1.day
29
+ data = {}
30
+ (@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
31
+ data[timestamp.to_i] = summaries[timestamp.to_i].to_f.round(2)
110
32
  end
33
+ data
111
34
  end
112
35
  end
113
36
  end
@@ -2,60 +2,77 @@ module RailsPulse
2
2
  module Routes
3
3
  module Tables
4
4
  class Index
5
- def initialize(ransack_query:, start_time:, params:)
5
+ def initialize(ransack_query:, period_type: nil, start_time:, params:)
6
6
  @ransack_query = ransack_query
7
+ @period_type = period_type
7
8
  @start_time = start_time
8
9
  @params = params
9
10
  end
10
11
 
11
12
  def to_table
12
- # Pre-calculate values to avoid SQL injection and improve readability
13
- minutes_elapsed = calculate_minutes_elapsed
13
+ # Check if we have explicit ransack sorts
14
+ has_sorts = @ransack_query.sorts.any?
14
15
 
15
- # Get thresholds with safe defaults to avoid nil access errors
16
- config = RailsPulse.configuration rescue nil
17
- thresholds = config&.route_thresholds || { slow: 500, very_slow: 1500, critical: 3000 }
18
-
19
- requests_per_minute_divisor = minutes_elapsed > 0 ? minutes_elapsed : 1
16
+ base_query = @ransack_query.result(distinct: false)
17
+ .joins("INNER JOIN rails_pulse_routes ON rails_pulse_routes.id = rails_pulse_summaries.summarizable_id")
18
+ .where(
19
+ summarizable_type: "RailsPulse::Route",
20
+ period_type: @period_type
21
+ )
20
22
 
21
- status_sql = build_status_sql(thresholds)
23
+ base_query = base_query.where(summarizable_id: @route.id) if @route
22
24
 
23
- @ransack_query.result(distinct: false)
24
- .left_joins(:requests)
25
- .group("rails_pulse_routes.id")
25
+ # Apply grouping and aggregation
26
+ grouped_query = base_query
27
+ .group(
28
+ "rails_pulse_summaries.summarizable_id",
29
+ "rails_pulse_summaries.summarizable_type",
30
+ "rails_pulse_routes.id",
31
+ "rails_pulse_routes.path",
32
+ "rails_pulse_routes.method"
33
+ )
26
34
  .select(
27
- "rails_pulse_routes.*",
28
- "COALESCE(AVG(rails_pulse_requests.duration), 0) AS average_response_time_ms",
29
- "COUNT(rails_pulse_requests.id) AS request_count",
30
- "COALESCE(COUNT(rails_pulse_requests.id) / #{requests_per_minute_divisor}, 0) AS requests_per_minute",
31
- "COALESCE(SUM(CASE WHEN rails_pulse_requests.is_error = true THEN 1 ELSE 0 END), 0) AS error_count",
32
- "CASE WHEN COUNT(rails_pulse_requests.id) > 0 THEN ROUND((COALESCE(SUM(CASE WHEN rails_pulse_requests.is_error = true THEN 1 ELSE 0 END), 0) * 100.0) / COUNT(rails_pulse_requests.id), 2) ELSE 0 END AS error_rate_percentage",
33
- "COALESCE(MAX(rails_pulse_requests.duration), 0) AS max_response_time_ms",
34
- "#{status_sql} AS status_indicator"
35
+ "rails_pulse_summaries.summarizable_id",
36
+ "rails_pulse_summaries.summarizable_type",
37
+ "rails_pulse_routes.id as route_id",
38
+ "rails_pulse_routes.path",
39
+ "rails_pulse_routes.method as route_method",
40
+ "AVG(rails_pulse_summaries.avg_duration) as avg_duration",
41
+ "MAX(rails_pulse_summaries.max_duration) as max_duration",
42
+ "SUM(rails_pulse_summaries.count) as count",
43
+ "SUM(rails_pulse_summaries.error_count) as error_count",
44
+ "SUM(rails_pulse_summaries.success_count) as success_count"
35
45
  )
36
- end
37
-
38
- private
39
46
 
40
- def calculate_minutes_elapsed
41
- start_timestamp = Time.at(@start_time.to_i).utc
42
- ((Time.current.utc - start_timestamp) / 60.0).round(2)
43
- end
47
+ # Apply sorting based on ransack sorts or use default
48
+ if has_sorts
49
+ # Apply custom sorting based on ransack parameters
50
+ sort = @ransack_query.sorts.first
51
+ direction = sort.dir == "desc" ? :desc : :asc
44
52
 
45
- def build_status_sql(thresholds)
46
- # Ensure all thresholds have default values
47
- slow = thresholds[:slow] || 500
48
- very_slow = thresholds[:very_slow] || 1500
49
- critical = thresholds[:critical] || 3000
53
+ case sort.name
54
+ when "avg_duration_sort"
55
+ grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").send(direction))
56
+ when "max_duration_sort"
57
+ grouped_query = grouped_query.order(Arel.sql("MAX(rails_pulse_summaries.max_duration)").send(direction))
58
+ when "count_sort"
59
+ grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count)").send(direction))
60
+ when "requests_per_minute"
61
+ grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count) / 60.0").send(direction))
62
+ when "error_rate_percentage"
63
+ grouped_query = grouped_query.order(Arel.sql("(SUM(rails_pulse_summaries.error_count) * 100.0) / SUM(rails_pulse_summaries.count)").send(direction))
64
+ when "route_path"
65
+ grouped_query = grouped_query.order(Arel.sql("rails_pulse_routes.path").send(direction))
66
+ else
67
+ # Unknown sort field, fallback to default
68
+ grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").desc)
69
+ end
70
+ else
71
+ # Apply default sort when no explicit sort is provided (matches controller default_table_sort)
72
+ grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").desc)
73
+ end
50
74
 
51
- <<-SQL.squish
52
- CASE
53
- WHEN COALESCE(AVG(rails_pulse_requests.duration), 0) >= #{critical} THEN 3
54
- WHEN COALESCE(AVG(rails_pulse_requests.duration), 0) >= #{very_slow} THEN 2
55
- WHEN COALESCE(AVG(rails_pulse_requests.duration), 0) >= #{slow} THEN 1
56
- ELSE 0
57
- END
58
- SQL
75
+ grouped_query
59
76
  end
60
77
  end
61
78
  end
@@ -0,0 +1,143 @@
1
+ module RailsPulse
2
+ class Summary < RailsPulse::ApplicationRecord
3
+ self.table_name = "rails_pulse_summaries"
4
+
5
+ PERIOD_TYPES = %w[hour day week month].freeze
6
+
7
+ # Polymorphic association
8
+ belongs_to :summarizable, polymorphic: true, optional: true # Optional for Request summaries
9
+
10
+ # Convenience associations for easier querying
11
+ belongs_to :route, -> { where(rails_pulse_summaries: { summarizable_type: "RailsPulse::Route" }) },
12
+ foreign_key: "summarizable_id", class_name: "RailsPulse::Route", optional: true
13
+ belongs_to :query, -> { where(rails_pulse_summaries: { summarizable_type: "RailsPulse::Query" }) },
14
+ foreign_key: "summarizable_id", class_name: "RailsPulse::Query", optional: true
15
+
16
+ # Validations
17
+ validates :period_type, inclusion: { in: PERIOD_TYPES }
18
+ validates :period_start, presence: true
19
+ validates :period_end, presence: true
20
+
21
+ # Scopes
22
+ scope :for_period_type, ->(type) { where(period_type: type) }
23
+ scope :for_date_range, ->(start_date, end_date) {
24
+ where(period_start: start_date..end_date)
25
+ }
26
+ scope :for_requests, -> { where(summarizable_type: "RailsPulse::Request") }
27
+ scope :for_routes, -> { where(summarizable_type: "RailsPulse::Route") }
28
+ scope :for_queries, -> { where(summarizable_type: "RailsPulse::Query") }
29
+ scope :recent, -> { order(period_start: :desc) }
30
+
31
+ # Special scope for overall request summaries
32
+ scope :overall_requests, -> {
33
+ where(summarizable_type: "RailsPulse::Request", summarizable_id: 0)
34
+ }
35
+
36
+ # Ransack configuration
37
+ def self.ransackable_attributes(auth_object = nil)
38
+ %w[
39
+ period_start period_end avg_duration max_duration count error_count
40
+ requests_per_minute error_rate_percentage route_path_cont
41
+ execution_count total_time_consumed normalized_sql
42
+ ]
43
+ end
44
+
45
+ def self.ransackable_associations(auth_object = nil)
46
+ %w[route query]
47
+ end
48
+
49
+ # Custom ransackers for calculated fields (designed to work with GROUP BY queries)
50
+ ransacker :count do
51
+ Arel.sql("SUM(rails_pulse_summaries.count)") # Use SUM for proper grouping
52
+ end
53
+
54
+ ransacker :requests_per_minute do
55
+ Arel.sql("SUM(rails_pulse_summaries.count) / 60.0") # Use SUM for consistency
56
+ end
57
+
58
+ ransacker :error_rate_percentage do
59
+ Arel.sql("(SUM(rails_pulse_summaries.error_count) * 100.0) / SUM(rails_pulse_summaries.count)") # Use SUM for both
60
+ end
61
+
62
+
63
+ # Ransacker for route path sorting (when joined with routes table)
64
+ ransacker :route_path do
65
+ Arel.sql("rails_pulse_routes.path")
66
+ end
67
+
68
+ # Ransacker for route path filtering using subquery (works without JOIN)
69
+ ransacker :route_path_cont do |parent|
70
+ Arel.sql(<<-SQL)
71
+ rails_pulse_summaries.summarizable_id IN (
72
+ SELECT id FROM rails_pulse_routes
73
+ WHERE rails_pulse_routes.path LIKE '%' || ? || '%'
74
+ )
75
+ SQL
76
+ end
77
+
78
+ # Sorting-specific ransackers for GROUP BY compatibility (used only in ORDER BY)
79
+ # These use different names to avoid conflicts with filtering
80
+ ransacker :avg_duration_sort do
81
+ Arel.sql("AVG(rails_pulse_summaries.avg_duration)")
82
+ end
83
+
84
+ ransacker :max_duration_sort do
85
+ Arel.sql("MAX(rails_pulse_summaries.max_duration)")
86
+ end
87
+
88
+ ransacker :count_sort do
89
+ Arel.sql("SUM(rails_pulse_summaries.count)")
90
+ end
91
+
92
+ ransacker :error_count_sort do
93
+ Arel.sql("SUM(rails_pulse_summaries.error_count)")
94
+ end
95
+
96
+ ransacker :success_count_sort do
97
+ Arel.sql("SUM(rails_pulse_summaries.success_count)")
98
+ end
99
+
100
+ ransacker :total_time_consumed_sort do
101
+ Arel.sql("SUM(rails_pulse_summaries.count * rails_pulse_summaries.avg_duration)")
102
+ end
103
+
104
+ # Alias execution_count_sort to count_sort for queries table compatibility
105
+ ransacker :execution_count_sort do
106
+ Arel.sql("SUM(rails_pulse_summaries.count)")
107
+ end
108
+
109
+ # Ransackers for queries table calculated fields
110
+ ransacker :execution_count do
111
+ Arel.sql("SUM(rails_pulse_summaries.count)") # Total executions
112
+ end
113
+
114
+ ransacker :total_time_consumed do
115
+ Arel.sql("SUM(rails_pulse_summaries.count * rails_pulse_summaries.avg_duration)") # Total time consumed
116
+ end
117
+
118
+ # Ransacker for query SQL sorting (when joined with queries table)
119
+ ransacker :normalized_sql do
120
+ Arel.sql("rails_pulse_queries.normalized_sql")
121
+ end
122
+
123
+ class << self
124
+ def calculate_period_end(period_type, start_time)
125
+ case period_type
126
+ when "hour" then start_time.end_of_hour
127
+ when "day" then start_time.end_of_day
128
+ when "week" then start_time.end_of_week
129
+ when "month" then start_time.end_of_month
130
+ end
131
+ end
132
+
133
+ def normalize_period_start(period_type, time)
134
+ case period_type
135
+ when "hour" then time.beginning_of_hour
136
+ when "day" then time.beginning_of_day
137
+ when "week" then time.beginning_of_week
138
+ when "month" then time.beginning_of_month
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end