rails_pulse 0.3.3 → 0.4.0.pre.2

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 (169) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +299 -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/schema_parser.rb +22 -2
  127. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +54 -6
  128. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +1 -1
  129. data/lib/generators/rails_pulse/templates/rails_pulse.rb +157 -16
  130. data/lib/generators/rails_pulse/upgrade_generator.rb +166 -38
  131. data/lib/rails_pulse/cleanup_service.rb +110 -15
  132. data/lib/rails_pulse/configuration.rb +125 -6
  133. data/lib/rails_pulse/engine.rb +32 -17
  134. data/lib/rails_pulse/installers/config_updater.rb +202 -0
  135. data/lib/rails_pulse/job_run_collector.rb +43 -16
  136. data/lib/rails_pulse/middleware/asset_server.rb +11 -0
  137. data/lib/rails_pulse/middleware/request_collector.rb +9 -4
  138. data/lib/rails_pulse/packaged_assets.rb +147 -0
  139. data/lib/rails_pulse/route_indexes.rb +66 -0
  140. data/lib/rails_pulse/subscribers/exception_subscriber.rb +36 -0
  141. data/lib/rails_pulse/tracker.rb +29 -10
  142. data/lib/rails_pulse/version.rb +1 -1
  143. data/lib/rails_pulse.rb +1 -0
  144. data/lib/rails_pulse_server.ru +16 -47
  145. data/lib/tasks/rails_pulse.rake +83 -4
  146. data/lib/tasks/rails_pulse_assets.rake +15 -0
  147. data/lib/tasks/rails_pulse_tasks.rake +29 -0
  148. data/public/rails-pulse-assets/rails-pulse-icons.js +6 -2
  149. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  150. data/public/rails-pulse-assets/rails-pulse.js +18 -117
  151. metadata +78 -37
  152. data/Rakefile +0 -554
  153. data/app/javascript/rails_pulse/controllers/form_controller.js +0 -39
  154. data/app/javascript/rails_pulse/controllers/period_selector_controller.js +0 -19
  155. data/app/javascript/rails_pulse/controllers/timezone_controller.js +0 -89
  156. data/app/javascript/rails_pulse/theme.js +0 -416
  157. data/config/brakeman.ignore +0 -190
  158. data/config/brakeman.yml +0 -68
  159. data/config/initializers/rails_pulse.rb +0 -287
  160. data/public/rails-pulse-assets/csp-test.js +0 -120
  161. data/public/rails-pulse-assets/rails-pulse-icons.js.map +0 -13
  162. data/public/rails-pulse-assets/rails-pulse.css.map +0 -1
  163. data/public/rails-pulse-assets/rails-pulse.js.map +0 -7
  164. data/vendor/assets/javascripts/rails-pulse-icons.js +0 -93
  165. data/vendor/assets/javascripts/rails-pulse-icons.js.map +0 -13
  166. data/vendor/assets/javascripts/rails-pulse.js +0 -157
  167. data/vendor/assets/javascripts/rails-pulse.js.map +0 -7
  168. data/vendor/assets/stylesheets/rails-pulse.css +0 -1
  169. data/vendor/assets/stylesheets/rails-pulse.css.map +0 -1
@@ -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
@@ -109,7 +109,7 @@ module RailsPulse
109
109
 
110
110
  # If filtering or sorting by route_path, we need to join the routes table
111
111
  needs_join = @ransack_query.sorts.any? { |sort| sort.name == "route_path" } ||
112
- params.dig(:q, :route_path_cont).present?
112
+ ransack_params[:route_path_cont].present?
113
113
 
114
114
  if needs_join
115
115
  base_query = base_query.joins(:route)
@@ -54,7 +54,6 @@ module RailsPulse
54
54
  show_action? ? { route: @route } : {}
55
55
  end
56
56
 
57
- # Filter to scope table results to a specific route on show pages
58
57
  def show_resource_filter
59
58
  { route_id_eq: @route.id }
60
59
  end
@@ -92,6 +91,8 @@ module RailsPulse
92
91
 
93
92
  # For PostgreSQL compatibility with DISTINCT + ORDER BY
94
93
  # we need to include computed columns in SELECT when ordering by them
94
+ base_query = base_query.joins(:route).preload(:route)
95
+
95
96
  if ordering_by_computed_column?
96
97
  base_query.select("rails_pulse_requests.*, #{status_indicator_sql} as status_indicator_value").distinct
