rails_pulse 0.3.3 → 0.4.0.pre.1

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 (167) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +272 -0
  3. data/README.md +44 -3
  4. data/app/assets/stylesheets/rails_pulse/application.css +21 -32
  5. data/app/assets/stylesheets/rails_pulse/components/backtrace.css +238 -0
  6. data/app/assets/stylesheets/rails_pulse/components/badge.css +32 -0
  7. data/app/assets/stylesheets/rails_pulse/components/button.css +2 -36
  8. data/app/assets/stylesheets/rails_pulse/components/card.css +8 -11
  9. data/app/assets/stylesheets/rails_pulse/components/chart.css +16 -3
  10. data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +5 -39
  11. data/app/assets/stylesheets/rails_pulse/components/dashboard.css +0 -52
  12. data/app/assets/stylesheets/rails_pulse/components/deployments.css +39 -0
  13. data/app/assets/stylesheets/rails_pulse/components/dialog.css +0 -5
  14. data/app/assets/stylesheets/rails_pulse/components/input.css +0 -14
  15. data/app/assets/stylesheets/rails_pulse/components/layouts.css +0 -20
  16. data/app/assets/stylesheets/rails_pulse/components/menu.css +0 -16
  17. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +0 -16
  18. data/app/assets/stylesheets/rails_pulse/components/storage.css +187 -0
  19. data/app/assets/stylesheets/rails_pulse/components/tags.css +0 -14
  20. data/app/assets/stylesheets/rails_pulse/components/time_range_selector.css +5 -5
  21. data/app/assets/stylesheets/rails_pulse/components/utilities.css +0 -17
  22. data/app/controllers/concerns/chart_table_concern.rb +15 -3
  23. data/app/controllers/concerns/ransack_params_concern.rb +18 -0
  24. data/app/controllers/concerns/response_range_concern.rb +1 -1
  25. data/app/controllers/concerns/time_range_concern.rb +28 -14
  26. data/app/controllers/rails_pulse/application_controller.rb +104 -31
  27. data/app/controllers/rails_pulse/dashboard_controller.rb +9 -0
  28. data/app/controllers/rails_pulse/deployments_controller.rb +26 -5
  29. data/app/controllers/rails_pulse/exception_occurrences_controller.rb +19 -0
  30. data/app/controllers/rails_pulse/exceptions_controller.rb +119 -0
  31. data/app/controllers/rails_pulse/job_runs_controller.rb +1 -1
  32. data/app/controllers/rails_pulse/requests_controller.rb +1 -1
  33. data/app/controllers/rails_pulse/routes_controller.rb +7 -1
  34. data/app/controllers/rails_pulse/storage_controller.rb +7 -0
  35. data/app/controllers/rails_pulse/tags_controller.rb +2 -2
  36. data/app/helpers/rails_pulse/application_helper.rb +1 -0
  37. data/app/helpers/rails_pulse/backtrace_helper.rb +85 -0
  38. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +4 -1
  39. data/app/helpers/rails_pulse/chart_helper.rb +0 -3
  40. data/app/helpers/rails_pulse/route_helper.rb +8 -16
  41. data/app/javascript/rails_pulse/application.js +29 -8
  42. data/app/javascript/rails_pulse/controllers/clipboard_controller.js +58 -0
  43. data/app/javascript/rails_pulse/controllers/index_controller.js +16 -10
  44. data/app/javascript/rails_pulse/controllers/pagination_controller.js +6 -2
  45. data/app/javascript/rails_pulse/controllers/popover_controller.js +4 -0
  46. data/app/models/concerns/rails_pulse/taggable.rb +11 -6
  47. data/app/models/rails_pulse/cards/base.rb +53 -0
  48. data/app/models/rails_pulse/dashboard/health_summary.rb +52 -0
  49. data/app/models/rails_pulse/dashboard/needs_attention.rb +73 -1
  50. data/app/models/rails_pulse/dashboard/storage_status.rb +490 -0
  51. data/app/models/rails_pulse/deployment.rb +47 -1
  52. data/app/models/rails_pulse/exception_group.rb +37 -0
  53. data/app/models/rails_pulse/exception_occurrence.rb +27 -0
  54. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +116 -0
  55. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +49 -0
  56. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +62 -0
  57. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +56 -0
  58. data/app/models/rails_pulse/exceptions/raw_data.rb +216 -0
  59. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -2
  60. data/app/models/rails_pulse/operation.rb +13 -4
  61. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +8 -3
  62. data/app/models/rails_pulse/request.rb +5 -1
  63. data/app/models/rails_pulse/route.rb +71 -21
  64. data/app/models/rails_pulse/routes/cards/error_rates.rb +6 -3
  65. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +8 -3
  66. data/app/models/rails_pulse/routes/tables/index.rb +8 -6
  67. data/app/models/rails_pulse/summary.rb +21 -10
  68. data/app/models/rails_pulse/tables/base.rb +4 -2
  69. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +47 -12
  70. data/app/services/rails_pulse/exception_capture_service.rb +228 -0
  71. data/app/services/rails_pulse/exception_message_sanitizer.rb +152 -0
  72. data/app/services/rails_pulse/operations/change_point.rb +144 -0
  73. data/app/services/rails_pulse/operations/compare.rb +92 -0
  74. data/app/services/rails_pulse/operations/comparison.rb +126 -0
  75. data/app/services/rails_pulse/operations/metric.rb +63 -0
  76. data/app/services/rails_pulse/operations/series.rb +84 -0
  77. data/app/services/rails_pulse/operations/subject.rb +77 -0
  78. data/app/services/rails_pulse/route_controller_action_backfiller.rb +110 -0
  79. data/app/services/rails_pulse/route_merger.rb +99 -0
  80. data/app/services/rails_pulse/route_migrator.rb +139 -0
  81. data/app/services/rails_pulse/route_path_normalizer.rb +79 -0
  82. data/app/services/rails_pulse/route_recognizer.rb +47 -0
  83. data/app/services/rails_pulse/summary_service.rb +45 -0
  84. data/app/services/rails_pulse/tag_filter_service.rb +5 -3
  85. data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
  86. data/app/views/layouts/rails_pulse/_onboarding_banner.html.erb +8 -0
  87. data/app/views/layouts/rails_pulse/_setup_screen.html.erb +5 -5
  88. data/app/views/layouts/rails_pulse/application.html.erb +4 -4
  89. data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
  90. data/app/views/rails_pulse/components/_panel.html.erb +16 -6
  91. data/app/views/rails_pulse/components/_raw_data.html.erb +34 -0
  92. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -8
  93. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +64 -0
  94. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +21 -10
  95. data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +67 -0
  96. data/app/views/rails_pulse/dashboard/index.html.erb +22 -1
  97. data/app/views/rails_pulse/deployments/_status_badge.html.erb +5 -0
  98. data/app/views/rails_pulse/deployments/_table.html.erb +29 -0
  99. data/app/views/rails_pulse/deployments/index.html.erb +15 -0
  100. data/app/views/rails_pulse/deployments/show.html.erb +62 -0
  101. data/app/views/rails_pulse/exception_occurrences/show.html.erb +45 -0
  102. data/app/views/rails_pulse/exceptions/_backtrace.html.erb +33 -0
  103. data/app/views/rails_pulse/exceptions/_metric_cards.html.erb +6 -0
  104. data/app/views/rails_pulse/exceptions/_occurrences_table.html.erb +23 -0
  105. data/app/views/rails_pulse/exceptions/_status_badge.html.erb +6 -0
  106. data/app/views/rails_pulse/exceptions/_table.html.erb +33 -0
  107. data/app/views/rails_pulse/exceptions/index.html.erb +66 -0
  108. data/app/views/rails_pulse/exceptions/show.html.erb +52 -0
  109. data/app/views/rails_pulse/queries/_raw_data.html.erb +1 -23
  110. data/app/views/rails_pulse/requests/_table.html.erb +1 -1
  111. data/app/views/rails_pulse/routes/_requests_table.html.erb +5 -0
  112. data/app/views/rails_pulse/routes/_table.html.erb +22 -8
  113. data/app/views/rails_pulse/routes/index.html.erb +5 -1
  114. data/app/views/rails_pulse/routes/show.html.erb +5 -0
  115. data/app/views/rails_pulse/storage/show.html.erb +123 -0
  116. data/config/routes.rb +13 -4
  117. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +50 -0
  118. data/db/rails_pulse_migrate/20260507000001_add_actual_query_to_operations.rb +10 -5
  119. data/db/rails_pulse_migrate/20260610000001_add_controller_action_to_rails_pulse_routes.rb +8 -0
  120. data/db/rails_pulse_migrate/20260610000002_change_rails_pulse_routes_to_multi_verb_model.rb +185 -0
  121. data/db/rails_pulse_migrate/20260610000003_add_null_action_unique_index_to_routes.rb +32 -0
  122. data/db/rails_pulse_migrate/20260823000001_add_location_to_exception_groups.rb +12 -0
  123. data/db/rails_pulse_schema.rb +54 -6
  124. data/lib/generators/rails_pulse/base_methods.rb +2 -0
  125. data/lib/generators/rails_pulse/install_generator.rb +5 -0
  126. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +54 -6
  127. data/lib/generators/rails_pulse/templates/rails_pulse.rb +152 -16
  128. data/lib/generators/rails_pulse/upgrade_generator.rb +166 -38
  129. data/lib/rails_pulse/cleanup_service.rb +110 -15
  130. data/lib/rails_pulse/configuration.rb +125 -6
  131. data/lib/rails_pulse/engine.rb +32 -17
  132. data/lib/rails_pulse/installers/config_updater.rb +202 -0
  133. data/lib/rails_pulse/job_run_collector.rb +43 -16
  134. data/lib/rails_pulse/middleware/asset_server.rb +11 -0
  135. data/lib/rails_pulse/middleware/request_collector.rb +9 -4
  136. data/lib/rails_pulse/packaged_assets.rb +147 -0
  137. data/lib/rails_pulse/route_indexes.rb +66 -0
  138. data/lib/rails_pulse/subscribers/exception_subscriber.rb +36 -0
  139. data/lib/rails_pulse/tracker.rb +12 -9
  140. data/lib/rails_pulse/version.rb +1 -1
  141. data/lib/rails_pulse.rb +1 -0
  142. data/lib/rails_pulse_server.ru +16 -47
  143. data/lib/tasks/rails_pulse.rake +83 -4
  144. data/lib/tasks/rails_pulse_assets.rake +15 -0
  145. data/lib/tasks/rails_pulse_tasks.rake +29 -0
  146. data/public/rails-pulse-assets/rails-pulse-icons.js +6 -2
  147. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  148. data/public/rails-pulse-assets/rails-pulse.js +18 -117
  149. metadata +78 -37
  150. data/Rakefile +0 -554
  151. data/app/javascript/rails_pulse/controllers/form_controller.js +0 -39
  152. data/app/javascript/rails_pulse/controllers/period_selector_controller.js +0 -19
  153. data/app/javascript/rails_pulse/controllers/timezone_controller.js +0 -89
  154. data/app/javascript/rails_pulse/theme.js +0 -416
  155. data/config/brakeman.ignore +0 -190
  156. data/config/brakeman.yml +0 -68
  157. data/config/initializers/rails_pulse.rb +0 -287
  158. data/public/rails-pulse-assets/csp-test.js +0 -120
  159. data/public/rails-pulse-assets/rails-pulse-icons.js.map +0 -13
  160. data/public/rails-pulse-assets/rails-pulse.css.map +0 -1
  161. data/public/rails-pulse-assets/rails-pulse.js.map +0 -7
  162. data/vendor/assets/javascripts/rails-pulse-icons.js +0 -93
  163. data/vendor/assets/javascripts/rails-pulse-icons.js.map +0 -13
  164. data/vendor/assets/javascripts/rails-pulse.js +0 -157
  165. data/vendor/assets/javascripts/rails-pulse.js.map +0 -7
  166. data/vendor/assets/stylesheets/rails-pulse.css +0 -1
  167. data/vendor/assets/stylesheets/rails-pulse.css.map +0 -1
