rails_pulse 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.
Files changed (160) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +638 -0
  4. data/Rakefile +207 -0
  5. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  6. data/app/assets/images/rails_pulse/menu.svg +1 -0
  7. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  8. data/app/assets/images/rails_pulse/request.png +0 -0
  9. data/app/assets/images/rails_pulse/routes.png +0 -0
  10. data/app/assets/stylesheets/rails_pulse/application.css +102 -0
  11. data/app/assets/stylesheets/rails_pulse/components/alert.css +24 -0
  12. data/app/assets/stylesheets/rails_pulse/components/badge.css +58 -0
  13. data/app/assets/stylesheets/rails_pulse/components/base.css +79 -0
  14. data/app/assets/stylesheets/rails_pulse/components/breadcrumb.css +31 -0
  15. data/app/assets/stylesheets/rails_pulse/components/button.css +99 -0
  16. data/app/assets/stylesheets/rails_pulse/components/card.css +19 -0
  17. data/app/assets/stylesheets/rails_pulse/components/chart.css +18 -0
  18. data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +86 -0
  19. data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +9 -0
  20. data/app/assets/stylesheets/rails_pulse/components/dialog.css +56 -0
  21. data/app/assets/stylesheets/rails_pulse/components/flash.css +47 -0
  22. data/app/assets/stylesheets/rails_pulse/components/input.css +80 -0
  23. data/app/assets/stylesheets/rails_pulse/components/layouts.css +63 -0
  24. data/app/assets/stylesheets/rails_pulse/components/menu.css +43 -0
  25. data/app/assets/stylesheets/rails_pulse/components/popover.css +36 -0
  26. data/app/assets/stylesheets/rails_pulse/components/prose.css +144 -0
  27. data/app/assets/stylesheets/rails_pulse/components/row.css +24 -0
  28. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +79 -0
  29. data/app/assets/stylesheets/rails_pulse/components/skeleton.css +5 -0
  30. data/app/assets/stylesheets/rails_pulse/components/table.css +37 -0
  31. data/app/assets/stylesheets/rails_pulse/components/utilities.css +36 -0
  32. data/app/controllers/concerns/chart_table_concern.rb +82 -0
  33. data/app/controllers/concerns/response_range_concern.rb +24 -0
  34. data/app/controllers/concerns/time_range_concern.rb +67 -0
  35. data/app/controllers/concerns/zoom_range_concern.rb +40 -0
  36. data/app/controllers/rails_pulse/application_controller.rb +67 -0
  37. data/app/controllers/rails_pulse/assets_controller.rb +33 -0
  38. data/app/controllers/rails_pulse/caches_controller.rb +115 -0
  39. data/app/controllers/rails_pulse/csp_test_controller.rb +57 -0
  40. data/app/controllers/rails_pulse/dashboard_controller.rb +6 -0
  41. data/app/controllers/rails_pulse/operations_controller.rb +219 -0
  42. data/app/controllers/rails_pulse/queries_controller.rb +121 -0
  43. data/app/controllers/rails_pulse/requests_controller.rb +69 -0
  44. data/app/controllers/rails_pulse/routes_controller.rb +99 -0
  45. data/app/helpers/rails_pulse/application_helper.rb +111 -0
  46. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +62 -0
  47. data/app/helpers/rails_pulse/cached_component_helper.rb +73 -0
  48. data/app/helpers/rails_pulse/chart_formatters.rb +43 -0
  49. data/app/helpers/rails_pulse/chart_helper.rb +140 -0
  50. data/app/helpers/rails_pulse/formatting_helper.rb +29 -0
  51. data/app/helpers/rails_pulse/status_helper.rb +279 -0
  52. data/app/helpers/rails_pulse/table_helper.rb +54 -0
  53. data/app/javascript/rails_pulse/application.js +119 -0
  54. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +20 -0
  55. data/app/javascript/rails_pulse/controllers/context_menu_controller.js +16 -0
  56. data/app/javascript/rails_pulse/controllers/dialog_controller.js +21 -0
  57. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +67 -0
  58. data/app/javascript/rails_pulse/controllers/form_controller.js +39 -0
  59. data/app/javascript/rails_pulse/controllers/icon_controller.js +170 -0
  60. data/app/javascript/rails_pulse/controllers/index_controller.js +230 -0
  61. data/app/javascript/rails_pulse/controllers/menu_controller.js +60 -0
  62. data/app/javascript/rails_pulse/controllers/pagination_controller.js +69 -0
  63. data/app/javascript/rails_pulse/controllers/popover_controller.js +91 -0
  64. data/app/javascript/rails_pulse/controllers/timezone_controller.js +106 -0
  65. data/app/javascript/rails_pulse/theme.js +416 -0
  66. data/app/jobs/rails_pulse/application_job.rb +4 -0
  67. data/app/jobs/rails_pulse/cleanup_job.rb +21 -0
  68. data/app/mailers/rails_pulse/application_mailer.rb +6 -0
  69. data/app/models/rails_pulse/application_record.rb +7 -0
  70. data/app/models/rails_pulse/component_cache_key.rb +33 -0
  71. data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +27 -0
  72. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +37 -0
  73. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +59 -0
  74. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +45 -0
  75. data/app/models/rails_pulse/operation.rb +87 -0
  76. data/app/models/rails_pulse/queries/cards/average_query_times.rb +52 -0
  77. data/app/models/rails_pulse/queries/cards/execution_rate.rb +57 -0
  78. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +71 -0
  79. data/app/models/rails_pulse/queries/charts/average_query_times.rb +112 -0
  80. data/app/models/rails_pulse/query.rb +58 -0
  81. data/app/models/rails_pulse/request.rb +64 -0
  82. data/app/models/rails_pulse/requests/charts/average_response_times.rb +99 -0
  83. data/app/models/rails_pulse/requests/charts/operations_chart.rb +35 -0
  84. data/app/models/rails_pulse/route.rb +77 -0
  85. data/app/models/rails_pulse/routes/cards/average_response_times.rb +54 -0
  86. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +73 -0
  87. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +73 -0
  88. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +59 -0
  89. data/app/models/rails_pulse/routes/charts/average_response_times.rb +115 -0
  90. data/app/models/rails_pulse/routes/tables/index.rb +63 -0
  91. data/app/services/rails_pulse/sql_query_normalizer.rb +124 -0
  92. data/app/views/layouts/rails_pulse/_menu_items.html.erb +19 -0
  93. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +44 -0
  94. data/app/views/layouts/rails_pulse/application.html.erb +72 -0
  95. data/app/views/rails_pulse/caches/show.html.erb +9 -0
  96. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +12 -0
  97. data/app/views/rails_pulse/components/_code_panel.html.erb +12 -0
  98. data/app/views/rails_pulse/components/_metric_card.html.erb +55 -0
  99. data/app/views/rails_pulse/components/_metric_row.html.erb +9 -0
  100. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +241 -0
  101. data/app/views/rails_pulse/components/_panel.html.erb +56 -0
  102. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +15 -0
  103. data/app/views/rails_pulse/components/_table.html.erb +50 -0
  104. data/app/views/rails_pulse/components/_table_head.html.erb +20 -0
  105. data/app/views/rails_pulse/components/_table_pagination.html.erb +45 -0
  106. data/app/views/rails_pulse/components/_time_period.html.erb +16 -0
  107. data/app/views/rails_pulse/csp_test/show.html.erb +207 -0
  108. data/app/views/rails_pulse/dashboard/charts/_bar_chart.html.erb +1 -0
  109. data/app/views/rails_pulse/dashboard/index.html.erb +64 -0
  110. data/app/views/rails_pulse/dashboard/tables/_routes_table.html.erb +32 -0
  111. data/app/views/rails_pulse/dashboard/tables/_standard_table.html.erb +1 -0
  112. data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +43 -0
  113. data/app/views/rails_pulse/operations/_operation_analysis_database.html.erb +12 -0
  114. data/app/views/rails_pulse/operations/_operation_analysis_generic.html.erb +15 -0
  115. data/app/views/rails_pulse/operations/_operation_analysis_other.html.erb +69 -0
  116. data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +39 -0
  117. data/app/views/rails_pulse/operations/show.html.erb +79 -0
  118. data/app/views/rails_pulse/queries/_show_table.html.erb +19 -0
  119. data/app/views/rails_pulse/queries/_table.html.erb +31 -0
  120. data/app/views/rails_pulse/queries/index.html.erb +64 -0
  121. data/app/views/rails_pulse/queries/show.html.erb +86 -0
  122. data/app/views/rails_pulse/requests/_operations.html.erb +85 -0
  123. data/app/views/rails_pulse/requests/_table.html.erb +31 -0
  124. data/app/views/rails_pulse/requests/index.html.erb +64 -0
  125. data/app/views/rails_pulse/requests/show.html.erb +44 -0
  126. data/app/views/rails_pulse/routes/_table.html.erb +29 -0
  127. data/app/views/rails_pulse/routes/index.html.erb +65 -0
  128. data/app/views/rails_pulse/routes/show.html.erb +67 -0
  129. data/app/views/rails_pulse/skeletons/_chart.html.erb +3 -0
  130. data/app/views/rails_pulse/skeletons/_metric_card.html.erb +20 -0
  131. data/app/views/rails_pulse/skeletons/_panel.html.erb +19 -0
  132. data/app/views/rails_pulse/skeletons/_table.html.erb +8 -0
  133. data/config/importmap.rb +12 -0
  134. data/config/initializers/rails_charts_csp_patch.rb +83 -0
  135. data/config/initializers/rails_pulse.rb +198 -0
  136. data/config/routes.rb +16 -0
  137. data/db/migrate/20250227235904_create_routes.rb +12 -0
  138. data/db/migrate/20250227235915_create_requests.rb +19 -0
  139. data/db/migrate/20250228000000_create_queries.rb +14 -0
  140. data/db/migrate/20250228000056_create_operations.rb +24 -0
  141. data/lib/generators/rails_pulse/install_generator.rb +17 -0
  142. data/lib/generators/rails_pulse/templates/rails_pulse.rb +198 -0
  143. data/lib/rails_pulse/cleanup_service.rb +212 -0
  144. data/lib/rails_pulse/configuration.rb +176 -0
  145. data/lib/rails_pulse/engine.rb +88 -0
  146. data/lib/rails_pulse/middleware/asset_server.rb +84 -0
  147. data/lib/rails_pulse/middleware/request_collector.rb +120 -0
  148. data/lib/rails_pulse/migration.rb +29 -0
  149. data/lib/rails_pulse/subscribers/operation_subscriber.rb +280 -0
  150. data/lib/rails_pulse/version.rb +3 -0
  151. data/lib/rails_pulse.rb +38 -0
  152. data/lib/tasks/rails_pulse_tasks.rake +138 -0
  153. data/public/rails-pulse-assets/csp-test.js +110 -0
  154. data/public/rails-pulse-assets/rails-pulse-icons.js +89 -0
  155. data/public/rails-pulse-assets/rails-pulse-icons.js.map +13 -0
  156. data/public/rails-pulse-assets/rails-pulse.css +1 -0
  157. data/public/rails-pulse-assets/rails-pulse.css.map +1 -0
  158. data/public/rails-pulse-assets/rails-pulse.js +183 -0
  159. data/public/rails-pulse-assets/rails-pulse.js.map +7 -0
  160. metadata +339 -0