97
98
  else
@@ -109,6 +110,11 @@ module RailsPulse
109
110
  end
110
111
  end
111
112
 
113
+ # Request/route-name filters that Summary chart queries don't join for.
114
+ def chart_filter_exclusions
115
+ %w[route_path_cont route_controller_action_cont controller_action_cont http_method_eq]
116
+ end
117
+
112
118
  def default_time_range_key
113
119
  :last_14_days
114
120
  end
@@ -0,0 +1,7 @@
1
+ module RailsPulse
2
+ class StorageController < ApplicationController
3
+ def show
4
+ @status = RailsPulse::Dashboard::StorageStatus.new
5
+ end
6
+ end
7
+ end
@@ -19,7 +19,7 @@ module RailsPulse
19
19
  flash[:alert] = "Failed to add tag"
20
20
  end
21
21
 
22
- redirect_back(fallback_location: root_path)
22
+ redirect_back(fallback_location: root_path, allow_other_host: false)
23
23
  end
24
24
 
25
25
  def destroy
@@ -30,7 +30,7 @@ module RailsPulse
30
30
  flash[:alert] = "Failed to remove tag"
31
31
  end
32
32
 
33
- redirect_back(fallback_location: root_path)
33
+ redirect_back(fallback_location: root_path, allow_other_host: false)
34
34
  end
35
35
 
36
36
  private
@@ -1,5 +1,6 @@
1
1
  module RailsPulse
2
2
  module ApplicationHelper
3
+ include BacktraceHelper
3
4
  include BreadcrumbsHelper
4
5
  include ChartHelper
5
6
  include CspHelper
@@ -0,0 +1,85 @@
1
+ module RailsPulse
2
+ module BacktraceHelper
3
+ APP_FRAME_PATTERN = %r{/app/|/config/|/lib/}
4
+ GEM_FRAME_PATTERN = %r{/gems/|/rubygems/|/bundler/|/lib/ruby/}
5
+
6
+ def app_frame?(frame)
7
+ file = frame["file"].to_s
8
+ file.match?(APP_FRAME_PATTERN) && !file.match?(GEM_FRAME_PATTERN)
9
+ end
10
+
11
+ # Strip everything before /app/, /lib/, /config/ so paths are relative.
12
+ # Falls back to gem-name/rest for gem frames, or just basename for stdlib.
13
+ def frame_display_path(frame)
14
+ file = frame["file"].to_s
15
+ # Gem frames: /…/gems/[ruby-ver]/gems/[gem-name]/lib/… → gem-name/lib/…
16
+ if (match = file.match(%r{/gems/[^/]+/gems/([^/]+)/(.+)}))
17
+ "#{match[1]}/#{match[2]}"
18
+ # Bundler path gems: /…/gems/[gem-name]/lib/… → gem-name/lib/…
19
+ elsif (match = file.match(%r{/gems/([^/]+)/(.+)}))
20
+ "#{match[1]}/#{match[2]}"
21
+ # App frames: strip absolute prefix, keep /app/…, /lib/…, /config/…
22
+ elsif (match = file.match(%r{(/(?:app|lib|config)/.+)}))
23
+ match[1]
24
+ else
25
+ File.basename(file)
26
+ end
27
+ end
28
+
29
+ def frame_dirname(frame)
30
+ File.dirname(frame_display_path(frame)) + "/"
31
+ end
32
+
33
+ def frame_basename(frame)
34
+ File.basename(frame_display_path(frame))
35
+ end
36
+
37
+ # Directories (relative to Rails.root) whose source may be shown, and the
38
+ # file types within them. Everything else under Rails.root — initializers
39
+ # with inline keys, credentials, database.yml, .env, tmp/ — stays
40
+ # unreadable even when a backtrace frame points at it.
41
+ SOURCE_ALLOWED_DIRECTORIES = %w[app/ lib/].freeze
42
+ SOURCE_ALLOWED_FILES = %w[config/routes.rb].freeze
43
+ SOURCE_ALLOWED_EXTENSIONS = %w[.rb .erb .rake .haml .slim .jbuilder].freeze
44
+
45
+ def frame_source_lines(frame, radius: 3)
46
+ file = frame["file"].to_s
47
+ line = frame["line"].to_i
48
+ return nil if file.blank? || line < 1
49
+ return nil unless File.exist?(file) && File.file?(file)
50
+ return nil unless source_readable?(file)
51
+
52
+ first_line = [ line - radius, 1 ].max
53
+ last_line = line + radius
54
+
55
+ lines = {}
56
+ File.foreach(file).with_index(1) do |content, lineno|
57
+ break if lineno > last_line
58
+ lines[lineno] = content.rstrip if lineno >= first_line
59
+ end
60
+ lines
61
+ rescue Errno::EACCES, Errno::ENOENT
62
+ nil
63
+ end
64
+
65
+ private
66
+
67
+ # Only read source for code files under Rails.root's app/ and lib/ (plus
68
+ # config/routes.rb). Resolved through realpath so symlinks cannot point
69
+ # outside, and matched on the relative path so a directory merely named
70
+ # "app" elsewhere on disk does not qualify.
71
+ def source_readable?(file)
72
+ real = File.realpath(file)
73
+ root = "#{Rails.root.realpath}#{File::SEPARATOR}"
74
+ return false unless real.start_with?(root)
75
+
76
+ relative = real.delete_prefix(root)
77
+ return false unless SOURCE_ALLOWED_EXTENSIONS.include?(File.extname(relative))
78
+
79
+ SOURCE_ALLOWED_FILES.include?(relative) ||
80
+ SOURCE_ALLOWED_DIRECTORIES.any? { |dir| relative.start_with?(dir) }
81
+ rescue Errno::ENOENT
82
+ false
83
+ end
84
+ end
85
+ end
@@ -3,7 +3,9 @@ module RailsPulse
3
3
  # Maps singularized path segments to their actual model class names
