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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +269 -12
  3. data/Rakefile +142 -8
  4. data/app/assets/stylesheets/rails_pulse/components/table.css +16 -1
  5. data/app/assets/stylesheets/rails_pulse/components/tags.css +7 -2
  6. data/app/assets/stylesheets/rails_pulse/components/utilities.css +3 -0
  7. data/app/controllers/concerns/chart_table_concern.rb +2 -1
  8. data/app/controllers/rails_pulse/application_controller.rb +11 -1
  9. data/app/controllers/rails_pulse/assets_controller.rb +18 -2
  10. data/app/controllers/rails_pulse/job_runs_controller.rb +37 -0
  11. data/app/controllers/rails_pulse/jobs_controller.rb +80 -0
  12. data/app/controllers/rails_pulse/operations_controller.rb +43 -31
  13. data/app/controllers/rails_pulse/queries_controller.rb +1 -1
  14. data/app/controllers/rails_pulse/requests_controller.rb +3 -9
  15. data/app/controllers/rails_pulse/routes_controller.rb +1 -1
  16. data/app/controllers/rails_pulse/tags_controller.rb +31 -5
  17. data/app/helpers/rails_pulse/application_helper.rb +32 -1
  18. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +15 -1
  19. data/app/helpers/rails_pulse/status_helper.rb +16 -0
  20. data/app/helpers/rails_pulse/tags_helper.rb +39 -1
  21. data/app/javascript/rails_pulse/controllers/chart_controller.js +112 -8
  22. data/app/models/concerns/rails_pulse/taggable.rb +25 -2
  23. data/app/models/rails_pulse/charts/operations_chart.rb +33 -0
  24. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +1 -2
  25. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
  26. data/app/models/rails_pulse/job.rb +85 -0
  27. data/app/models/rails_pulse/job_run.rb +76 -0
  28. data/app/models/rails_pulse/jobs/cards/average_duration.rb +85 -0
  29. data/app/models/rails_pulse/jobs/cards/base.rb +70 -0
  30. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +85 -0
  31. data/app/models/rails_pulse/jobs/cards/total_jobs.rb +74 -0
  32. data/app/models/rails_pulse/jobs/cards/total_runs.rb +48 -0
  33. data/app/models/rails_pulse/operation.rb +16 -3
  34. data/app/models/rails_pulse/queries/cards/average_query_times.rb +3 -3
  35. data/app/models/rails_pulse/queries/cards/execution_rate.rb +1 -1
  36. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +1 -1
  37. data/app/models/rails_pulse/queries/tables/index.rb +2 -1
  38. data/app/models/rails_pulse/query.rb +10 -1
  39. data/app/models/rails_pulse/routes/cards/average_response_times.rb +3 -2
  40. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +1 -1
  41. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +1 -1
  42. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +1 -1
  43. data/app/models/rails_pulse/routes/tables/index.rb +2 -1
  44. data/app/models/rails_pulse/summary.rb +10 -3
  45. data/app/services/rails_pulse/summary_service.rb +46 -0
  46. data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
  47. data/app/views/layouts/rails_pulse/application.html.erb +23 -0
  48. data/app/views/rails_pulse/components/_active_filters.html.erb +7 -6
  49. data/app/views/rails_pulse/components/_page_header.html.erb +8 -7
  50. data/app/views/rails_pulse/components/_table.html.erb +7 -4
  51. data/app/views/rails_pulse/dashboard/index.html.erb +1 -1
  52. data/app/views/rails_pulse/job_runs/_operations.html.erb +78 -0
  53. data/app/views/rails_pulse/job_runs/index.html.erb +3 -0
  54. data/app/views/rails_pulse/job_runs/show.html.erb +51 -0
  55. data/app/views/rails_pulse/jobs/_job_runs_table.html.erb +35 -0
  56. data/app/views/rails_pulse/jobs/_table.html.erb +43 -0
  57. data/app/views/rails_pulse/jobs/index.html.erb +34 -0
  58. data/app/views/rails_pulse/jobs/show.html.erb +49 -0
  59. data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +29 -27
  60. data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +11 -9
  61. data/app/views/rails_pulse/operations/show.html.erb +10 -8
  62. data/app/views/rails_pulse/queries/_table.html.erb +3 -3
  63. data/app/views/rails_pulse/requests/_table.html.erb +6 -6
  64. data/app/views/rails_pulse/routes/_table.html.erb +3 -3
  65. data/app/views/rails_pulse/routes/show.html.erb +1 -1
  66. data/app/views/rails_pulse/tags/_tag_manager.html.erb +7 -14
  67. data/config/brakeman.ignore +213 -0
  68. data/config/brakeman.yml +68 -0
  69. data/config/initializers/rails_pulse.rb +52 -0
  70. data/config/routes.rb +6 -0
  71. data/db/rails_pulse_migrate/20250113000000_add_jobs_to_rails_pulse.rb +95 -0
  72. data/db/rails_pulse_migrate/20250122000000_add_query_fingerprinting.rb +150 -0
  73. data/db/rails_pulse_migrate/20250202000000_add_index_to_request_uuid.rb +14 -0
  74. data/db/rails_pulse_schema.rb +186 -103
  75. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +186 -103
  76. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +30 -1
  77. data/lib/generators/rails_pulse/templates/rails_pulse.rb +31 -0
  78. data/lib/rails_pulse/active_job_extensions.rb +13 -0
  79. data/lib/rails_pulse/adapters/delayed_job_plugin.rb +25 -0
  80. data/lib/rails_pulse/adapters/sidekiq_middleware.rb +41 -0
  81. data/lib/rails_pulse/cleanup_service.rb +65 -0
  82. data/lib/rails_pulse/configuration.rb +80 -7
  83. data/lib/rails_pulse/engine.rb +34 -3
  84. data/lib/rails_pulse/extensions/active_record.rb +82 -0
  85. data/lib/rails_pulse/job_run_collector.rb +172 -0
  86. data/lib/rails_pulse/middleware/request_collector.rb +20 -43
  87. data/lib/rails_pulse/subscribers/operation_subscriber.rb +11 -5
  88. data/lib/rails_pulse/tracker.rb +82 -0
  89. data/lib/rails_pulse/version.rb +1 -1
  90. data/lib/rails_pulse.rb +2 -0
  91. data/lib/rails_pulse_server.ru +107 -0
  92. data/lib/tasks/rails_pulse_benchmark.rake +382 -0
  93. data/public/rails-pulse-assets/rails-pulse-icons.js +3 -2
  94. data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
  95. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  96. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  97. data/public/rails-pulse-assets/rails-pulse.js +1 -1
  98. data/public/rails-pulse-assets/rails-pulse.js.map +3 -3
  99. metadata +35 -7
  100. data/app/models/rails_pulse/requests/charts/operations_chart.rb +0 -35
  101. 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 9+ (Method)
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
- asset_path = Rails.root.join("public", "rails-pulse-assets", asset_name)
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", asset_name)
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
- @operation.request.operations
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
- @operation.request.operations
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
- @operation.request.operations
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
- similar_queries = @operation.request.operations
121
- .where(operation_type: [ "sql" ])
122
- .where("label LIKE ?", "%#{@operation.label.split.first(3).join(' ')}%")
123
- .where.not(id: @operation.id)
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
- if similar_queries.count > 2
126
- suggestions << {
127
- type: "n_plus_one",
128
- icon: "alert-triangle",
129
- title: "Potential N+1 Query",
130
- description: "#{similar_queries.count + 1} similar queries detected. Consider using includes() or joins().",
131
- priority: "high"
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
- view_db_operations = @operation.request.operations
153
- .where(operation_type: [ "sql" ])
154
- .where("occurred_at >= ? AND occurred_at <= ?",
155
- @operation.occurred_at,
156
- @operation.occurred_at + @operation.duration)
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
- if view_db_operations.count > 0
159
- suggestions << {
160
- type: "database",
161
- icon: "database",
162
- title: "Database Queries in View",
163
- description: "#{view_db_operations.count} database queries during view rendering. Move data fetching to the controller.",
164
- priority: "medium"
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, items: session_pagination_limit)
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::Requests::Charts::OperationsChart.new(@request.operations)
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
- # Use 'items:' for Pagy 8.x compatibility ('limit:' is for Pagy 43+)
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, items: session_pagination_limit)
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
- if tag.blank?
9
- render turbo_stream: turbo_stream.replace("tag_manager_#{@taggable.class.name.demodulize.underscore}_#{@taggable.id}",
10
- partial: "rails_pulse/tags/tag_manager",
11
- locals: { taggable: @taggable, error: "Tag cannot be blank" })
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: current_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