rails_pulse 0.2.4 → 0.2.5.pre.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/README.md +269 -12
- data/Rakefile +142 -8
- data/app/assets/stylesheets/rails_pulse/components/table.css +16 -1
- data/app/assets/stylesheets/rails_pulse/components/tags.css +7 -2
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +3 -0
- data/app/controllers/concerns/chart_table_concern.rb +2 -1
- data/app/controllers/rails_pulse/application_controller.rb +11 -1
- data/app/controllers/rails_pulse/assets_controller.rb +18 -2
- data/app/controllers/rails_pulse/job_runs_controller.rb +37 -0
- data/app/controllers/rails_pulse/jobs_controller.rb +80 -0
- data/app/controllers/rails_pulse/operations_controller.rb +43 -31
- data/app/controllers/rails_pulse/queries_controller.rb +1 -1
- data/app/controllers/rails_pulse/requests_controller.rb +3 -9
- data/app/controllers/rails_pulse/routes_controller.rb +1 -1
- data/app/controllers/rails_pulse/tags_controller.rb +31 -5
- data/app/helpers/rails_pulse/application_helper.rb +32 -1
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +15 -1
- data/app/helpers/rails_pulse/status_helper.rb +16 -0
- data/app/helpers/rails_pulse/tags_helper.rb +39 -1
- data/app/javascript/rails_pulse/controllers/chart_controller.js +112 -8
- data/app/models/concerns/rails_pulse/taggable.rb +25 -2
- data/app/models/rails_pulse/charts/operations_chart.rb +33 -0
- data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +1 -2
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
- data/app/models/rails_pulse/job.rb +85 -0
- data/app/models/rails_pulse/job_run.rb +76 -0
- data/app/models/rails_pulse/jobs/cards/average_duration.rb +85 -0
- data/app/models/rails_pulse/jobs/cards/base.rb +70 -0
- data/app/models/rails_pulse/jobs/cards/failure_rate.rb +85 -0
- data/app/models/rails_pulse/jobs/cards/total_jobs.rb +74 -0
- data/app/models/rails_pulse/jobs/cards/total_runs.rb +48 -0
- data/app/models/rails_pulse/operation.rb +16 -3
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +3 -3
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +1 -1
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +1 -1
- data/app/models/rails_pulse/queries/tables/index.rb +2 -1
- data/app/models/rails_pulse/query.rb +10 -1
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +3 -2
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +1 -1
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +1 -1
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +1 -1
- data/app/models/rails_pulse/routes/tables/index.rb +2 -1
- data/app/models/rails_pulse/summary.rb +10 -3
- data/app/services/rails_pulse/summary_service.rb +46 -0
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
- data/app/views/layouts/rails_pulse/application.html.erb +23 -0
- data/app/views/rails_pulse/components/_active_filters.html.erb +7 -6
- data/app/views/rails_pulse/components/_page_header.html.erb +8 -7
- data/app/views/rails_pulse/components/_table.html.erb +7 -4
- data/app/views/rails_pulse/dashboard/index.html.erb +1 -1
- data/app/views/rails_pulse/job_runs/_operations.html.erb +78 -0
- data/app/views/rails_pulse/job_runs/index.html.erb +3 -0
- data/app/views/rails_pulse/job_runs/show.html.erb +51 -0
- data/app/views/rails_pulse/jobs/_job_runs_table.html.erb +35 -0
- data/app/views/rails_pulse/jobs/_table.html.erb +43 -0
- data/app/views/rails_pulse/jobs/index.html.erb +34 -0
- data/app/views/rails_pulse/jobs/show.html.erb +49 -0
- data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +29 -27
- data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +11 -9
- data/app/views/rails_pulse/operations/show.html.erb +10 -8
- data/app/views/rails_pulse/queries/_table.html.erb +3 -3
- data/app/views/rails_pulse/requests/_table.html.erb +6 -6
- data/app/views/rails_pulse/routes/_table.html.erb +3 -3
- data/app/views/rails_pulse/routes/show.html.erb +1 -1
- data/app/views/rails_pulse/tags/_tag_manager.html.erb +7 -14
- data/config/brakeman.ignore +213 -0
- data/config/brakeman.yml +68 -0
- data/config/initializers/rails_pulse.rb +52 -0
- data/config/routes.rb +6 -0
- data/db/rails_pulse_migrate/20250113000000_add_jobs_to_rails_pulse.rb +95 -0
- data/db/rails_pulse_migrate/20250122000000_add_query_fingerprinting.rb +150 -0
- data/db/rails_pulse_migrate/20250202000000_add_index_to_request_uuid.rb +14 -0
- data/db/rails_pulse_schema.rb +186 -103
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +186 -103
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +30 -1
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +31 -0
- data/lib/rails_pulse/active_job_extensions.rb +13 -0
- data/lib/rails_pulse/adapters/delayed_job_plugin.rb +25 -0
- data/lib/rails_pulse/adapters/sidekiq_middleware.rb +41 -0
- data/lib/rails_pulse/cleanup_service.rb +65 -0
- data/lib/rails_pulse/configuration.rb +80 -7
- data/lib/rails_pulse/engine.rb +34 -3
- data/lib/rails_pulse/extensions/active_record.rb +82 -0
- data/lib/rails_pulse/job_run_collector.rb +172 -0
- data/lib/rails_pulse/middleware/request_collector.rb +20 -43
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +11 -5
- data/lib/rails_pulse/tracker.rb +82 -0
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +2 -0
- data/lib/rails_pulse_server.ru +107 -0
- data/lib/tasks/rails_pulse_benchmark.rake +382 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js +3 -2
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +1 -1
- data/public/rails-pulse-assets/rails-pulse.js.map +3 -3
- metadata +35 -7
- data/app/models/rails_pulse/requests/charts/operations_chart.rb +0 -35
- data/db/migrate/20250930105043_install_rails_pulse_tables.rb +0 -23
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module RailsPulse
|
|
2
2
|
class ApplicationController < ActionController::Base
|
|
3
|
-
# Support both Pagy 8.x (Backend) and Pagy
|
|
3
|
+
# Support both Pagy 8.x (Backend) and Pagy 43+ (Method)
|
|
4
4
|
if defined?(Pagy::Method)
|
|
5
5
|
include Pagy::Method
|
|
6
6
|
else
|
|
@@ -152,5 +152,15 @@ module RailsPulse
|
|
|
152
152
|
def set_show_non_tagged_default
|
|
153
153
|
session[:show_non_tagged] = true if session[:show_non_tagged].nil?
|
|
154
154
|
end
|
|
155
|
+
|
|
156
|
+
# Returns Pagy options hash with correct parameter name for current version
|
|
157
|
+
# Pagy 8.x uses 'items:', Pagy 43+ uses 'limit:'
|
|
158
|
+
def pagy_options(count)
|
|
159
|
+
if defined?(Pagy::Method)
|
|
160
|
+
{ limit: count } # Pagy 43+
|
|
161
|
+
else
|
|
162
|
+
{ items: count } # Pagy 8.x
|
|
163
|
+
end
|
|
164
|
+
end
|
|
155
165
|
end
|
|
156
166
|
end
|
|
@@ -4,11 +4,17 @@ module RailsPulse
|
|
|
4
4
|
|
|
5
5
|
def show
|
|
6
6
|
asset_name = params[:asset_name]
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
# Prevent path traversal attacks by validating asset name
|
|
9
|
+
return head :not_found unless valid_asset_name?(asset_name)
|
|
10
|
+
|
|
11
|
+
# Use sanitized asset name to build path
|
|
12
|
+
sanitized_name = File.basename(asset_name)
|
|
13
|
+
asset_path = Rails.root.join("public", "rails-pulse-assets", sanitized_name)
|
|
8
14
|
|
|
9
15
|
# Fallback to engine assets if not found in host app
|
|
10
16
|
unless File.exist?(asset_path)
|
|
11
|
-
asset_path = RailsPulse::Engine.root.join("public", "rails-pulse-assets",
|
|
17
|
+
asset_path = RailsPulse::Engine.root.join("public", "rails-pulse-assets", sanitized_name)
|
|
12
18
|
end
|
|
13
19
|
|
|
14
20
|
if File.exist?(asset_path)
|
|
@@ -29,5 +35,15 @@ module RailsPulse
|
|
|
29
35
|
head :not_found
|
|
30
36
|
end
|
|
31
37
|
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def valid_asset_name?(name)
|
|
42
|
+
return false if name.blank?
|
|
43
|
+
return false if name.include?("..")
|
|
44
|
+
return false if name.include?("/")
|
|
45
|
+
return false if name.include?("\\")
|
|
46
|
+
true
|
|
47
|
+
end
|
|
32
48
|
end
|
|
33
49
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
class JobRunsController < ApplicationController
|
|
3
|
+
include TagFilterConcern
|
|
4
|
+
|
|
5
|
+
before_action :set_job
|
|
6
|
+
before_action :set_run, only: :show
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@ransack_query = @job.runs.ransack(params[:q])
|
|
10
|
+
@pagy, @runs = pagy(@ransack_query.result.order(occurred_at: :desc), **pagy_options(session_pagination_limit))
|
|
11
|
+
@table_data = @runs
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def show
|
|
15
|
+
@operations = @run.operations.order(:start_time)
|
|
16
|
+
@operation_timeline = RailsPulse::Charts::OperationsChart.new(@operations)
|
|
17
|
+
|
|
18
|
+
# Group operations by type
|
|
19
|
+
@operations_by_type = @operations.group_by(&:operation_type)
|
|
20
|
+
|
|
21
|
+
# SQL queries
|
|
22
|
+
@sql_operations = @operations.where(operation_type: "sql")
|
|
23
|
+
.includes(:query)
|
|
24
|
+
.order(duration: :desc)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def set_job
|
|
30
|
+
@job = RailsPulse::Job.find(params[:job_id])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def set_run
|
|
34
|
+
@run = @job.runs.find(params[:id])
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
class JobsController < ApplicationController
|
|
3
|
+
include TagFilterConcern
|
|
4
|
+
include TimeRangeConcern
|
|
5
|
+
|
|
6
|
+
# Override TIME_RANGE_OPTIONS from TimeRangeConcern
|
|
7
|
+
remove_const(:TIME_RANGE_OPTIONS) if const_defined?(:TIME_RANGE_OPTIONS)
|
|
8
|
+
TIME_RANGE_OPTIONS = [
|
|
9
|
+
[ "Recent", "recent" ],
|
|
10
|
+
[ "Custom Range", "custom" ]
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
before_action :set_job, only: :show
|
|
14
|
+
|
|
15
|
+
def index
|
|
16
|
+
setup_metric_cards
|
|
17
|
+
|
|
18
|
+
@ransack_query = RailsPulse::Job.ransack(params[:q])
|
|
19
|
+
|
|
20
|
+
# Apply tag filters from session
|
|
21
|
+
base_query = apply_tag_filters(@ransack_query.result)
|
|
22
|
+
|
|
23
|
+
@pagy, @jobs = pagy(base_query.order(runs_count: :desc),
|
|
24
|
+
**pagy_options(session_pagination_limit),
|
|
25
|
+
overflow: :last_page)
|
|
26
|
+
@table_data = @jobs
|
|
27
|
+
@available_queues = RailsPulse::Job.distinct.pluck(:queue_name).compact.sort
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def show
|
|
31
|
+
setup_metric_cards
|
|
32
|
+
|
|
33
|
+
ransack_params = params[:q] || {}
|
|
34
|
+
|
|
35
|
+
# Check if user explicitly selected a time range
|
|
36
|
+
time_mode = params.dig(:q, :period_start_range) || "recent"
|
|
37
|
+
|
|
38
|
+
# Apply time range filter only if custom mode is selected
|
|
39
|
+
if time_mode == "custom"
|
|
40
|
+
# Get time range from TimeRangeConcern which parses custom_date_range
|
|
41
|
+
@start_time, @end_time, @selected_time_range, @time_diff_hours = setup_time_range
|
|
42
|
+
|
|
43
|
+
# Apply time filters using parsed times from concern
|
|
44
|
+
ransack_params = ransack_params.merge(
|
|
45
|
+
occurred_at_gteq: Time.at(@start_time),
|
|
46
|
+
occurred_at_lteq: Time.at(@end_time)
|
|
47
|
+
)
|
|
48
|
+
else
|
|
49
|
+
# Recent mode - no time filters, just rely on sort + pagination
|
|
50
|
+
@selected_time_range = "recent"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@ransack_query = @job.runs.ransack(ransack_params)
|
|
54
|
+
@ransack_query.sorts = "occurred_at desc" if @ransack_query.sorts.empty?
|
|
55
|
+
|
|
56
|
+
# Apply tag filters from session
|
|
57
|
+
base_query = apply_tag_filters(@ransack_query.result)
|
|
58
|
+
|
|
59
|
+
@pagy, @recent_runs = pagy(base_query,
|
|
60
|
+
**pagy_options(session_pagination_limit),
|
|
61
|
+
overflow: :last_page)
|
|
62
|
+
@table_data = @recent_runs
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def set_job
|
|
68
|
+
@job = RailsPulse::Job.find(params[:id])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def setup_metric_cards
|
|
72
|
+
return if turbo_frame_request?
|
|
73
|
+
|
|
74
|
+
# Pass the job to scope the cards to the current job on the show page
|
|
75
|
+
@total_runs_metric_card = RailsPulse::Jobs::Cards::TotalRuns.new(job: @job).to_metric_card
|
|
76
|
+
@failure_rate_metric_card = RailsPulse::Jobs::Cards::FailureRate.new(job: @job).to_metric_card
|
|
77
|
+
@average_duration_metric_card = RailsPulse::Jobs::Cards::AverageDuration.new(job: @job).to_metric_card
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -4,6 +4,8 @@ module RailsPulse
|
|
|
4
4
|
|
|
5
5
|
def show
|
|
6
6
|
@request = @operation.request
|
|
7
|
+
@job_run = @operation.job_run
|
|
8
|
+
@parent = @request || @job_run
|
|
7
9
|
@related_operations = find_related_operations
|
|
8
10
|
@performance_context = calculate_performance_context
|
|
9
11
|
@optimization_suggestions = generate_optimization_suggestions
|
|
@@ -20,22 +22,24 @@ module RailsPulse
|
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
def find_related_operations
|
|
25
|
+
return [] unless @parent
|
|
26
|
+
|
|
23
27
|
case @operation.operation_type
|
|
24
28
|
when "sql"
|
|
25
|
-
# Find other SQL operations in the same request with similar queries
|
|
26
|
-
@
|
|
29
|
+
# Find other SQL operations in the same request/job run with similar queries
|
|
30
|
+
@parent.operations
|
|
27
31
|
.where(operation_type: [ "sql" ])
|
|
28
32
|
.where.not(id: @operation.id)
|
|
29
33
|
.limit(5)
|
|
30
34
|
when "template", "partial", "layout", "collection"
|
|
31
|
-
# Find other view operations in the same request
|
|
32
|
-
@
|
|
35
|
+
# Find other view operations in the same request/job run
|
|
36
|
+
@parent.operations
|
|
33
37
|
.where(operation_type: [ "template", "partial", "layout", "collection" ])
|
|
34
38
|
.where.not(id: @operation.id)
|
|
35
39
|
.limit(5)
|
|
36
40
|
else
|
|
37
|
-
# Find operations of the same type in the same request
|
|
38
|
-
@
|
|
41
|
+
# Find operations of the same type in the same request/job run
|
|
42
|
+
@parent.operations
|
|
39
43
|
.where(operation_type: @operation.operation_type)
|
|
40
44
|
.where.not(id: @operation.id)
|
|
41
45
|
.limit(5)
|
|
@@ -117,19 +121,25 @@ module RailsPulse
|
|
|
117
121
|
end
|
|
118
122
|
|
|
119
123
|
# Check for potential N+1 queries
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
.
|
|
124
|
+
if @parent
|
|
125
|
+
# Sanitize LIKE pattern to prevent SQL injection via wildcards
|
|
126
|
+
label_prefix = @operation.label.split.first(3).join(" ")
|
|
127
|
+
sanitized_pattern = ActiveRecord::Base.sanitize_sql_like(label_prefix, "\\")
|
|
124
128
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
129
|
+
similar_queries = @parent.operations
|
|
130
|
+
.where(operation_type: [ "sql" ])
|
|
131
|
+
.where("label LIKE ?", "%#{sanitized_pattern}%")
|
|
132
|
+
.where.not(id: @operation.id)
|
|
133
|
+
|
|
134
|
+
if similar_queries.count > 2
|
|
135
|
+
suggestions << {
|
|
136
|
+
type: "n_plus_one",
|
|
137
|
+
icon: "alert-triangle",
|
|
138
|
+
title: "Potential N+1 Query",
|
|
139
|
+
description: "#{similar_queries.count + 1} similar queries detected. Consider using includes() or joins().",
|
|
140
|
+
priority: "high"
|
|
141
|
+
}
|
|
142
|
+
end
|
|
133
143
|
end
|
|
134
144
|
|
|
135
145
|
suggestions
|
|
@@ -149,20 +159,22 @@ module RailsPulse
|
|
|
149
159
|
end
|
|
150
160
|
|
|
151
161
|
# Check for database queries in views
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
162
|
+
if @parent
|
|
163
|
+
view_db_operations = @parent.operations
|
|
164
|
+
.where(operation_type: [ "sql" ])
|
|
165
|
+
.where("occurred_at >= ? AND occurred_at <= ?",
|
|
166
|
+
@operation.occurred_at,
|
|
167
|
+
@operation.occurred_at + @operation.duration)
|
|
157
168
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
169
|
+
if view_db_operations.count > 0
|
|
170
|
+
suggestions << {
|
|
171
|
+
type: "database",
|
|
172
|
+
icon: "database",
|
|
173
|
+
title: "Database Queries in View",
|
|
174
|
+
description: "#{view_db_operations.count} database queries during view rendering. Move data fetching to the controller.",
|
|
175
|
+
priority: "medium"
|
|
176
|
+
}
|
|
177
|
+
end
|
|
166
178
|
end
|
|
167
179
|
|
|
168
180
|
suggestions
|
|
@@ -163,7 +163,7 @@ module RailsPulse
|
|
|
163
163
|
table_results = build_table_results
|
|
164
164
|
handle_pagination
|
|
165
165
|
|
|
166
|
-
@pagy, @table_data = pagy(table_results,
|
|
166
|
+
@pagy, @table_data = pagy(table_results, **pagy_options(session_pagination_limit))
|
|
167
167
|
end
|
|
168
168
|
|
|
169
169
|
def handle_pagination
|
|
@@ -18,7 +18,7 @@ module RailsPulse
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def show
|
|
21
|
-
@operation_timeline = RailsPulse::
|
|
21
|
+
@operation_timeline = RailsPulse::Charts::OperationsChart.new(@request.operations)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
private
|
|
@@ -118,18 +118,12 @@ module RailsPulse
|
|
|
118
118
|
def setup_table_data(ransack_params)
|
|
119
119
|
table_ransack_params = build_table_ransack_params(ransack_params)
|
|
120
120
|
@ransack_query = table_model.ransack(table_ransack_params)
|
|
121
|
-
|
|
122
|
-
# Only apply default sort if not using Requests::Tables::Index (which handles its own sorting)
|
|
123
|
-
# For requests, we always use the Tables::Index on the index action
|
|
124
|
-
unless action_name == "index"
|
|
125
|
-
@ransack_query.sorts = default_table_sort if @ransack_query.sorts.empty?
|
|
126
|
-
end
|
|
121
|
+
@ransack_query.sorts = default_table_sort if @ransack_query.sorts.empty?
|
|
127
122
|
|
|
128
123
|
table_results = build_table_results
|
|
129
124
|
handle_pagination
|
|
130
125
|
|
|
131
|
-
|
|
132
|
-
@pagy, @table_data = pagy(table_results, items: session_pagination_limit)
|
|
126
|
+
@pagy, @table_data = pagy(table_results, **pagy_options(session_pagination_limit))
|
|
133
127
|
end
|
|
134
128
|
|
|
135
129
|
def handle_pagination
|
|
@@ -142,7 +142,7 @@ module RailsPulse
|
|
|
142
142
|
table_results = build_table_results
|
|
143
143
|
handle_pagination
|
|
144
144
|
|
|
145
|
-
@pagy, @table_data = pagy(table_results,
|
|
145
|
+
@pagy, @table_data = pagy(table_results, **pagy_options(session_pagination_limit))
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
def handle_pagination
|
|
@@ -2,17 +2,26 @@ module RailsPulse
|
|
|
2
2
|
class TagsController < ApplicationController
|
|
3
3
|
before_action :set_taggable
|
|
4
4
|
|
|
5
|
+
# Tag validation rules
|
|
6
|
+
TAG_NAME_REGEX = /\A[a-z0-9_-]+\z/i
|
|
7
|
+
MAX_TAG_LENGTH = 50
|
|
8
|
+
|
|
5
9
|
def create
|
|
6
10
|
tag = params[:tag]
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
# Validate tag name
|
|
13
|
+
error_message = validate_tag(tag)
|
|
14
|
+
if error_message
|
|
15
|
+
render_error(error_message)
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Add tag to taggable
|
|
20
|
+
unless @taggable.add_tag(tag)
|
|
21
|
+
render_error("Failed to add tag")
|
|
12
22
|
return
|
|
13
23
|
end
|
|
14
24
|
|
|
15
|
-
@taggable.add_tag(tag)
|
|
16
25
|
@taggable.reload
|
|
17
26
|
|
|
18
27
|
render turbo_stream: turbo_stream.replace("tag_manager_#{@taggable.class.name.demodulize.underscore}_#{@taggable.id}",
|
|
@@ -32,6 +41,19 @@ module RailsPulse
|
|
|
32
41
|
|
|
33
42
|
private
|
|
34
43
|
|
|
44
|
+
def validate_tag(tag)
|
|
45
|
+
return "Tag cannot be blank" if tag.blank?
|
|
46
|
+
return "Tag must be #{MAX_TAG_LENGTH} characters or less" if tag.length > MAX_TAG_LENGTH
|
|
47
|
+
return "Tag can only contain letters, numbers, hyphens, and underscores" unless tag.match?(TAG_NAME_REGEX)
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def render_error(message)
|
|
52
|
+
render turbo_stream: turbo_stream.replace("tag_manager_#{@taggable.class.name.demodulize.underscore}_#{@taggable.id}",
|
|
53
|
+
partial: "rails_pulse/tags/tag_manager",
|
|
54
|
+
locals: { taggable: @taggable, error: message })
|
|
55
|
+
end
|
|
56
|
+
|
|
35
57
|
def set_taggable
|
|
36
58
|
@taggable_type = params[:taggable_type]
|
|
37
59
|
@taggable_id = params[:taggable_id]
|
|
@@ -43,6 +65,10 @@ module RailsPulse
|
|
|
43
65
|
Request.find(@taggable_id)
|
|
44
66
|
when "query"
|
|
45
67
|
Query.find(@taggable_id)
|
|
68
|
+
when "job"
|
|
69
|
+
Job.find(@taggable_id)
|
|
70
|
+
when "job_run"
|
|
71
|
+
JobRun.find(@taggable_id)
|
|
46
72
|
else
|
|
47
73
|
head :not_found
|
|
48
74
|
end
|
|
@@ -18,9 +18,25 @@ module RailsPulse
|
|
|
18
18
|
width = options[:width] || options["width"] || 24
|
|
19
19
|
height = options[:height] || options["height"] || 24
|
|
20
20
|
css_class = options[:class] || options["class"] || ""
|
|
21
|
+
custom_style = options[:style] || options["style"]
|
|
22
|
+
|
|
23
|
+
# Normalize numeric width/height values into px for layout stability
|
|
24
|
+
width_css = normalize_dimension(width)
|
|
25
|
+
height_css = normalize_dimension(height)
|
|
26
|
+
|
|
27
|
+
default_style = [
|
|
28
|
+
"display:inline-flex",
|
|
29
|
+
"align-items:center",
|
|
30
|
+
"justify-content:center",
|
|
31
|
+
"width:#{width_css}",
|
|
32
|
+
"height:#{height_css}",
|
|
33
|
+
"flex-shrink:0"
|
|
34
|
+
].join(";")
|
|
35
|
+
|
|
36
|
+
style_attribute = [ default_style, custom_style ].compact.join(";")
|
|
21
37
|
|
|
22
38
|
# Additional HTML attributes
|
|
23
|
-
attrs = options.except(:width, :height, :class, "width", "height", "class")
|
|
39
|
+
attrs = options.except(:width, :height, :class, :style, "width", "height", "class", "style")
|
|
24
40
|
|
|
25
41
|
content_tag("rails-pulse-icon",
|
|
26
42
|
"",
|
|
@@ -31,6 +47,7 @@ module RailsPulse
|
|
|
31
47
|
'rails-pulse--icon-height-value': height
|
|
32
48
|
},
|
|
33
49
|
class: css_class,
|
|
50
|
+
style: style_attribute,
|
|
34
51
|
**attrs
|
|
35
52
|
)
|
|
36
53
|
end
|
|
@@ -38,6 +55,20 @@ module RailsPulse
|
|
|
38
55
|
# Backward compatibility alias - can be removed after migration
|
|
39
56
|
alias_method :lucide_icon, :rails_pulse_icon
|
|
40
57
|
|
|
58
|
+
def normalize_dimension(value)
|
|
59
|
+
string = value.to_s
|
|
60
|
+
return string if string.empty?
|
|
61
|
+
|
|
62
|
+
if string.match?(/[a-z%]+\z/i)
|
|
63
|
+
string
|
|
64
|
+
else
|
|
65
|
+
number = Float(string)
|
|
66
|
+
formatted = number == number.to_i ? number.to_i.to_s : number.to_s
|
|
67
|
+
"#{formatted}px"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private :normalize_dimension
|
|
41
72
|
# Get items per page from Pagy instance (compatible with Pagy 8.x and 43+)
|
|
42
73
|
def pagy_items(pagy)
|
|
43
74
|
# Pagy 43+ uses options[:items] or has a limit method
|
|
@@ -49,9 +49,23 @@ module RailsPulse
|
|
|
49
49
|
|
|
50
50
|
is_last = index == path_segments.length - 1
|
|
51
51
|
|
|
52
|
+
# For nested resources, if this is a collection name followed by an ID,
|
|
53
|
+
# link to the parent resource's show page instead of the nested index
|
|
54
|
+
breadcrumb_path = if !is_last &&
|
|
55
|
+
segment !~ /^\d+$/ &&
|
|
56
|
+
index > 0 &&
|
|
57
|
+
path_segments[index - 1] =~ /^\d+$/ &&
|
|
58
|
+
path_segments[index + 1] =~ /^\d+$/
|
|
59
|
+
# This is a nested collection (e.g., /jobs/5/runs/291)
|
|
60
|
+
# Link to parent show page (e.g., /jobs/5)
|
|
61
|
+
path_segments[0..index-1].inject(main_app.rails_pulse_path.chomp("/")) { |path, seg| path + "/#{seg}" }
|
|
62
|
+
else
|
|
63
|
+
current_path
|
|
64
|
+
end
|
|
65
|
+
|
|
52
66
|
crumbs << {
|
|
53
67
|
title: title,
|
|
54
|
-
path:
|
|
68
|
+
path: breadcrumb_path,
|
|
55
69
|
current: is_last
|
|
56
70
|
}
|
|
57
71
|
end
|
|
@@ -281,5 +281,21 @@ module RailsPulse
|
|
|
281
281
|
[ "Critical (≥ #{thresholds[:critical]}ms)", :critical ]
|
|
282
282
|
]
|
|
283
283
|
end
|
|
284
|
+
|
|
285
|
+
def duration_threshold_filter_options(type = :route)
|
|
286
|
+
thresholds = RailsPulse.configuration.public_send("#{type}_thresholds")
|
|
287
|
+
|
|
288
|
+
all_label =
|
|
289
|
+
case type
|
|
290
|
+
when :job then "All Job Runs"
|
|
291
|
+
else "All #{type.to_s.humanize.pluralize}"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
threshold_options = thresholds.map do |name, value|
|
|
295
|
+
[ "#{name.to_s.humanize} (≥ #{value}ms)", value ]
|
|
296
|
+
end.sort_by { |_, value| value }
|
|
297
|
+
|
|
298
|
+
[ [ all_label, nil ] ] + threshold_options
|
|
299
|
+
end
|
|
284
300
|
end
|
|
285
301
|
end
|
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
module RailsPulse
|
|
2
2
|
module TagsHelper
|
|
3
|
+
# Render a single tag badge
|
|
4
|
+
# Options:
|
|
5
|
+
# - variant: :default (no class), :secondary, :positive
|
|
6
|
+
# - removable: boolean - whether to include a remove button
|
|
7
|
+
# - taggable_type: string - type of taggable object (for remove button)
|
|
8
|
+
# - taggable_id: integer - id of taggable object (for remove button)
|
|
9
|
+
def render_tag_badge(tag, variant: :default, removable: false, taggable_type: nil, taggable_id: nil)
|
|
10
|
+
badge_class = case variant
|
|
11
|
+
when :secondary
|
|
12
|
+
"badge badge--secondary font-normal"
|
|
13
|
+
when :positive
|
|
14
|
+
"badge badge--positive font-normal"
|
|
15
|
+
else
|
|
16
|
+
"badge font-normal"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
if removable && taggable_type && taggable_id
|
|
20
|
+
# For removable tags, render the full structure with button_to
|
|
21
|
+
content_tag(:span, class: badge_class) do
|
|
22
|
+
concat tag.humanize
|
|
23
|
+
concat " "
|
|
24
|
+
concat(
|
|
25
|
+
button_to(
|
|
26
|
+
remove_tag_path(taggable_type, taggable_id, tag: tag),
|
|
27
|
+
method: :delete,
|
|
28
|
+
class: "tag-remove",
|
|
29
|
+
data: { turbo_frame: "_top" }
|
|
30
|
+
) do
|
|
31
|
+
content_tag(:span, "×", "aria-hidden": "true")
|
|
32
|
+
end
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
else
|
|
36
|
+
# For non-removable tags, just render the badge
|
|
37
|
+
content_tag(:span, tag, class: badge_class)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
3
41
|
# Display tags as badge elements
|
|
4
42
|
# Accepts:
|
|
5
43
|
# - Taggable objects (with tag_list method)
|
|
@@ -23,7 +61,7 @@ module RailsPulse
|
|
|
23
61
|
|
|
24
62
|
return content_tag(:span, "-", class: "text-subtle") if tag_array.empty?
|
|
25
63
|
|
|
26
|
-
safe_join(tag_array.map { |tag| content_tag(:div, tag, class: "badge") }, " ")
|
|
64
|
+
safe_join(tag_array.map { |tag| content_tag(:div, tag.humanize, class: "badge") }, " ")
|
|
27
65
|
end
|
|
28
66
|
end
|
|
29
67
|
end
|