4
4
  # when the default classify convention doesn't match
5
5
  SEGMENT_CLASS_OVERRIDES = {
6
- "run" => "JobRun"
6
+ "run" => "JobRun",
7
+ "exception" => "ExceptionGroup",
8
+ "occurrence" => "ExceptionOccurrence"
7
9
  }.freeze
8
10
 
9
11
  def breadcrumbs
@@ -88,6 +90,7 @@ module RailsPulse
88
90
 
89
91
  crumbs
90
92
  end
93
+
91
94
  def page_title
92
95
  crumbs = breadcrumbs
93
96
  return "Rails Pulse" if crumbs.empty?
@@ -43,9 +43,6 @@ module RailsPulse
43
43
  axisPointer: { type: "shadow" },
44
44
  formatter: tooltip_formatter
45
45
  },
46
- toolbox: {
47
- feature: { saveAsImage: { show: false } }
48
- },
49
46
  xAxis: {
50
47
  axisLine: { show: false },
51
48
  axisTick: { show: false },
@@ -25,24 +25,16 @@ module RailsPulse
25
25
  RailsPulse::Engine.routes.url_helpers.respond_to?(method, include_private) || super
26
26
  end
27
27
 
28
- # Generate asset paths using Rails asset pipeline
29
- # This allows assets to work with CDN, digests, and precompiled manifests
28
+ # After `assets:precompile`, files live in public/assets with a digest
29
+ # so config.asset_host / CDN-only CSP work. Until then (development,
30
+ # or hosts with no pipeline) serve from the gem via AssetServer.
31
+ # Always pass the result to tag.link / tag.script, not stylesheet_link_tag:
32
+ # a middleware path must not be rewritten onto the CDN.
30
33
  def asset_path(asset_name)
31
- # Only use asset pipeline if we have Sprockets/Propshaft configured
32
- # Otherwise fall back to middleware serving
33
- if defined?(::Sprockets) || defined?(::Propshaft)
34
- # Use the main application's asset_path helper to ensure we get
35
- # the correct digested paths from the precompiled manifest
36
- begin
37
- ActionController::Base.helpers.asset_path(asset_name)
38
- rescue => e
39
- # Fallback to direct path if asset pipeline is not available
40
- Rails.logger.warn "[Rails Pulse] Asset pipeline error for #{asset_name}: #{e.message}"
41
- "/rails-pulse-assets/#{asset_name}"
42
- end
34
+ if (packaged = RailsPulse::PackagedAssets.url_path(asset_name))
35
+ ActionController::Base.helpers.asset_path(packaged)
43
36
  else
44
- # No asset pipeline - use middleware serving
45
- "/rails-pulse-assets/#{asset_name}"
37
+ "/rails-pulse-assets/#{RailsPulse::VERSION}/#{asset_name}"
46
38
  end
47
39
  end
48
40
  end