@@ -18,7 +18,7 @@
18
18
 
19
19
  .time-range-trigger:hover {
20
20
  background: var(--color-secondary);
21
- border-color: var(--color-border-emphasis);
21
+ border-color: var(--color-border-dark);
22
22
  }
23
23
 
24
24
  .time-range-trigger svg {
@@ -28,7 +28,7 @@
28
28
 
29
29
  .time-range-trigger:has(+ [popover]:popover-open) {
30
30
  background: var(--color-secondary);
31
- border-color: var(--color-border-emphasis);
31
+ border-color: var(--color-border-dark);
32
32
  }
33
33
 
34
34
  .time-range-trigger:has(+ [popover]:popover-open) svg {
@@ -36,16 +36,16 @@
36
36
  }
37
37
 
38
38
  /* Light mode improvements */
39
- html[data-color-scheme="light"] .time-range-trigger {
39
+ html:not([data-color-scheme="dark"]) .time-range-trigger {
40
40
  border-color: rgba(0, 0, 0, 0.2);
41
41
  }
42
42
 
43
- html[data-color-scheme="light"] .time-range-trigger:hover {
43
+ html:not([data-color-scheme="dark"]) .time-range-trigger:hover {
44
44
  background: rgba(0, 0, 0, 0.05);
45
45
  border-color: rgba(0, 0, 0, 0.3);
46
46
  }
47
47
 
48
- html[data-color-scheme="light"] .time-range-trigger:has(+ [popover]:popover-open) {
48
+ html:not([data-color-scheme="dark"]) .time-range-trigger:has(+ [popover]:popover-open) {
49
49
  background: rgba(0, 0, 0, 0.05);
50
50
  border-color: rgba(0, 0, 0, 0.3);
51
51
  }
@@ -3,37 +3,20 @@
3
3
  .w-4 { width: 1rem; }
4
4
  .w-6 { width: 1.5rem; }
5
5
  .w-8 { width: 2rem; }
6
- .w-12 { width: 3rem; }
7
6
  .w-16 { width: 4rem; }
8
7
  .w-20 { width: 5rem; }
9
8
  .w-24 { width: 6rem; }
10
9
  .w-28 { width: 7rem; }
11
10
  .w-32 { width: 8rem; }
12
11
  .w-36 { width: 9rem; }
13
- .w-40 { width: 10rem; }
14
- .w-44 { width: 11rem; }
15
12
  .w-48 { width: 12rem; }
16
- .w-52 { width: 13rem; }
17
13
  .w-56 { width: 14rem; }
18
14
  .w-60 { width: 15rem; }
19
- .w-64 { width: 16rem; }
20
15
 
21
16
  /* Min-width utilities */
22
- .min-w-0 { min-width: 0; }
23
- .min-w-4 { min-width: 1rem; }
24
- .min-w-8 { min-width: 2rem; }
25
- .min-w-12 { min-width: 3rem; }
26
- .min-w-16 { min-width: 4rem; }
27
- .min-w-20 { min-width: 5rem; }
28
- .min-w-24 { min-width: 6rem; }
29
- .min-w-32 { min-width: 8rem; }
30
17
 
31
18
  /* Max-width utilities */
32
- .max-w-xs { max-width: 20rem; }
33
- .max-w-sm { max-width: 24rem; }
34
- .max-w-md { max-width: 28rem; }
35
19
  .max-w-lg { max-width: 32rem; }
36
- .max-w-xl { max-width: 36rem; }
37
20
 
38
21
  /* Table layout utilities */
39
22
  .table-fixed { table-layout: fixed; }
@@ -53,7 +53,7 @@ module ChartTableConcern
53
53
  end
54
54
 
55
55
  def setup_chart_and_table_data
56
- ransack_params = params[:q] || {}
56
+ ransack_params = self.ransack_params
57
57
 
58
58
  unless partial_request?
59
59
  # Setup chart data first using original time range (no sorting from table)
@@ -172,11 +172,18 @@ module ChartTableConcern
172
172
  action_name == "show"
173
173
  end
174
174
 
175
+ # Hook: override to return param keys that should be excluded from chart queries.
176
+ # Use this when a filter requires a JOIN that chart queries don't perform
177
+ # (e.g. route path/action filters that need rails_pulse_routes joined).
178
+ def chart_filter_exclusions
179
+ []
180
+ end
181
+
175
182
  # Builds ransack parameters for chart queries
176
183
  # Common pattern: time range + optional duration filter + resource scope
177
184
  # Handles "recent" mode where @page_timings.start_time/@end_time may be nil
178
185
  def build_chart_ransack_params(ransack_params)
179
- base_params = ransack_params.except(:s)
186
+ base_params = ransack_params.except(:s, *chart_filter_exclusions)
180
187
 
181
188
  # Add time filters if we have time boundaries (not in "recent" mode)
182
189
  if @page_timings&.start_time && @page_timings&.end_time
@@ -196,7 +203,7 @@ module ChartTableConcern
196
203
 
197
204
  # Scope to specific resource on show pages
198
205
  if show_action?
199
- base_params.merge(summarizable_id_eq: current_resource.id)
206
+ base_params.merge(resource_id_scope)
200
207
  else
201
208
  base_params
202
209
  end
@@ -308,4 +315,9 @@ module ChartTableConcern
308
315
  def current_resource
309
316
  nil
310
317
  end
318
+
319
+ # Ransack scope for chart summaries on show pages. Override to customize (e.g. IN queries).
320
+ def resource_id_scope
321
+ { summarizable_id_eq: current_resource.id }
322
+ end
311
323
  end
@@ -0,0 +1,18 @@
1
+ # RansackParamsConcern
2
+ #
3
+ # Every filterable page reads its Ransack conditions from params[:q]. The
4
+ # dashboard's own forms always send a hash, but a hand-edited URL can make it
5
+ # a string or an array (`?q=foo`), and each caller would then invoke Hash
6
+ # methods on it and 500. Normalise once, here.
7
+ module RansackParamsConcern
8
+ extend ActiveSupport::Concern
9
+
10
+ private
11
+
12
+ # params[:q] as an ActionController::Parameters hash, or an empty one when
13
+ # the value is absent or not hash-shaped.
14
+ def ransack_params
15
+ q = params[:q]
16
+ q.respond_to?(:permit) ? q : ActionController::Parameters.new({})
17
+ end
18
+ end
@@ -5,9 +5,9 @@
5
5
  # Supports both page-specific duration filters and global performance threshold filters.
6
6
  module ResponseRangeConcern
7
7
  extend ActiveSupport::Concern
8
+ include RansackParamsConcern
8
9
 
9
10
  def setup_duration_range(type = :route)
10
- ransack_params = params[:q] || {}
11
11
  thresholds = RailsPulse.configuration.public_send("#{type}_thresholds")
12
12
 
13
13
  # Check all duration-related parameters
@@ -11,6 +11,7 @@
11
11
  # Normalizes times to beginning/end of hour or day based on range duration.
12
12
  module TimeRangeConcern
13
13
  extend ActiveSupport::Concern
14
+ include RansackParamsConcern
14
15
 
15
16
  included do
16
17
  # Define the constant in the including class - ordered by most common usage
@@ -40,8 +41,6 @@ module TimeRangeConcern
40
41
  end_time = Time.zone.now
41
42
  selected_time_range = default_key
42
43
 
43
- ransack_params = params[:q] || {}
44
-
45
44
  # Priority 1: Page-specific preset from dropdown (check this first!)
46
45
  if ransack_params[:period_start_range].present? && ransack_params[:period_start_range].to_sym != :custom
47
46
  # Predefined time range from dropdown
@@ -59,22 +58,30 @@ module TimeRangeConcern
59
58
  elsif ransack_params[:period_start_range].present? && ransack_params[:period_start_range].to_sym == :custom && ransack_params[:custom_date_range].present? && ransack_params[:custom_date_range].include?(" to ")
60
59
  # Custom datetime range from custom range picker
61
60
  dates = ransack_params[:custom_date_range].split(" to ")
62
- start_time = parse_time_param(dates[0].strip)
63
- end_time = parse_time_param(dates[1].strip)
64
- selected_time_range = :custom
61
+ custom_start = parse_time_param(dates[0].strip)
62
+ custom_end = parse_time_param(dates[1].strip)
63
+ if custom_start && custom_end
64
+ start_time = custom_start
65
+ end_time = custom_end
66
+ selected_time_range = :custom
67
+ end
65
68
  # Priority 3: Page-specific filters (chart zoom)
66
69
  elsif ransack_params[:occurred_at_gteq].present? && ransack_params[:occurred_at_lt].present?
67
70
  # Custom time range from chart zoom
68
- start_time = parse_time_param(ransack_params[:occurred_at_gteq])
69
- end_time = parse_time_param(ransack_params[:occurred_at_lt])
70
- selected_time_range = :custom
71
+ zoom_start = parse_time_param(ransack_params[:occurred_at_gteq])
72
+ zoom_end = parse_time_param(ransack_params[:occurred_at_lt])
73
+ if zoom_start && zoom_end
74
+ start_time = zoom_start
75
+ end_time = zoom_end
76
+ selected_time_range = :custom
77
+ end
71
78
  # Priority 4: Time range selector (from session)
72
79
  elsif session[:time_range_preference].present?
73
80
  preference = session[:time_range_preference]
74
81
  if preference.is_a?(Hash) && preference["type"] == "custom"
75
82
  # Custom range from time range selector
76
- start_time = parse_time_param(preference["start_time"])
77
- end_time = parse_time_param(preference["end_time"])
83
+ start_time = parse_time_param(preference["start_time"]) || start_time
84
+ end_time = parse_time_param(preference["end_time"]) || end_time
78
85
  selected_time_range = :custom
79
86
  else
80
87
  # Preset from time range selector
@@ -90,8 +97,8 @@ module TimeRangeConcern
90
97
  end
91
98
  # Priority 5: Global filters (from session)
92
99
  elsif session_global_filters["start_time"].present? || session_global_filters["end_time"].present?
93
- start_time = parse_time_param(session_global_filters["start_time"]) if session_global_filters["start_time"].present?
94
- end_time = parse_time_param(session_global_filters["end_time"]) if session_global_filters["end_time"].present?
100
+ start_time = parse_time_param(session_global_filters["start_time"]) || start_time
101
+ end_time = parse_time_param(session_global_filters["end_time"]) || end_time
95
102
  selected_time_range = :custom
96
103
  end
97
104
  # Priority 6: Default time range (already set above)
@@ -112,17 +119,24 @@ module TimeRangeConcern
112
119
 
113
120
  private
114
121
 
122
+ # Returns nil for anything that does not parse, so a hand-edited or stale
123
+ # value falls back to the default range instead of raising.
115
124
  def parse_time_param(param)
116
125
  case param
117
126
  when Time, DateTime
118
127
  param.in_time_zone
119
128
  when String
129
+ return nil if param.blank?
130
+
120
131
  # Parse as server local time (not UTC, not Time.zone)
121
132
  # This ensures flatpickr datetime strings are interpreted in server's timezone
122
133
  Time.parse(param).localtime
134
+ when Numeric
135
+ Time.zone.at(param)
123
136
  else
124
- # Assume it's an integer timestamp
125
- Time.zone.at(param.to_i)
137
+ nil
126
138
  end
139
+ rescue ArgumentError, TypeError, RangeError
140
+ nil
127
141
  end
128
142
  end
@@ -2,11 +2,30 @@ module RailsPulse
2
2
  class ApplicationController < ActionController::Base
3
3
  include PaginationConcern
4
4
  include SessionFiltersConcern
5
+ include RansackParamsConcern
6
+
7
+ # Declared here rather than inherited. ActionController::Base only gets
8
+ # protect_from_forgery from the host's `default_protect_from_forgery`,
9
+ # which load_defaults 5.2+ turns on — a host still on an older
10
+ # load_defaults (or one that set the flag false and calls
11
+ # protect_from_forgery in its own ApplicationController) would leave every
12
+ # state-changing engine endpoint forgeable. Idempotent when the host
13
+ # already has it; the deployments API and assets controller keep their
14
+ # explicit skips.
15
+ protect_from_forgery with: :exception
5
16
 
6
17
  before_action :authenticate_rails_pulse_user!
7
18
  before_action :set_onboarding_state
8
19
  before_action :load_deployment_markers
9
20
 
21
+ # Values accepted into the cookie session. Everything else is dropped:
22
+ # the session has a 4 KB ceiling and a stored value is re-parsed on every
23
+ # later request, so an unbounded or unparseable string would 500 the
24
+ # dashboard until the cookie is cleared.
25
+ TIME_RANGE_PRESETS = %w[last_24_hours last_7_days last_14_days last_30_days].freeze
26
+ PERFORMANCE_THRESHOLDS = %w[slow very_slow critical].freeze
27
+ MAX_TIME_PARAM_LENGTH = 64
28
+
10
29
  def set_global_filters
11
30
  if params[:clear] == "true"
12
31
  session.delete(:global_filters)
@@ -15,21 +34,24 @@ module RailsPulse
15
34
  filters = session[:global_filters] || {}
16
35
 
17
36
  # Update time filters if provided
18
- if params[:start_time].present? && params[:end_time].present?
19
- filters["start_time"] = params[:start_time]
20
- filters["end_time"] = params[:end_time]
37
+ start_time = valid_time_param(params[:start_time])
38
+ end_time = valid_time_param(params[:end_time])
39
+ if start_time && end_time
40
+ filters["start_time"] = start_time
41
+ filters["end_time"] = end_time
21
42
  end
22
43
 
23
- # Update performance threshold if provided (or remove if empty)
24
- if params[:performance_threshold].present?
25
- filters["performance_threshold"] = params[:performance_threshold]
44
+ # Update performance threshold if provided (or remove if empty/unknown)
45
+ threshold = params[:performance_threshold].to_s
46
+ if PERFORMANCE_THRESHOLDS.include?(threshold)
47
+ filters["performance_threshold"] = threshold
26
48
  else
27
49
  filters.delete("performance_threshold")
28
50
  end
29
51
 
30
52
  # Update tag visibility - convert enabled tags to disabled tags
31
53
  all_tags = RailsPulse.configuration.tags
32
- enabled_tags = params[:enabled_tags] || []
54
+ enabled_tags = Array(params[:enabled_tags]).map(&:to_s)
33
55
 
34
56
  # Handle "non_tagged" separately
35
57
  session[:show_non_tagged] = enabled_tags.include?("non_tagged")
@@ -50,28 +72,45 @@ module RailsPulse
50
72
  end
51
73
 
52
74
  # Redirect back to the referring page or root
53
- redirect_back(fallback_location: root_path)
75
+ redirect_back(fallback_location: root_path, allow_other_host: false)
54
76
  end
55
77
 
56
78
  def set_time_range
57
- if params[:preset].present?
79
+ preset = params[:preset].to_s
80
+ start_time = valid_time_param(params[:start_time])
81
+ end_time = valid_time_param(params[:end_time])
82
+
83
+ if preset.present?
58
84
  # Store preset selection
59
- session[:time_range_preference] = params[:preset]
60
- elsif params[:start_time].present? && params[:end_time].present?
85
+ session[:time_range_preference] = preset if TIME_RANGE_PRESETS.include?(preset)
86
+ elsif start_time && end_time
61
87
  # Store custom range
62
88
  session[:time_range_preference] = {
63
89
  type: "custom",
64
- start_time: params[:start_time],
65
- end_time: params[:end_time]
90
+ start_time: start_time,
91
+ end_time: end_time
66
92
  }
67
93
  end
68
94
 
69
95
  # Redirect back to the referring page or root
70
- redirect_back(fallback_location: root_path)
96
+ redirect_back(fallback_location: root_path, allow_other_host: false)
71
97
  end
72
98
 
73
99
  private
74
100
 
101
+ # The original string if it is short enough to live in the session and
102
+ # Time.parse accepts it (TimeRangeConcern parses it back the same way);
103
+ # nil otherwise.
104
+ def valid_time_param(value)
105
+ value = value.to_s.strip
106
+ return nil if value.blank? || value.length > MAX_TIME_PARAM_LENGTH
107
+
108
+ Time.parse(value)
109
+ value
110
+ rescue ArgumentError, TypeError
111
+ nil
112
+ end
113
+
75
114
  def partial_request?
76
115
  request.headers["X-Partial-Request"] == "true"
77
116
  end
@@ -80,33 +119,65 @@ module RailsPulse
80
119
  RailsPulse.logger
81
120
  end
82
121
 
122
+ # Two hooks, run in order:
123
+ #
124
+ # * `authentication_method` — the legacy hook. It denies by rendering or
125
+ # redirecting (the documented `unless signed_in? then redirect_to`
126
+ # style). Returning literal `false` without responding is also a
127
+ # denial; returning nil (which `unless … end` does on success) allows.
128
+ # * `authorize` — a fail-closed predicate. Anything falsy is a 403.
129
+ #
130
+ # With neither configured, HTTP Basic against RAILS_PULSE_PASSWORD.
83
131
  def authenticate_rails_pulse_user!
84
- return unless RailsPulse.configuration.authentication_enabled
132
+ config = RailsPulse.configuration
133
+ return unless config.authentication_enabled
85
134
 
86
- # If no authentication method is configured, use fallback HTTP Basic Auth
87
- if RailsPulse.configuration.authentication_method.nil?
135
+ if config.authentication_method.nil? && config.authorize.nil?
88
136
  return fallback_http_basic_auth
89
137
  end
90
138
 
91
- # Safely execute authentication method in controller context
92
- case RailsPulse.configuration.authentication_method
139
+ unless config.authentication_method.nil?
140
+ run_authentication_method(config.authentication_method)
141
+ return if performed?
142
+ end
143
+
144
+ run_authorize_predicate(config.authorize) if config.authorize
145
+ rescue StandardError => e
146
+ logger.warn "RailsPulse authentication failed: #{e.message}"
147
+ redirect_to config.authentication_redirect_path
148
+ end
149
+
150
+ def run_authentication_method(hook)
151
+ result = case hook
93
152
  when Proc
94
- instance_exec(&RailsPulse.configuration.authentication_method)
153
+ instance_exec(&hook)
95
154
  when Symbol, String
96
- method_name = RailsPulse.configuration.authentication_method.to_s
97
- if respond_to?(method_name, true)
98
- send(method_name)
99
- else
155
+ method_name = hook.to_s
156
+ unless respond_to?(method_name, true)
100
157
  logger.error "RailsPulse: Authentication method '#{method_name}' not found"
101
- render plain: "Authentication configuration error", status: :internal_server_error
158
+ return render plain: "Authentication configuration error", status: :internal_server_error
102
159
  end
160
+ send(method_name)
103
161
  else
104
- logger.error "RailsPulse: Invalid authentication method type: #{RailsPulse.configuration.authentication_method.class}"
105
- render plain: "Authentication configuration error", status: :internal_server_error
162
+ logger.error "RailsPulse: Invalid authentication method type: #{hook.class}"
163
+ return render plain: "Authentication configuration error", status: :internal_server_error
106
164
  end
107
- rescue StandardError => e
108
- logger.warn "RailsPulse authentication failed: #{e.message}"
109
- redirect_to RailsPulse.configuration.authentication_redirect_path
165
+
166
+ return if performed?
167
+ return unless result == false
168
+
169
+ # `proc { current_user&.admin? }` reads as a predicate but never halts
170
+ # the chain. Treat an explicit false as the denial it was meant to be.
171
+ logger.warn "RailsPulse: authentication_method returned false without rendering or redirecting; " \
172
+ "denying access. Use config.authorize for predicate-style checks."
173
+ render plain: "Forbidden", status: :forbidden
174
+ end
175
+
176
+ def run_authorize_predicate(predicate)
177
+ allowed = predicate.arity.zero? ? instance_exec(&predicate) : predicate.call(self)
178
+ return if allowed
179
+
180
+ render plain: "Forbidden", status: :forbidden
110
181
  end
111
182
 
112
183
  def fallback_http_basic_auth
@@ -119,7 +190,8 @@ module RailsPulse
119
190
  logger.error "RailsPulse: No authentication method configured and RAILS_PULSE_PASSWORD not set. Access denied."
120
191
  false
121
192
  else
122
- username == expected_username && password == expected_password
193
+ ActiveSupport::SecurityUtils.secure_compare(username.to_s, expected_username) &&
194
+ ActiveSupport::SecurityUtils.secure_compare(password.to_s, expected_password)
123
195
  end
124
196
  end
125
197
  end
@@ -134,6 +206,7 @@ module RailsPulse
134
206
  last_summary_at = RailsPulse::Summary.maximum(:updated_at)
135
207
  @has_summaries = last_summary_at.present?
136
208
  @summaries_stale = RailsPulse.configuration.warn_on_stale_summaries && @has_summaries && last_summary_at < 2.hours.ago
209
+ @route_backfill_pending = RailsPulse::Route.needs_action_backfill?
137
210
  end
138
211
  end
139
212
  end
@@ -33,6 +33,15 @@ module RailsPulse
33
33
 
34
34
  # System Health bar
35
35
  @health_summary = RailsPulse::Dashboard::HealthSummary.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period).to_health_data
36
+
37
+ @storage_status = RailsPulse::Dashboard::StorageStatus.new
38
+
39
+ # Deployments panel — scoped to the same window as the chart markers so
40
+ # the panel and the markers drawn on the charts always agree.
41
+ deployments_in_range = RailsPulse::Deployment.for_range(Time.zone.at(@start_time), Time.zone.at(@end_time))
42
+ @deployment_count = deployments_in_range.count
43
+ @recent_deployments = deployments_in_range.recent.limit(4)
44
+ @last_deployment = RailsPulse::Deployment.recent.first
36
45
  end
37
46
  end
38
47
  end
@@ -1,7 +1,22 @@
1
1
  module RailsPulse
2
2
  class DeploymentsController < ApplicationController
3
- skip_before_action :authenticate_rails_pulse_user!
4
- before_action :authenticate_deployment_request!
3
+ # The API actions authenticate by token so CI can post without a session;
4
+ # the browsable pages use the dashboard's own authentication like every
5
+ # other page. Scoping the skip to create/finish means adding a UI does not
6
+ # widen the hole the 0.4.0 audit closed.
7
+ skip_before_action :authenticate_rails_pulse_user!, only: %i[create finish]
8
+ skip_before_action :verify_authenticity_token, only: %i[create finish]
9
+ before_action :authenticate_deployment_request!, only: %i[create finish]
10
+
11
+ def index
12
+ @ransack_query = Deployment.ransack(ransack_params)
13
+ @ransack_query.sorts = "started_at desc" if @ransack_query.sorts.empty?
14
+ @pagination, @table_data = paginate(@ransack_query.result, limit: session_pagination_limit)
15
+ end
16
+
17
+ def show
18
+ @deployment = Deployment.find(params[:id])
19
+ end
5
20
 
6
21
  def create
7
22
  deployment = RailsPulse::Deployment.new(
@@ -44,10 +59,16 @@ module RailsPulse
44
59
  def authenticate_deployment_request!
45
60
  token = RailsPulse.configuration.deployment_api_token
46
61
  if token.present?
47
- provided = request.headers["X-Rails-Pulse-Token"]
48
- render json: { error: "Unauthorized" }, status: :unauthorized unless provided == token
49
- else
62
+ provided = request.headers["X-Rails-Pulse-Token"].to_s
63
+ unless ActiveSupport::SecurityUtils.secure_compare(provided, token)
64
+ render json: { error: "Unauthorized" }, status: :unauthorized
65
+ end
66
+ elsif RailsPulse.configuration.authentication_enabled
50
67
  authenticate_rails_pulse_user!
68
+ else
69
+ # No token configured and authentication disabled — fail closed.
70
+ # Without a token there is no way to verify the caller.
71
+ render json: { error: "Unauthorized — set deployment_api_token or enable authentication" }, status: :unauthorized
51
72
  end
52
73
  end
53
74
 
@@ -0,0 +1,19 @@
1
+ module RailsPulse
2
+ class ExceptionOccurrencesController < ApplicationController
3
+ before_action :set_exception_group
4
+ before_action :set_occurrence
5
+
6
+ def show
7
+ end
8
+
9
+ private
10
+
11
+ def set_exception_group
12
+ @exception_group = ExceptionGroup.find(params[:exception_id])
13
+ end
14
+
15
+ def set_occurrence
16
+ @occurrence = @exception_group.occurrences.find(params[:id])
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,119 @@
1
+ module RailsPulse
2
+ class ExceptionsController < ApplicationController
3
+ include TimeRangeConcern
4
+ include TagFilterConcern
5
+ include DeploymentMarkersConcern
6
+
7
+ before_action :set_exception_group, only: %i[show update]
8
+
9
+ def index
10
+ ransack_params = self.ransack_params.dup
11
+ apply_default_status_filter!(ransack_params)
12
+
13
+ @ransack_query = ExceptionGroup.ransack(ransack_params)
14
+ @ransack_query.sorts = "last_seen_at desc" if @ransack_query.sorts.empty?
15
+ @pagination, @table_data = paginate(@ransack_query.result, limit: session_pagination_limit)
16
+
17
+ setup_frequency_view
18
+ end
19
+
20
+ def show
21
+ occurrences = @exception_group.occurrences.order(occurred_at: :desc)
22
+ @latest_occurrence = occurrences.first
23
+ @pagination, @occurrences = paginate(occurrences, limit: session_pagination_limit)
24
+ end
25
+
26
+ def update
27
+ if params.key?(:preserve)
28
+ update_preserve
29
+ else
30
+ update_status
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # The index is a list of groups, not an aggregate table, so it does not use
37
+ # ChartTableConcern. It still needs a time range and a frequency series:
38
+ # without them the page can say what is failing but not whether it is
39
+ # getting worse, which is the question users actually arrive with.
40
+ def setup_frequency_view
41
+ # Timestamps, not Times, to match every other index controller — the
42
+ # shared concerns and helpers that read @start_time expect integers.
43
+ @start_time, @end_time, @selected_time_range, @time_diff_hours = setup_time_range
44
+
45
+ @period_type = @time_diff_hours <= 25 ? "hour" : "day"
46
+ period_days = window_days_for(@selected_time_range)
47
+
48
+ populate_deployment_markers
49
+
50
+ @occurrence_volume_chart = Exceptions::Charts::OccurrenceVolume.new(
51
+ start_time: Time.zone.at(@start_time),
52
+ end_time: Time.zone.at(@end_time),
53
+ period_type: @period_type
54
+ ).to_chart_data
55
+
56
+ card_params = { period: period_days, period_type: @period_type }
57
+
58
+ @total_occurrences_metric_card = Exceptions::Cards::TotalOccurrences.new(**card_params).to_metric_card
59
+ @exception_rate_metric_card = Exceptions::Cards::ExceptionRate.new(**card_params).to_metric_card
60
+ @open_groups_metric_card = Exceptions::Cards::OpenGroups.new(**card_params).to_metric_card
61
+ end
62
+
63
+ def set_exception_group
64
+ @exception_group = ExceptionGroup.find(params[:id])
65
+ end
66
+
67
+ # The card copy ("compared to previous N days") has to name the range the
68
+ # user picked. Measuring the span instead reads one day long, because the
69
+ # range runs from the start of the first day to the end of the last.
70
+ def window_days_for(selected_range)
71
+ case selected_range.to_s
72
+ when "last_24_hours" then 1
73
+ when "last_7_days" then 7
74
+ when "last_14_days" then 14
75
+ when "last_30_days" then 30
76
+ else [ ((@end_time - @start_time) / 1.day.to_i).round, 1 ].max
77
+ end
78
+ end
79
+
80
+ # Exceptions are low-volume compared with requests, so a day is rarely
81
+ # enough to see a trend. Matches the jobs and queries defaults.
82
+ def default_time_range_key
83
+ :last_7_days
84
+ end
85
+
86
+ # Default the list to open groups. An explicit blank status ("All")
87
+ # clears the filter; any other value is passed through to ransack.
88
+ def apply_default_status_filter!(ransack_params)
89
+ if !ransack_params.key?(:status_eq) && !ransack_params.key?("status_eq")
90
+ ransack_params[:status_eq] = "open"
91
+ elsif ransack_params[:status_eq].blank? && ransack_params["status_eq"].blank?
92
+ ransack_params.delete(:status_eq)
93
+ ransack_params.delete("status_eq")
94
+ end
95
+ end
96
+
97
+ def update_preserve
98
+ @exception_group.update!(preserve: ActiveModel::Type::Boolean.new.cast(params[:preserve]))
99
+ flash[:notice] = @exception_group.preserve? ? "Exception preserved from cleanup." : "Exception no longer preserved."
100
+ redirect_to exception_path(@exception_group)
101
+ end
102
+
103
+ def update_status
104
+ new_status = params[:status]
105
+ unless ExceptionGroup::STATUSES.include?(new_status)
106
+ head :unprocessable_entity
107
+ return
108
+ end
109
+
110
+ @exception_group.update!(
111
+ status: new_status,
112
+ resolved_at: new_status == "resolved" ? Time.current : nil
113
+ )
114
+
115
+ flash[:notice] = "Exception marked as #{new_status}."
116
+ redirect_to exception_path(@exception_group)
117
+ end
118
+ end
119
+ end
@@ -6,7 +6,7 @@ module RailsPulse
6
6
  before_action :set_run, only: :show
7
7
 
8
8
  def index
9
- @ransack_query = @job.runs.ransack(params[:q])
9
+ @ransack_query = @job.runs.ransack(ransack_params)
10
10
  @pagination, @runs = paginate(@ransack_query.result.order(occurred_at: :desc), limit: session_pagination_limit)
11
11
  @table_data = @runs
12
12
  end