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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +299 -0
- data/README.md +44 -3
- data/app/assets/stylesheets/rails_pulse/application.css +21 -32
- data/app/assets/stylesheets/rails_pulse/components/backtrace.css +238 -0
- data/app/assets/stylesheets/rails_pulse/components/badge.css +32 -0
- data/app/assets/stylesheets/rails_pulse/components/button.css +2 -36
- data/app/assets/stylesheets/rails_pulse/components/card.css +8 -11
- data/app/assets/stylesheets/rails_pulse/components/chart.css +16 -3
- data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +5 -39
- data/app/assets/stylesheets/rails_pulse/components/dashboard.css +0 -52
- data/app/assets/stylesheets/rails_pulse/components/deployments.css +39 -0
- data/app/assets/stylesheets/rails_pulse/components/dialog.css +0 -5
- data/app/assets/stylesheets/rails_pulse/components/input.css +0 -14
- data/app/assets/stylesheets/rails_pulse/components/layouts.css +0 -20
- data/app/assets/stylesheets/rails_pulse/components/menu.css +0 -16
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +0 -16
- data/app/assets/stylesheets/rails_pulse/components/storage.css +187 -0
- data/app/assets/stylesheets/rails_pulse/components/tags.css +0 -14
- data/app/assets/stylesheets/rails_pulse/components/time_range_selector.css +5 -5
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +0 -17
- data/app/controllers/concerns/chart_table_concern.rb +15 -3
- data/app/controllers/concerns/ransack_params_concern.rb +18 -0
- data/app/controllers/concerns/response_range_concern.rb +1 -1
- data/app/controllers/concerns/time_range_concern.rb +28 -14
- data/app/controllers/rails_pulse/application_controller.rb +104 -31
- data/app/controllers/rails_pulse/dashboard_controller.rb +9 -0
- data/app/controllers/rails_pulse/deployments_controller.rb +26 -5
- data/app/controllers/rails_pulse/exception_occurrences_controller.rb +19 -0
- data/app/controllers/rails_pulse/exceptions_controller.rb +119 -0
- data/app/controllers/rails_pulse/job_runs_controller.rb +1 -1
- data/app/controllers/rails_pulse/requests_controller.rb +1 -1
- data/app/controllers/rails_pulse/routes_controller.rb +7 -1
- data/app/controllers/rails_pulse/storage_controller.rb +7 -0
- data/app/controllers/rails_pulse/tags_controller.rb +2 -2
- data/app/helpers/rails_pulse/application_helper.rb +1 -0
- data/app/helpers/rails_pulse/backtrace_helper.rb +85 -0
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +4 -1
- data/app/helpers/rails_pulse/chart_helper.rb +0 -3
- data/app/helpers/rails_pulse/route_helper.rb +8 -16
- data/app/javascript/rails_pulse/application.js +29 -8
- data/app/javascript/rails_pulse/controllers/clipboard_controller.js +58 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +16 -10
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +6 -2
- data/app/javascript/rails_pulse/controllers/popover_controller.js +4 -0
- data/app/models/concerns/rails_pulse/taggable.rb +11 -6
- data/app/models/rails_pulse/cards/base.rb +53 -0
- data/app/models/rails_pulse/dashboard/health_summary.rb +52 -0
- data/app/models/rails_pulse/dashboard/needs_attention.rb +73 -1
- data/app/models/rails_pulse/dashboard/storage_status.rb +490 -0
- data/app/models/rails_pulse/deployment.rb +47 -1
- data/app/models/rails_pulse/exception_group.rb +37 -0
- data/app/models/rails_pulse/exception_occurrence.rb +27 -0
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +116 -0
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +49 -0
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +62 -0
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +56 -0
- data/app/models/rails_pulse/exceptions/raw_data.rb +216 -0
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -2
- data/app/models/rails_pulse/operation.rb +13 -4
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +8 -3
- data/app/models/rails_pulse/request.rb +5 -1
- data/app/models/rails_pulse/route.rb +71 -21
- data/app/models/rails_pulse/routes/cards/error_rates.rb +6 -3
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +8 -3
- data/app/models/rails_pulse/routes/tables/index.rb +8 -6
- data/app/models/rails_pulse/summary.rb +21 -10
- data/app/models/rails_pulse/tables/base.rb +4 -2
- data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +47 -12
- data/app/services/rails_pulse/exception_capture_service.rb +228 -0
- data/app/services/rails_pulse/exception_message_sanitizer.rb +152 -0
- data/app/services/rails_pulse/operations/change_point.rb +144 -0
- data/app/services/rails_pulse/operations/compare.rb +92 -0
- data/app/services/rails_pulse/operations/comparison.rb +126 -0
- data/app/services/rails_pulse/operations/metric.rb +63 -0
- data/app/services/rails_pulse/operations/series.rb +84 -0
- data/app/services/rails_pulse/operations/subject.rb +77 -0
- data/app/services/rails_pulse/route_controller_action_backfiller.rb +110 -0
- data/app/services/rails_pulse/route_merger.rb +99 -0
- data/app/services/rails_pulse/route_migrator.rb +139 -0
- data/app/services/rails_pulse/route_path_normalizer.rb +79 -0
- data/app/services/rails_pulse/route_recognizer.rb +47 -0
- data/app/services/rails_pulse/summary_service.rb +45 -0
- data/app/services/rails_pulse/tag_filter_service.rb +5 -3
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
- data/app/views/layouts/rails_pulse/_onboarding_banner.html.erb +8 -0
- data/app/views/layouts/rails_pulse/_setup_screen.html.erb +5 -5
- data/app/views/layouts/rails_pulse/application.html.erb +4 -4
- data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
- data/app/views/rails_pulse/components/_panel.html.erb +16 -6
- data/app/views/rails_pulse/components/_raw_data.html.erb +34 -0
- data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -8
- data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +64 -0
- data/app/views/rails_pulse/dashboard/_health_badge.html.erb +21 -10
- data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +67 -0
- data/app/views/rails_pulse/dashboard/index.html.erb +22 -1
- data/app/views/rails_pulse/deployments/_status_badge.html.erb +5 -0
- data/app/views/rails_pulse/deployments/_table.html.erb +29 -0
- data/app/views/rails_pulse/deployments/index.html.erb +15 -0
- data/app/views/rails_pulse/deployments/show.html.erb +62 -0
- data/app/views/rails_pulse/exception_occurrences/show.html.erb +45 -0
- data/app/views/rails_pulse/exceptions/_backtrace.html.erb +33 -0
- data/app/views/rails_pulse/exceptions/_metric_cards.html.erb +6 -0
- data/app/views/rails_pulse/exceptions/_occurrences_table.html.erb +23 -0
- data/app/views/rails_pulse/exceptions/_status_badge.html.erb +6 -0
- data/app/views/rails_pulse/exceptions/_table.html.erb +33 -0
- data/app/views/rails_pulse/exceptions/index.html.erb +66 -0
- data/app/views/rails_pulse/exceptions/show.html.erb +52 -0
- data/app/views/rails_pulse/queries/_raw_data.html.erb +1 -23
- data/app/views/rails_pulse/requests/_table.html.erb +1 -1
- data/app/views/rails_pulse/routes/_requests_table.html.erb +5 -0
- data/app/views/rails_pulse/routes/_table.html.erb +22 -8
- data/app/views/rails_pulse/routes/index.html.erb +5 -1
- data/app/views/rails_pulse/routes/show.html.erb +5 -0
- data/app/views/rails_pulse/storage/show.html.erb +123 -0
- data/config/routes.rb +13 -4
- data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +50 -0
- data/db/rails_pulse_migrate/20260507000001_add_actual_query_to_operations.rb +10 -5
- data/db/rails_pulse_migrate/20260610000001_add_controller_action_to_rails_pulse_routes.rb +8 -0
- data/db/rails_pulse_migrate/20260610000002_change_rails_pulse_routes_to_multi_verb_model.rb +185 -0
- data/db/rails_pulse_migrate/20260610000003_add_null_action_unique_index_to_routes.rb +32 -0
- data/db/rails_pulse_migrate/20260823000001_add_location_to_exception_groups.rb +12 -0
- data/db/rails_pulse_schema.rb +54 -6
- data/lib/generators/rails_pulse/base_methods.rb +2 -0
- data/lib/generators/rails_pulse/install_generator.rb +5 -0
- data/lib/generators/rails_pulse/schema_parser.rb +22 -2
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +54 -6
- data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +1 -1
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +157 -16
- data/lib/generators/rails_pulse/upgrade_generator.rb +166 -38
- data/lib/rails_pulse/cleanup_service.rb +110 -15
- data/lib/rails_pulse/configuration.rb +125 -6
- data/lib/rails_pulse/engine.rb +32 -17
- data/lib/rails_pulse/installers/config_updater.rb +202 -0
- data/lib/rails_pulse/job_run_collector.rb +43 -16
- data/lib/rails_pulse/middleware/asset_server.rb +11 -0
- data/lib/rails_pulse/middleware/request_collector.rb +9 -4
- data/lib/rails_pulse/packaged_assets.rb +147 -0
- data/lib/rails_pulse/route_indexes.rb +66 -0
- data/lib/rails_pulse/subscribers/exception_subscriber.rb +36 -0
- data/lib/rails_pulse/tracker.rb +29 -10
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +1 -0
- data/lib/rails_pulse_server.ru +16 -47
- data/lib/tasks/rails_pulse.rake +83 -4
- data/lib/tasks/rails_pulse_assets.rake +15 -0
- data/lib/tasks/rails_pulse_tasks.rake +29 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js +6 -2
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +18 -117
- metadata +78 -37
- data/Rakefile +0 -554
- data/app/javascript/rails_pulse/controllers/form_controller.js +0 -39
- data/app/javascript/rails_pulse/controllers/period_selector_controller.js +0 -19
- data/app/javascript/rails_pulse/controllers/timezone_controller.js +0 -89
- data/app/javascript/rails_pulse/theme.js +0 -416
- data/config/brakeman.ignore +0 -190
- data/config/brakeman.yml +0 -68
- data/config/initializers/rails_pulse.rb +0 -287
- data/public/rails-pulse-assets/csp-test.js +0 -120
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +0 -13
- data/public/rails-pulse-assets/rails-pulse.css.map +0 -1
- data/public/rails-pulse-assets/rails-pulse.js.map +0 -7
- data/vendor/assets/javascripts/rails-pulse-icons.js +0 -93
- data/vendor/assets/javascripts/rails-pulse-icons.js.map +0 -13
- data/vendor/assets/javascripts/rails-pulse.js +0 -157
- data/vendor/assets/javascripts/rails-pulse.js.map +0 -7
- data/vendor/assets/stylesheets/rails-pulse.css +0 -1
- 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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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] =
|
|
60
|
-
elsif
|
|
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:
|
|
65
|
-
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
|
-
|
|
132
|
+
config = RailsPulse.configuration
|
|
133
|
+
return unless config.authentication_enabled
|
|
85
134
|
|
|
86
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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(&
|
|
153
|
+
instance_exec(&hook)
|
|
95
154
|
when Symbol, String
|
|
96
|
-
method_name =
|
|
97
|
-
|
|
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: #{
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
@@ -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?
|
|
@@ -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
|
-
#
|
|
29
|
-
#
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
45
|
-
"/rails-pulse-assets/#{asset_name}"
|
|
37
|
+
"/rails-pulse-assets/#{RailsPulse::VERSION}/#{asset_name}"
|
|
46
38
|
end
|
|
47
39
|
end
|
|
48
40
|
end
|