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,87 @@
1
+ module RailsPulse
2
+ class Operation < RailsPulse::ApplicationRecord
3
+ self.table_name = "rails_pulse_operations"
4
+
5
+ OPERATION_TYPES = %w[
6
+ sql
7
+ controller
8
+ template
9
+ partial
10
+ layout
11
+ collection
12
+ cache_read
13
+ cache_write
14
+ http
15
+ job
16
+ mailer
17
+ storage
18
+ ].freeze
19
+
20
+ # Associations
21
+ belongs_to :request, class_name: "RailsPulse::Request"
22
+ belongs_to :query, class_name: "RailsPulse::Query", optional: true
23
+
24
+ # Validations
25
+ validates :request_id, presence: true
26
+ validates :operation_type, presence: true, inclusion: { in: OPERATION_TYPES }
27
+ validates :label, presence: true
28
+ validates :occurred_at, presence: true
29
+ validates :duration, presence: true, numericality: { greater_than_or_equal_to: 0 }
30
+
31
+ # Scopes (optional, for convenience)
32
+ scope :by_type, ->(type) { where(operation_type: type) }
33
+
34
+ before_validation :associate_query
35
+
36
+ def self.ransackable_attributes(auth_object = nil)
37
+ %w[id occurred_at label duration start_time average_query_time_ms query_count operation_type]
38
+ end
39
+
40
+ def self.ransackable_associations(auth_object = nil)
41
+ %w[]
42
+ end
43
+
44
+ ransacker :average_query_time_ms do
45
+ Arel.sql("COALESCE(AVG(rails_pulse_operations.duration), 0)")
46
+ end
47
+
48
+ ransacker :query_count do
49
+ Arel.sql("COUNT(rails_pulse_operations.id)")
50
+ end
51
+
52
+ ransacker :occurred_at, formatter: ->(val) {
53
+ # Handle different time formats for database compatibility
54
+ case val
55
+ when Time, DateTime, ActiveSupport::TimeWithZone
56
+ val.utc.iso8601
57
+ when String
58
+ Time.zone.parse(val).utc.iso8601
59
+ when Integer
60
+ Time.at(val).utc.iso8601
61
+ else
62
+ # Fallback: try to parse as integer timestamp
63
+ Time.at(val.to_i).utc.iso8601
64
+ end
65
+ } do |parent|
66
+ parent.table[:occurred_at]
67
+ end
68
+
69
+ def to_s
70
+ id
71
+ end
72
+
73
+ private
74
+
75
+ def associate_query
76
+ return unless operation_type == "sql" && label.present?
77
+
78
+ normalized = normalize_query_label(label)
79
+ self.query = RailsPulse::Query.find_or_create_by(normalized_sql: normalized)
80
+ end
81
+
82
+ # Normalize SQL query using the dedicated service
83
+ def normalize_query_label(label)
84
+ RailsPulse::SqlQueryNormalizer.normalize(label)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,52 @@
1
+ module RailsPulse
2
+ module Queries
3
+ module Cards
4
+ class AverageQueryTimes
5
+ def initialize(query:)
6
+ @query = query
7
+ end
8
+
9
+ def to_metric_card
10
+ operations = if @query
11
+ RailsPulse::Operation.where(query: @query)
12
+ else
13
+ RailsPulse::Operation.all
14
+ end
15
+
16
+ # Calculate overall average response time
17
+ average_query_time = operations.average(:duration)&.round(0) || 0
18
+
19
+ # Calculate trend by comparing last 7 days vs previous 7 days
20
+ last_7_days = 7.days.ago.beginning_of_day
21
+ previous_7_days = 14.days.ago.beginning_of_day
22
+ current_period_avg = operations.where("occurred_at >= ?", last_7_days).average(:duration) || 0
23
+ previous_period_avg = operations.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days).average(:duration) || 0
24
+
25
+ percentage = previous_period_avg.zero? ? 0 : ((previous_period_avg - current_period_avg) / previous_period_avg * 100).abs.round(1)
26
+ trend_icon = percentage < 0.1 ? "move-right" : current_period_avg < previous_period_avg ? "trending-down" : "trending-up"
27
+ trend_amount = previous_period_avg.zero? ? "0%" : "#{percentage}%"
28
+
29
+ sparkline_data = operations
30
+ .group_by_week(:occurred_at, time_zone: "UTC")
31
+ .average(:duration)
32
+ .each_with_object({}) do |(date, avg), hash|
33
+ formatted_date = date.strftime("%b %-d")
34
+ value = avg&.round(0) || 0
35
+ hash[formatted_date] = {
36
+ value: value
37
+ }
38
+ end
39
+
40
+ {
41
+ title: "Average Query Time",
42
+ summary: "#{average_query_time} ms",
43
+ line_chart_data: sparkline_data,
44
+ trend_icon: trend_icon,
45
+ trend_amount: trend_amount,
46
+ trend_text: "Compared to last week"
47
+ }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,57 @@
1
+ module RailsPulse
2
+ module Queries
3
+ module Cards
4
+ class ExecutionRate
5
+ def initialize(query: nil)
6
+ @query = query
7
+ end
8
+
9
+ def to_metric_card
10
+ operations = if @query
11
+ RailsPulse::Operation.where(query: @query)
12
+ else
13
+ RailsPulse::Operation.all
14
+ end
15
+
16
+ # Calculate total request count
17
+ total_request_count = operations.count
18
+
19
+ # Calculate trend by comparing last 7 days vs previous 7 days
20
+ last_7_days = 7.days.ago.beginning_of_day
21
+ previous_7_days = 14.days.ago.beginning_of_day
22
+ current_period_count = operations.where("occurred_at >= ?", last_7_days).count
23
+ previous_period_count = operations.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days).count
24
+
25
+ percentage = previous_period_count.zero? ? 0 : ((previous_period_count - current_period_count) / previous_period_count.to_f * 100).abs.round(1)
26
+ trend_icon = percentage < 0.1 ? "move-right" : current_period_count < previous_period_count ? "trending-down" : "trending-up"
27
+ trend_amount = previous_period_count.zero? ? "0%" : "#{percentage}%"
28
+
29
+ sparkline_data = operations
30
+ .group_by_week(:occurred_at, time_zone: "UTC")
31
+ .count
32
+ .each_with_object({}) do |(date, count), hash|
33
+ formatted_date = date.strftime("%b %-d")
34
+ hash[formatted_date] = {
35
+ value: count
36
+ }
37
+ end
38
+
39
+ # Calculate average operations per minute
40
+ min_time = operations.minimum(:occurred_at)
41
+ max_time = operations.maximum(:occurred_at)
42
+ total_minutes = min_time && max_time && min_time != max_time ? (max_time - min_time) / 60.0 : 1
43
+ average_operations_per_minute = total_request_count / total_minutes
44
+
45
+ {
46
+ title: "Execution Rate",
47
+ summary: "#{average_operations_per_minute.round(2)} / min",
48
+ line_chart_data: sparkline_data,
49
+ trend_icon: trend_icon,
50
+ trend_amount: trend_amount,
51
+ trend_text: "Compared to last week"
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,71 @@
1
+ module RailsPulse
2
+ module Queries
3
+ module Cards
4
+ class PercentileQueryTimes
5
+ def initialize(query: nil)
6
+ @query = query
7
+ end
8
+
9
+ def to_metric_card
10
+ operations = if @query
11
+ RailsPulse::Operation.where(query: @query)
12
+ else
13
+ RailsPulse::Operation.all
14
+ end
15
+
16
+ # Calculate overall 95th percentile response time
17
+ count = operations.count
18
+ percentile_95th = if count > 0
19
+ operations.select("duration").order("duration").limit(1).offset((count * 0.95).floor).pluck(:duration).first || 0
20
+ else
21
+ 0
22
+ end
23
+
24
+ # Calculate trend by comparing last 7 days vs previous 7 days for 95th percentile
25
+ last_7_days = 7.days.ago.beginning_of_day
26
+ previous_7_days = 14.days.ago.beginning_of_day
27
+
28
+ current_period = operations.where("occurred_at >= ?", last_7_days)
29
+ current_count = current_period.count
30
+ current_period_95th = if current_count > 0
31
+ current_period.select("duration").order("duration").limit(1).offset((current_count * 0.95).floor).pluck(:duration).first || 0
32
+ else
33
+ 0
34
+ end
35
+
36
+ previous_period = operations.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days)
37
+ previous_count = previous_period.count
38
+ previous_period_95th = if previous_count > 0
39
+ previous_period.select("duration").order("duration").limit(1).offset((previous_count * 0.95).floor).pluck(:duration).first || 0
40
+ else
41
+ 0
42
+ end
43
+
44
+ percentage = previous_period_95th.zero? ? 0 : ((previous_period_95th - current_period_95th) / previous_period_95th * 100).abs.round(1)
45
+ trend_icon = percentage < 0.1 ? "move-right" : current_period_95th < previous_period_95th ? "trending-down" : "trending-up"
46
+ trend_amount = previous_period_95th.zero? ? "0%" : "#{percentage}%"
47
+
48
+ sparkline_data = operations
49
+ .group_by_week(:occurred_at, time_zone: "UTC")
50
+ .average(:duration)
51
+ .each_with_object({}) do |(date, avg), hash|
52
+ formatted_date = date.strftime("%b %-d")
53
+ value = avg&.round(0) || 0
54
+ hash[formatted_date] = {
55
+ value: value
56
+ }
57
+ end
58
+
59
+ {
60
+ title: "95th Percentile Query Time",
61
+ summary: "#{percentile_95th} ms",
62
+ line_chart_data: sparkline_data,
63
+ trend_icon: trend_icon,
64
+ trend_amount: trend_amount,
65
+ trend_text: "Compared to last week"
66
+ }
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,112 @@
1
+ module RailsPulse
2
+ module Queries
3
+ module Charts
4
+ class AverageQueryTimes
5
+ def initialize(ransack_query:, group_by: :group_by_day, query: nil)
6
+ @ransack_query = ransack_query
7
+ @group_by = group_by
8
+ @query = query
9
+ end
10
+
11
+ def to_rails_chart
12
+ # Get actual data using existing logic
13
+ actual_data = if @query
14
+ @ransack_query.result(distinct: false)
15
+ .public_send(@group_by, "occurred_at", series: true, time_zone: "UTC")
16
+ .average(:duration)
17
+ else
18
+ @ransack_query.result(distinct: false)
19
+ .left_joins(:operations)
20
+ .public_send(
21
+ @group_by,
22
+ "rails_pulse_operations.occurred_at",
23
+ series: true,
24
+ time_zone: "UTC"
25
+ )
26
+ .average("rails_pulse_operations.duration")
27
+ end
28
+
29
+ # Create full time range and fill in missing periods
30
+ fill_missing_periods(actual_data)
31
+ end
32
+
33
+ private
34
+
35
+ def fill_missing_periods(actual_data)
36
+ # Extract actual time range from ransack query conditions
37
+ start_time, end_time = extract_time_range_from_ransack
38
+
39
+ # Create time range based on grouping type
40
+ case @group_by
41
+ when :group_by_hour
42
+ time_range = generate_hour_range(start_time, end_time)
43
+ else # :group_by_day
44
+ time_range = generate_day_range(start_time, end_time)
45
+ end
46
+
47
+ # Fill in all periods with zero values for missing periods
48
+ time_range.each_with_object({}) do |period, result|
49
+ occurred_at = period.is_a?(String) ? Time.parse(period) : period
50
+ occurred_at = (occurred_at.is_a?(Time) || occurred_at.is_a?(Date)) ? occurred_at : Time.current
51
+
52
+ normalized_occurred_at =
53
+ case @group_by
54
+ when :group_by_hour
55
+ occurred_at&.beginning_of_hour || occurred_at
56
+ when :group_by_day
57
+ occurred_at&.beginning_of_day || occurred_at
58
+ else
59
+ occurred_at
60
+ end
61
+
62
+ # Use actual data if available, otherwise default to 0
63
+ average_duration = actual_data[period] || 0
64
+ result[normalized_occurred_at.to_i] = {
65
+ value: average_duration.to_f
66
+ }
67
+ end
68
+ end
69
+
70
+ def generate_day_range(start_time, end_time)
71
+ (start_time.to_date..end_time.to_date).map(&:beginning_of_day)
72
+ end
73
+
74
+ def generate_hour_range(start_time, end_time)
75
+ current = start_time
76
+ hours = []
77
+ while current <= end_time
78
+ hours << current
79
+ current += 1.hour
80
+ end
81
+ hours
82
+ end
83
+
84
+ def extract_time_range_from_ransack
85
+ # Extract time range from ransack conditions
86
+ conditions = @ransack_query.conditions
87
+
88
+ if @query
89
+ # For specific query, look for occurred_at conditions
90
+ start_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "gteq" }
91
+ end_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "lt" }
92
+ else
93
+ # For general operations, look for rails_pulse_operations_occurred_at conditions
94
+ start_condition = conditions.find { |c| c.a.first == "rails_pulse_operations_occurred_at" && c.p == "gteq" }
95
+ end_condition = conditions.find { |c| c.a.first == "rails_pulse_operations_occurred_at" && c.p == "lt" }
96
+ end
97
+
98
+ start_time = start_condition&.v || 2.weeks.ago
99
+ end_time = end_condition&.v || Time.current
100
+
101
+ # Normalize time boundaries based on grouping
102
+ case @group_by
103
+ when :group_by_hour
104
+ [ start_time.beginning_of_hour, end_time.beginning_of_hour ]
105
+ else
106
+ [ start_time.beginning_of_day, end_time.beginning_of_day ]
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,58 @@
1
+ module RailsPulse
2
+ class Query < RailsPulse::ApplicationRecord
3
+ self.table_name = "rails_pulse_queries"
4
+
5
+ # Associations
6
+ has_many :operations, class_name: "RailsPulse::Operation", inverse_of: :query
7
+
8
+ # Validations
9
+ validates :normalized_sql, presence: true, uniqueness: true
10
+
11
+ def self.ransackable_attributes(auth_object = nil)
12
+ %w[id normalized_sql average_query_time_ms execution_count total_time_consumed performance_status occurred_at]
13
+ end
14
+
15
+ def self.ransackable_associations(auth_object = nil)
16
+ %w[operations]
17
+ end
18
+
19
+ ransacker :average_query_time_ms do
20
+ Arel.sql("COALESCE(AVG(rails_pulse_operations.duration), 0)")
21
+ end
22
+
23
+ ransacker :execution_count do
24
+ Arel.sql("COUNT(rails_pulse_operations.id)")
25
+ end
26
+
27
+ ransacker :total_time_consumed do
28
+ Arel.sql("COALESCE(SUM(rails_pulse_operations.duration), 0)")
29
+ end
30
+
31
+ ransacker :performance_status do
32
+ # Calculate status indicator based on query_thresholds with safe defaults
33
+ config = RailsPulse.configuration rescue nil
34
+ thresholds = config&.query_thresholds || { slow: 200, very_slow: 500, critical: 1000 }
35
+
36
+ slow = (thresholds[:slow] || 200).to_f
37
+ very_slow = (thresholds[:very_slow] || 500).to_f
38
+ critical = (thresholds[:critical] || 1000).to_f
39
+
40
+ # Use Arel to safely construct the SQL with parameterized values
41
+ avg_duration = Arel.sql("COALESCE(AVG(rails_pulse_operations.duration), 0)")
42
+
43
+ Arel::Nodes::Case.new(avg_duration)
44
+ .when(avg_duration.lt(slow)).then(0)
45
+ .when(avg_duration.lt(very_slow)).then(1)
46
+ .when(avg_duration.lt(critical)).then(2)
47
+ .else(3)
48
+ end
49
+
50
+ ransacker :occurred_at do
51
+ Arel.sql("MAX(rails_pulse_operations.occurred_at)")
52
+ end
53
+
54
+ def to_s
55
+ id
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,64 @@
1
+ module RailsPulse
2
+ class Request < RailsPulse::ApplicationRecord
3
+ self.table_name = "rails_pulse_requests"
4
+
5
+ # Associations
6
+ belongs_to :route, class_name: "RailsPulse::Route"
7
+ has_many :operations, class_name: "RailsPulse::Operation", foreign_key: "request_id", dependent: :destroy
8
+
9
+ # Validations
10
+ validates :route_id, presence: true
11
+ validates :occurred_at, presence: true
12
+ validates :duration, presence: true, numericality: { greater_than_or_equal_to: 0 }
13
+ validates :status, presence: true
14
+ validates :is_error, inclusion: { in: [ true, false ] }
15
+ validates :request_uuid, presence: true, uniqueness: true
16
+
17
+ before_create :set_request_uuid
18
+
19
+ def self.ransackable_attributes(auth_object = nil)
20
+ %w[id route_id occurred_at duration status status_indicator route_path]
21
+ end
22
+
23
+ def self.ransackable_associations(auth_object = nil)
24
+ %w[route]
25
+ end
26
+
27
+ ransacker :occurred_at do |parent|
28
+ parent.table[:occurred_at]
29
+ end
30
+
31
+ ransacker :route_path do |parent|
32
+ Arel.sql("rails_pulse_routes.path")
33
+ end
34
+
35
+ ransacker :status_indicator do |parent|
36
+ # Calculate status indicator based on request_thresholds with safe defaults
37
+ config = RailsPulse.configuration rescue nil
38
+ thresholds = config&.request_thresholds || { slow: 500, very_slow: 1000, critical: 2000 }
39
+
40
+ slow = thresholds[:slow] || 500
41
+ very_slow = thresholds[:very_slow] || 1000
42
+ critical = thresholds[:critical] || 2000
43
+
44
+ Arel.sql("
45
+ CASE
46
+ WHEN rails_pulse_requests.duration < #{slow} THEN 0
47
+ WHEN rails_pulse_requests.duration < #{very_slow} THEN 1
48
+ WHEN rails_pulse_requests.duration < #{critical} THEN 2
49
+ ELSE 3
50
+ END
51
+ ")
52
+ end
53
+
54
+ def to_s
55
+ occurred_at.strftime("%b %d, %Y %l:%M %p")
56
+ end
57
+
58
+ private
59
+
60
+ def set_request_uuid
61
+ self.request_uuid = SecureRandom.uuid if request_uuid.blank?
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,99 @@
1
+ module RailsPulse
2
+ module Requests
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 = @ransack_query.result(distinct: false)
14
+ .public_send(
15
+ @group_by,
16
+ "rails_pulse_requests.occurred_at",
17
+ series: true,
18
+ time_zone: "UTC"
19
+ )
20
+ .average("rails_pulse_requests.duration")
21
+
22
+ # Create full time range and fill in missing periods
23
+ fill_missing_periods(actual_data)
24
+ end
25
+
26
+ private
27
+
28
+ def fill_missing_periods(actual_data)
29
+ # Extract actual time range from ransack query conditions
30
+ start_time, end_time = extract_time_range_from_ransack
31
+
32
+ # Create time range based on grouping type
33
+ case @group_by
34
+ when :group_by_hour
35
+ time_range = generate_hour_range(start_time, end_time)
36
+ else # :group_by_day
37
+ time_range = generate_day_range(start_time, end_time)
38
+ end
39
+
40
+ # Fill in all periods with zero values for missing periods
41
+ time_range.each_with_object({}) do |period, result|
42
+ occurred_at = period.is_a?(String) ? Time.parse(period) : period
43
+ occurred_at = (occurred_at.is_a?(Time) || occurred_at.is_a?(Date)) ? occurred_at : Time.current
44
+
45
+ normalized_occurred_at =
46
+ case @group_by
47
+ when :group_by_hour
48
+ occurred_at&.beginning_of_hour || occurred_at
49
+ when :group_by_day
50
+ occurred_at&.beginning_of_day || occurred_at
51
+ else
52
+ occurred_at
53
+ end
54
+
55
+ # Use actual data if available, otherwise default to 0
56
+ average_duration = actual_data[period] || 0
57
+ result[normalized_occurred_at.to_i] = {
58
+ value: average_duration.to_f
59
+ }
60
+ end
61
+ end
62
+
63
+ def generate_day_range(start_time, end_time)
64
+ (start_time.to_date..end_time.to_date).map(&:beginning_of_day)
65
+ end
66
+
67
+ def generate_hour_range(start_time, end_time)
68
+ current = start_time
69
+ hours = []
70
+ while current <= end_time
71
+ hours << current
72
+ current += 1.hour
73
+ end
74
+ hours
75
+ end
76
+
77
+ def extract_time_range_from_ransack
78
+ # Extract time range from ransack conditions
79
+ conditions = @ransack_query.conditions
80
+
81
+ # For requests, look for occurred_at conditions on rails_pulse_requests
82
+ start_condition = conditions.find { |c| c.a.first == "rails_pulse_requests_occurred_at" && c.p == "gteq" }
83
+ end_condition = conditions.find { |c| c.a.first == "rails_pulse_requests_occurred_at" && c.p == "lt" }
84
+
85
+ start_time = start_condition&.v || 2.weeks.ago
86
+ end_time = end_condition&.v || Time.current
87
+
88
+ # Normalize time boundaries based on grouping
89
+ case @group_by
90
+ when :group_by_hour
91
+ [ start_time.beginning_of_hour, end_time.beginning_of_hour ]
92
+ else
93
+ [ start_time.beginning_of_day, end_time.beginning_of_day ]
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,35 @@
1
+ module RailsPulse
2
+ module Requests
3
+ module Charts
4
+ class OperationsChart
5
+ OperationBar = Struct.new(:operation, :duration, :left_pct, :width_pct)
6
+
7
+ attr_reader :bars, :min_start, :max_end, :total_duration
8
+
9
+ HORIZONTAL_OFFSET_PX = 20
10
+
11
+ def initialize(operations)
12
+ @operations = operations
13
+ @min_start = @operations.map(&:start_time).min || 0
14
+ @max_end = @operations.map { |op| op.start_time + op.duration }.max || 1
15
+ @total_duration = (@max_end - @min_start).nonzero? || 1
16
+ @bars = build_bars
17
+ end
18
+
19
+ private
20
+
21
+ def build_bars
22
+ @operations.map do |operation|
23
+ left_pct = ((operation.start_time - @min_start).to_f / @total_duration) * (100 - px_to_pct) + px_to_pct / 2
24
+ width_pct = (operation.duration.to_f / @total_duration) * (100 - px_to_pct)
25
+ OperationBar.new(operation, operation.duration.round(0), left_pct, width_pct)
26
+ end
27
+ end
28
+
29
+ def px_to_pct
30
+ (HORIZONTAL_OFFSET_PX.to_f / 1000) * 100
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end