@@ -0,0 +1,77 @@
1
+ module RailsPulse
2
+ class Route < RailsPulse::ApplicationRecord
3
+ self.table_name = "rails_pulse_routes"
4
+
5
+ # Associations
6
+ has_many :requests, class_name: "RailsPulse::Request", foreign_key: "route_id", dependent: :restrict_with_exception
7
+
8
+ # Validations
9
+ validates :method, presence: true
10
+ validates :path, presence: true, uniqueness: { scope: :method, message: "and method combination must be unique" }
11
+
12
+ # Scopes (optional, for convenience)
13
+ scope :by_method_and_path, ->(method, path) { where(method: method, path: path).first_or_create }
14
+
15
+ def self.ransackable_attributes(auth_object = nil)
16
+ %w[path average_response_time_ms max_response_time_ms request_count requests_per_minute occurred_at requests_occurred_at error_count error_rate_percentage status_indicator]
17
+ end
18
+
19
+ def self.ransackable_associations(auth_object = nil)
20
+ %w[requests]
21
+ end
22
+
23
+ ransacker :average_response_time_ms do
24
+ Arel.sql("COALESCE(AVG(rails_pulse_requests.duration), 0)")
25
+ end
26
+
27
+ ransacker :request_count do
28
+ Arel.sql("COUNT(rails_pulse_requests.id)")
29
+ end
30
+
31
+ ransacker :occurred_at do |parent|
32
+ parent.table[:occurred_at]
33
+ end
34
+
35
+ ransacker :requests_occurred_at do |_parent|
36
+ Arel.sql("rails_pulse_requests.occurred_at")
37
+ end
38
+
39
+ ransacker :error_count do
40
+ Arel.sql(
41
+ "COALESCE(SUM(CASE WHEN rails_pulse_requests.is_error = true THEN 1 ELSE 0 END), 0)"
42
+ )
43
+ end
44
+
45
+ ransacker :max_response_time_ms do
46
+ Arel.sql("COALESCE(MAX(rails_pulse_requests.duration), 0)")
47
+ end
48
+
49
+ ransacker :error_rate_percentage do
50
+ Arel.sql("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")
51
+ end
52
+
53
+ ransacker :requests_per_minute do
54
+ # Use a simpler database-agnostic approach - this is mainly used for sorting/filtering
55
+ # so exact precision isn't as critical as avoiding database-specific functions
56
+ Arel.sql("COUNT(rails_pulse_requests.id)")
57
+ end
58
+
59
+ # Remove the problematic ransacker that causes SQL syntax errors
60
+ # The status_indicator will be handled differently or removed from filtering
61
+ # ransacker :status_indicator do
62
+ # # Removed to fix SQL generation issues in tests
63
+ # end
64
+
65
+ def to_breadcrumb
66
+ path
67
+ end
68
+
69
+ def self.average_response_time
70
+ joins(:requests).average("rails_pulse_requests.duration") || 0
71
+ end
72
+
73
+ def path_and_method
74
+ "#{path} #{method}"
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,54 @@
1
+ module RailsPulse
2
+ module Routes
3
+ module Cards
4
+ class AverageResponseTimes
5
+ def initialize(route:)
6
+ @route = route
7
+ end
8
+
9
+ def to_metric_card
10
+ requests = if @route
11
+ RailsPulse::Request.where(route: @route)
12
+ else
13
+ RailsPulse::Request.all
14
+ end
15
+
16
+ requests = requests.where("occurred_at >= ?", 2.weeks.ago.beginning_of_day)
17
+
18
+ # Calculate overall average response time
19
+ average_response_time = requests.average(:duration)&.round(0) || 0
20
+
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_avg = requests.where("occurred_at >= ?", last_7_days).average(:duration) || 0
25
+ previous_period_avg = requests.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days).average(:duration) || 0
26
+
27
+ percentage = previous_period_avg.zero? ? 0 : ((previous_period_avg - current_period_avg) / previous_period_avg * 100).abs.round(1)
28
+ trend_icon = percentage < 0.1 ? "move-right" : current_period_avg < previous_period_avg ? "trending-down" : "trending-up"
29
+ trend_amount = previous_period_avg.zero? ? "0%" : "#{percentage}%"
30
+
31
+ sparkline_data = requests
32
+ .group_by_week(:occurred_at, time_zone: "UTC")
33
+ .average(:duration)
34
+ .each_with_object({}) do |(date, avg), hash|
35
+ formatted_date = date.strftime("%b %-d")
36
+ value = avg&.round(0) || 0
37
+ hash[formatted_date] = {
38
+ value: value
39
+ }
40
+ end
41
+
42
+ {
43
+ title: "Average Response Time",
44
+ summary: "#{average_response_time} ms",
45
+ line_chart_data: sparkline_data,
46
+ trend_icon: trend_icon,
47
+ trend_amount: trend_amount,
48
+ trend_text: "Compared to last week"
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,73 @@
1
+ module RailsPulse
2
+ module Routes
3
+ module Cards
4
+ class ErrorRatePerRoute
5
+ def initialize(route: nil)
6
+ @route = route
7
+ end
8
+
9
+ def to_metric_card
10
+ # Calculate error rate for each route or a specific route
11
+ routes = if @route
12
+ RailsPulse::Route.where(id: @route)
13
+ else
14
+ RailsPulse::Route.all
15
+ end
16
+
17
+ routes = routes.where("occurred_at >= ?", 2.weeks.ago.beginning_of_day)
18
+
19
+ error_rates = routes.joins(:requests)
20
+ .select("rails_pulse_routes.id, rails_pulse_routes.path, COUNT(rails_pulse_requests.id) as total_requests, SUM(CASE WHEN rails_pulse_requests.is_error = true THEN 1 ELSE 0 END) as error_count")
21
+ .group("rails_pulse_routes.id, rails_pulse_routes.path")
22
+ .map do |route|
23
+ error_rate = route.error_count.to_f / route.total_requests * 100
24
+ {
25
+ path: route.path,
26
+ error_rate: error_rate.round(2)
27
+ }
28
+ end
29
+
30
+ # Calculate overall error rate summary as errors per day
31
+ requests = @route ? RailsPulse::Request.where(route: @route) : RailsPulse::Request.all
32
+ total_errors = requests.where(is_error: true).count
33
+ min_time = requests.minimum(:occurred_at)
34
+ max_time = requests.maximum(:occurred_at)
35
+ total_days = min_time && max_time && min_time != max_time ? (max_time - min_time) / 1.day : 1
36
+ errors_per_day = total_errors / total_days
37
+ error_rate_summary = "#{errors_per_day.round(2)} / day"
38
+
39
+ # Generate sparkline data
40
+ sparkline_data = requests
41
+ .where(is_error: true)
42
+ .group_by_week(:occurred_at, time_zone: "UTC")
43
+ .count
44
+ .each_with_object({}) do |(date, count), hash|
45
+ formatted_date = date.strftime("%b %-d")
46
+ hash[formatted_date] = {
47
+ value: count
48
+ }
49
+ end
50
+
51
+ # Determine trend direction and amount
52
+ last_7_days = 7.days.ago.beginning_of_day
53
+ previous_7_days = 14.days.ago.beginning_of_day
54
+ current_period_errors = requests.where("occurred_at >= ? AND is_error = ?", last_7_days, true).count
55
+ previous_period_errors = requests.where("occurred_at >= ? AND occurred_at < ? AND is_error = ?", previous_7_days, last_7_days, true).count
56
+
57
+ trend_amount = previous_period_errors.zero? ? "0%" : "#{((current_period_errors - previous_period_errors) / previous_period_errors.to_f * 100).round(1)}%"
58
+ trend_icon = trend_amount.to_f < 0.1 ? "move-right" : current_period_errors < previous_period_errors ? "trending-down" : "trending-up"
59
+
60
+ {
61
+ title: "Error Rate Per Route",
62
+ data: error_rates,
63
+ summary: error_rate_summary,
64
+ line_chart_data: sparkline_data,
65
+ trend_icon: trend_icon,
66
+ trend_amount: trend_amount,
67
+ trend_text: "Compared to last week"
68
+ }
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,73 @@
1
+ module RailsPulse
2
+ module Routes
3
+ module Cards
4
+ class PercentileResponseTimes
5
+ def initialize(route: nil)
6
+ @route = route
7
+ end
8
+
9
+ def to_metric_card
10
+ requests = if @route
11
+ RailsPulse::Request.where(route: @route)
12
+ else
13
+ RailsPulse::Request.all
14
+ end
15
+
16
+ requests = requests.where("occurred_at >= ?", 2.weeks.ago.beginning_of_day)
17
+
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
25
+
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
29
+
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
37
+
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
45
+
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}%"
49
+
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
60
+
61
+ {
62
+ title: "95th Percentile Response Time",
63
+ summary: "#{percentile_95th} ms",
64
+ line_chart_data: sparkline_data,
65
+ trend_icon: trend_icon,
66
+ trend_amount: trend_amount,
67
+ trend_text: "Compared to last week"
68
+ }
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,59 @@
1
+ module RailsPulse
2
+ module Routes
3
+ module Cards
4
+ class RequestCountTotals
5
+ def initialize(route: nil)
6
+ @route = route
7
+ end
8
+
9
+ def to_metric_card
10
+ requests = if @route
11
+ RailsPulse::Request.where(route: @route)
12
+ else
13
+ RailsPulse::Request.all
14
+ end
15
+
16
+ requests = requests.where("occurred_at >= ?", 2.weeks.ago.beginning_of_day)
17
+
18
+ # Calculate total request count
19
+ total_request_count = requests.count
20
+
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
26
+
27
+ percentage = previous_period_count.zero? ? 0 : ((previous_period_count - current_period_count) / previous_period_count.to_f * 100).abs.round(1)
28
+ trend_icon = percentage < 0.1 ? "move-right" : current_period_count < previous_period_count ? "trending-down" : "trending-up"
29
+ trend_amount = previous_period_count.zero? ? "0%" : "#{percentage}%"
30
+
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
45
+ average_requests_per_minute = total_request_count / total_minutes
46
+
47
+ {
48
+ title: "Request Count Total",
49
+ summary: "#{average_requests_per_minute.round(2)} / min",
50
+ line_chart_data: sparkline_data,
51
+ trend_icon: trend_icon,
52
+ trend_amount: trend_amount,
53
+ trend_text: "Compared to last week"
54
+ }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,115 @@
1
+ module RailsPulse
2
+ module Routes
3
+ module Charts
4
+ class AverageResponseTimes
5
+ def initialize(ransack_query:, group_by: :group_by_day, route: nil)
6
+ @ransack_query = ransack_query
7
+ @group_by = group_by
8
+ @route = route
9
+ end
10
+
11
+ 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 ]
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,63 @@
1
+ module RailsPulse
2
+ module Routes
3
+ module Tables
4
+ class Index
5
+ def initialize(ransack_query:, start_time:, params:)
6
+ @ransack_query = ransack_query
7
+ @start_time = start_time
8
+ @params = params
9
+ end
10
+
11
+ def to_table
12
+ # Pre-calculate values to avoid SQL injection and improve readability
13
+ minutes_elapsed = calculate_minutes_elapsed
14
+
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
20
+
21
+ status_sql = build_status_sql(thresholds)
22
+
23
+ @ransack_query.result(distinct: false)
24
+ .left_joins(:requests)
25
+ .group("rails_pulse_routes.id")
26
+ .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
+ )
36
+ end
37
+
38
+ private
39
+
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
44
+
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
50
+
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
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,124 @@
1
+ module RailsPulse
2
+ class SqlQueryNormalizer
3
+ # Smart normalization: preserve table/column names, replace only literal values
4
+ def self.normalize(query_string)
5
+ new(query_string).normalize
6
+ end
7
+
8
+ def initialize(query_string)
9
+ @query_string = query_string
10
+ end
11
+
12
+ def normalize
13
+ return nil if @query_string.nil?
14
+ return "" if @query_string.empty?
15
+
16
+ normalized = @query_string.dup
17
+
18
+ # Step 1: Temporarily protect quoted identifiers
19
+ protected_identifiers = protect_identifiers(normalized)
20
+ normalized = protected_identifiers[:normalized]
21
+
22
+ # Step 2: Replace literal values
23
+ normalized = replace_literal_values(normalized)
24
+
25
+ # Step 3: Handle special SQL constructs
26
+ normalized = handle_special_constructs(normalized)
27
+
28
+ # Step 4: Restore protected identifiers
29
+ normalized = restore_identifiers(normalized, protected_identifiers[:mapping])
30
+
31
+ # Step 5: Clean up and normalize whitespace
32
+ normalize_whitespace(normalized)
33
+ end
34
+
35
+ private
36
+
37
+ def protect_identifiers(query)
38
+ protected_identifiers = {}
39
+ identifier_counter = 0
40
+ normalized = query.dup
41
+
42
+ # Protect backticked identifiers (MySQL style)
43
+ normalized = normalized.gsub(/`([^`]+)`/) do |match|
44
+ placeholder = "__IDENTIFIER_#{identifier_counter}__"
45
+ protected_identifiers[placeholder] = match
46
+ identifier_counter += 1
47
+ placeholder
48
+ end
49
+
50
+ # Protect double-quoted identifiers (PostgreSQL/SQL standard style)
51
+ # Only protect if they appear in contexts where identifiers are expected
52
+ normalized = normalized.gsub(/"([^"]+)"/) do |match|
53
+ content = $1
54
+ # Only protect if it looks like an identifier (no spaces, not a sentence)
55
+ if looks_like_identifier?(content)
56
+ placeholder = "__IDENTIFIER_#{identifier_counter}__"
57
+ protected_identifiers[placeholder] = match
58
+ identifier_counter += 1
59
+ placeholder
60
+ else
61
+ match # Leave it as-is for now, will be replaced as string literal
62
+ end
63
+ end
64
+
65
+ { normalized: normalized, mapping: protected_identifiers }
66
+ end
67
+
68
+ def looks_like_identifier?(content)
69
+ content.match?(/^[a-zA-Z_][a-zA-Z0-9_]*$/) || content.include?(".")
70
+ end
71
+
72
+ def replace_literal_values(query)
73
+ normalized = query.dup
74
+
75
+ # Replace floating-point numbers FIRST (before integers) to avoid double replacement
76
+ normalized = normalized.gsub(/(?<![a-zA-Z_])\b\d+\.\d+\b(?![a-zA-Z_])/, "?")
77
+
78
+ # Replace integer literals with placeholders, but preserve identifiers containing numbers
79
+ # Negative lookbehind/lookahead prevents replacing numbers in table/column names
80
+ normalized = normalized.gsub(/(?<![a-zA-Z_])\b\d+\b(?![a-zA-Z_])/, "?")
81
+
82
+ # Replace string literals (single quotes)
83
+ normalized = normalized.gsub(/'(?:[^']|'')*'/, "?")
84
+
85
+ # Replace double-quoted string literals (not protected identifiers)
86
+ normalized = normalized.gsub(/"(?:[^"]|"")*"/, "?")
87
+
88
+ # Handle boolean literals
89
+ normalized = normalized.gsub(/\b(true|false)\b/i, "?")
90
+
91
+ normalized
92
+ end
93
+
94
+ def handle_special_constructs(query)
95
+ normalized = query.dup
96
+
97
+ # Handle IN clauses with multiple values - replace content but preserve structure
98
+ normalized = normalized.gsub(/\bIN\s*\(\s*([^)]+)\)/i) do |match|
99
+ content = $1
100
+ # Count commas to determine number of values
101
+ value_count = content.split(",").length
102
+ placeholders = Array.new(value_count, "?").join(", ")
103
+ "IN (#{placeholders})"
104
+ end
105
+
106
+ # Handle BETWEEN clauses
107
+ normalized = normalized.gsub(/\bBETWEEN\s+\?\s+AND\s+\?/i, "BETWEEN ? AND ?")
108
+
109
+ normalized
110
+ end
111
+
112
+ def restore_identifiers(query, identifier_mapping)
113
+ normalized = query.dup
114
+ identifier_mapping.each do |placeholder, original|
115
+ normalized = normalized.gsub(placeholder, original)
116
+ end
117
+ normalized
118
+ end
119
+
120
+ def normalize_whitespace(query)
121
+ query.gsub(/\s+/, " ").strip
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,19 @@
1
+ <%= link_to root_path, class: 'btn sidebar-menu__button' do %>
2
+ <%= rails_pulse_icon 'layout-dashboard', width: '16' %>
3
+ <span class="overflow-ellipsis">Dashboard</span>
4
+ <% end %>
5
+
6
+ <%= link_to routes_path, class: 'btn sidebar-menu__button' do %>
7
+ <%= rails_pulse_icon 'route', width: '16' %>
8
+ <span class="overflow-ellipsis">Routes</span>
9
+ <% end %>
10
+
11
+ <%= link_to requests_path, class: 'btn sidebar-menu__button' do %>
12
+ <%= rails_pulse_icon 'audio-lines', width: '16' %>
13
+ <span class="overflow-ellipsis">Requests</span>
14
+ <% end %>
15
+
16
+ <%= link_to queries_path, class: 'btn sidebar-menu__button' do %>
17
+ <%= rails_pulse_icon 'database', width: '16' %>
18
+ <span class="overflow-ellipsis">Queries</span>
19
+ <% end %>