rails_pulse 0.1.0

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 (160) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +638 -0
  4. data/Rakefile +207 -0
  5. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  6. data/app/assets/images/rails_pulse/menu.svg +1 -0
  7. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  8. data/app/assets/images/rails_pulse/request.png +0 -0
  9. data/app/assets/images/rails_pulse/routes.png +0 -0
  10. data/app/assets/stylesheets/rails_pulse/application.css +102 -0
  11. data/app/assets/stylesheets/rails_pulse/components/alert.css +24 -0
  12. data/app/assets/stylesheets/rails_pulse/components/badge.css +58 -0
  13. data/app/assets/stylesheets/rails_pulse/components/base.css +79 -0
  14. data/app/assets/stylesheets/rails_pulse/components/breadcrumb.css +31 -0
  15. data/app/assets/stylesheets/rails_pulse/components/button.css +99 -0
  16. data/app/assets/stylesheets/rails_pulse/components/card.css +19 -0
  17. data/app/assets/stylesheets/rails_pulse/components/chart.css +18 -0
  18. data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +86 -0
  19. data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +9 -0
  20. data/app/assets/stylesheets/rails_pulse/components/dialog.css +56 -0
  21. data/app/assets/stylesheets/rails_pulse/components/flash.css +47 -0
  22. data/app/assets/stylesheets/rails_pulse/components/input.css +80 -0
  23. data/app/assets/stylesheets/rails_pulse/components/layouts.css +63 -0
  24. data/app/assets/stylesheets/rails_pulse/components/menu.css +43 -0
  25. data/app/assets/stylesheets/rails_pulse/components/popover.css +36 -0
  26. data/app/assets/stylesheets/rails_pulse/components/prose.css +144 -0
  27. data/app/assets/stylesheets/rails_pulse/components/row.css +24 -0
  28. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +79 -0
  29. data/app/assets/stylesheets/rails_pulse/components/skeleton.css +5 -0
  30. data/app/assets/stylesheets/rails_pulse/components/table.css +37 -0
  31. data/app/assets/stylesheets/rails_pulse/components/utilities.css +36 -0
  32. data/app/controllers/concerns/chart_table_concern.rb +82 -0
  33. data/app/controllers/concerns/response_range_concern.rb +24 -0
  34. data/app/controllers/concerns/time_range_concern.rb +67 -0
  35. data/app/controllers/concerns/zoom_range_concern.rb +40 -0
  36. data/app/controllers/rails_pulse/application_controller.rb +67 -0
  37. data/app/controllers/rails_pulse/assets_controller.rb +33 -0
  38. data/app/controllers/rails_pulse/caches_controller.rb +115 -0
  39. data/app/controllers/rails_pulse/csp_test_controller.rb +57 -0
  40. data/app/controllers/rails_pulse/dashboard_controller.rb +6 -0
  41. data/app/controllers/rails_pulse/operations_controller.rb +219 -0
  42. data/app/controllers/rails_pulse/queries_controller.rb +121 -0
  43. data/app/controllers/rails_pulse/requests_controller.rb +69 -0
  44. data/app/controllers/rails_pulse/routes_controller.rb +99 -0
  45. data/app/helpers/rails_pulse/application_helper.rb +111 -0
  46. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +62 -0
  47. data/app/helpers/rails_pulse/cached_component_helper.rb +73 -0
  48. data/app/helpers/rails_pulse/chart_formatters.rb +43 -0
  49. data/app/helpers/rails_pulse/chart_helper.rb +140 -0
  50. data/app/helpers/rails_pulse/formatting_helper.rb +29 -0
  51. data/app/helpers/rails_pulse/status_helper.rb +279 -0
  52. data/app/helpers/rails_pulse/table_helper.rb +54 -0
  53. data/app/javascript/rails_pulse/application.js +119 -0
  54. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +20 -0
  55. data/app/javascript/rails_pulse/controllers/context_menu_controller.js +16 -0
  56. data/app/javascript/rails_pulse/controllers/dialog_controller.js +21 -0
  57. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +67 -0
  58. data/app/javascript/rails_pulse/controllers/form_controller.js +39 -0
  59. data/app/javascript/rails_pulse/controllers/icon_controller.js +170 -0
  60. data/app/javascript/rails_pulse/controllers/index_controller.js +230 -0
  61. data/app/javascript/rails_pulse/controllers/menu_controller.js +60 -0
  62. data/app/javascript/rails_pulse/controllers/pagination_controller.js +69 -0
  63. data/app/javascript/rails_pulse/controllers/popover_controller.js +91 -0
  64. data/app/javascript/rails_pulse/controllers/timezone_controller.js +106 -0
  65. data/app/javascript/rails_pulse/theme.js +416 -0
  66. data/app/jobs/rails_pulse/application_job.rb +4 -0
  67. data/app/jobs/rails_pulse/cleanup_job.rb +21 -0
  68. data/app/mailers/rails_pulse/application_mailer.rb +6 -0
  69. data/app/models/rails_pulse/application_record.rb +7 -0
  70. data/app/models/rails_pulse/component_cache_key.rb +33 -0
  71. data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +27 -0
  72. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +37 -0
  73. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +59 -0
  74. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +45 -0
  75. data/app/models/rails_pulse/operation.rb +87 -0
  76. data/app/models/rails_pulse/queries/cards/average_query_times.rb +52 -0
  77. data/app/models/rails_pulse/queries/cards/execution_rate.rb +57 -0
  78. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +71 -0
  79. data/app/models/rails_pulse/queries/charts/average_query_times.rb +112 -0
  80. data/app/models/rails_pulse/query.rb +58 -0
  81. data/app/models/rails_pulse/request.rb +64 -0
  82. data/app/models/rails_pulse/requests/charts/average_response_times.rb +99 -0
  83. data/app/models/rails_pulse/requests/charts/operations_chart.rb +35 -0
  84. data/app/models/rails_pulse/route.rb +77 -0
  85. data/app/models/rails_pulse/routes/cards/average_response_times.rb +54 -0
  86. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +73 -0
  87. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +73 -0
  88. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +59 -0
  89. data/app/models/rails_pulse/routes/charts/average_response_times.rb +115 -0
  90. data/app/models/rails_pulse/routes/tables/index.rb +63 -0
  91. data/app/services/rails_pulse/sql_query_normalizer.rb +124 -0
  92. data/app/views/layouts/rails_pulse/_menu_items.html.erb +19 -0
  93. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +44 -0
  94. data/app/views/layouts/rails_pulse/application.html.erb +72 -0
  95. data/app/views/rails_pulse/caches/show.html.erb +9 -0
  96. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +12 -0
  97. data/app/views/rails_pulse/components/_code_panel.html.erb +12 -0
  98. data/app/views/rails_pulse/components/_metric_card.html.erb +55 -0
  99. data/app/views/rails_pulse/components/_metric_row.html.erb +9 -0
  100. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +241 -0
  101. data/app/views/rails_pulse/components/_panel.html.erb +56 -0
  102. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +15 -0
  103. data/app/views/rails_pulse/components/_table.html.erb +50 -0
  104. data/app/views/rails_pulse/components/_table_head.html.erb +20 -0
  105. data/app/views/rails_pulse/components/_table_pagination.html.erb +45 -0
  106. data/app/views/rails_pulse/components/_time_period.html.erb +16 -0
  107. data/app/views/rails_pulse/csp_test/show.html.erb +207 -0
  108. data/app/views/rails_pulse/dashboard/charts/_bar_chart.html.erb +1 -0
  109. data/app/views/rails_pulse/dashboard/index.html.erb +64 -0
  110. data/app/views/rails_pulse/dashboard/tables/_routes_table.html.erb +32 -0
  111. data/app/views/rails_pulse/dashboard/tables/_standard_table.html.erb +1 -0
  112. data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +43 -0
  113. data/app/views/rails_pulse/operations/_operation_analysis_database.html.erb +12 -0
  114. data/app/views/rails_pulse/operations/_operation_analysis_generic.html.erb +15 -0
  115. data/app/views/rails_pulse/operations/_operation_analysis_other.html.erb +69 -0
  116. data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +39 -0
  117. data/app/views/rails_pulse/operations/show.html.erb +79 -0
  118. data/app/views/rails_pulse/queries/_show_table.html.erb +19 -0
  119. data/app/views/rails_pulse/queries/_table.html.erb +31 -0
  120. data/app/views/rails_pulse/queries/index.html.erb +64 -0
  121. data/app/views/rails_pulse/queries/show.html.erb +86 -0
  122. data/app/views/rails_pulse/requests/_operations.html.erb +85 -0
  123. data/app/views/rails_pulse/requests/_table.html.erb +31 -0
  124. data/app/views/rails_pulse/requests/index.html.erb +64 -0
  125. data/app/views/rails_pulse/requests/show.html.erb +44 -0
  126. data/app/views/rails_pulse/routes/_table.html.erb +29 -0
  127. data/app/views/rails_pulse/routes/index.html.erb +65 -0
  128. data/app/views/rails_pulse/routes/show.html.erb +67 -0
  129. data/app/views/rails_pulse/skeletons/_chart.html.erb +3 -0
  130. data/app/views/rails_pulse/skeletons/_metric_card.html.erb +20 -0
  131. data/app/views/rails_pulse/skeletons/_panel.html.erb +19 -0
  132. data/app/views/rails_pulse/skeletons/_table.html.erb +8 -0
  133. data/config/importmap.rb +12 -0
  134. data/config/initializers/rails_charts_csp_patch.rb +83 -0
  135. data/config/initializers/rails_pulse.rb +198 -0
  136. data/config/routes.rb +16 -0
  137. data/db/migrate/20250227235904_create_routes.rb +12 -0
  138. data/db/migrate/20250227235915_create_requests.rb +19 -0
  139. data/db/migrate/20250228000000_create_queries.rb +14 -0
  140. data/db/migrate/20250228000056_create_operations.rb +24 -0
  141. data/lib/generators/rails_pulse/install_generator.rb +17 -0
  142. data/lib/generators/rails_pulse/templates/rails_pulse.rb +198 -0
  143. data/lib/rails_pulse/cleanup_service.rb +212 -0
  144. data/lib/rails_pulse/configuration.rb +176 -0
  145. data/lib/rails_pulse/engine.rb +88 -0
  146. data/lib/rails_pulse/middleware/asset_server.rb +84 -0
  147. data/lib/rails_pulse/middleware/request_collector.rb +120 -0
  148. data/lib/rails_pulse/migration.rb +29 -0
  149. data/lib/rails_pulse/subscribers/operation_subscriber.rb +280 -0
  150. data/lib/rails_pulse/version.rb +3 -0
  151. data/lib/rails_pulse.rb +38 -0
  152. data/lib/tasks/rails_pulse_tasks.rake +138 -0
  153. data/public/rails-pulse-assets/csp-test.js +110 -0
  154. data/public/rails-pulse-assets/rails-pulse-icons.js +89 -0
  155. data/public/rails-pulse-assets/rails-pulse-icons.js.map +13 -0
  156. data/public/rails-pulse-assets/rails-pulse.css +1 -0
  157. data/public/rails-pulse-assets/rails-pulse.css.map +1 -0
  158. data/public/rails-pulse-assets/rails-pulse.js +183 -0
  159. data/public/rails-pulse-assets/rails-pulse.js.map +7 -0
  160. metadata +339 -0
@@ -0,0 +1,219 @@
1
+ module RailsPulse
2
+ class OperationsController < ApplicationController
3
+ before_action :set_operation, only: :show
4
+
5
+ def show
6
+ @request = @operation.request
7
+ @related_operations = find_related_operations
8
+ @performance_context = calculate_performance_context
9
+ @optimization_suggestions = generate_optimization_suggestions
10
+
11
+ respond_to do |format|
12
+ format.html
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def set_operation
19
+ @operation = Operation.find(params[:id])
20
+ end
21
+
22
+ def find_related_operations
23
+ case @operation.operation_type
24
+ when "sql"
25
+ # Find other SQL operations in the same request with similar queries
26
+ @operation.request.operations
27
+ .where(operation_type: [ "sql" ])
28
+ .where.not(id: @operation.id)
29
+ .limit(5)
30
+ when "template", "partial", "layout", "collection"
31
+ # Find other view operations in the same request
32
+ @operation.request.operations
33
+ .where(operation_type: [ "template", "partial", "layout", "collection" ])
34
+ .where.not(id: @operation.id)
35
+ .limit(5)
36
+ else
37
+ # Find operations of the same type in the same request
38
+ @operation.request.operations
39
+ .where(operation_type: @operation.operation_type)
40
+ .where.not(id: @operation.id)
41
+ .limit(5)
42
+ end
43
+ end
44
+
45
+ def calculate_performance_context
46
+ # Calculate percentiles and comparisons for this operation type
47
+ similar_operations = Operation.where(operation_type: @operation.operation_type)
48
+ .where("occurred_at >= ?", 7.days.ago)
49
+ .limit(1000)
50
+
51
+ return {} if similar_operations.empty?
52
+
53
+ durations = similar_operations.pluck(:duration).sort
54
+ total_count = durations.length
55
+
56
+ {
57
+ percentile_50: durations[(total_count * 0.5).floor] || 0,
58
+ percentile_75: durations[(total_count * 0.75).floor] || 0,
59
+ percentile_90: durations[(total_count * 0.9).floor] || 0,
60
+ percentile_95: durations[(total_count * 0.95).floor] || 0,
61
+ average: durations.sum / total_count.to_f,
62
+ count: total_count,
63
+ current_percentile: calculate_percentile(@operation.duration, durations)
64
+ }
65
+ end
66
+
67
+ def calculate_percentile(value, sorted_array)
68
+ return 0 if sorted_array.empty?
69
+
70
+ index = sorted_array.bsearch_index { |x| x >= value } || sorted_array.length
71
+ (index.to_f / sorted_array.length * 100).round(1)
72
+ end
73
+
74
+ def generate_optimization_suggestions
75
+ suggestions = []
76
+
77
+ case @operation.operation_type
78
+ when "sql"
79
+ suggestions.concat(sql_optimization_suggestions)
80
+ when "template", "partial", "layout", "collection"
81
+ suggestions.concat(view_optimization_suggestions)
82
+ when "controller"
83
+ suggestions.concat(controller_optimization_suggestions)
84
+ when "cache_read", "cache_write"
85
+ suggestions.concat(cache_optimization_suggestions)
86
+ when "http"
87
+ suggestions.concat(http_optimization_suggestions)
88
+ end
89
+
90
+ suggestions
91
+ end
92
+
93
+ def sql_optimization_suggestions
94
+ suggestions = []
95
+
96
+ if @operation.duration > 100
97
+ suggestions << {
98
+ type: "performance",
99
+ icon: "zap",
100
+ title: "Slow Query Detected",
101
+ description: "This query took #{@operation.duration.round(2)}ms. Consider adding database indexes or optimizing the query.",
102
+ priority: "high"
103
+ }
104
+ end
105
+
106
+ if @operation.label&.match?(/SELECT.*FROM\s+(\w+)/i)
107
+ table_name = @operation.label.match(/FROM\s+(\w+)/i)&.captures&.first
108
+ if table_name
109
+ suggestions << {
110
+ type: "index",
111
+ icon: "database",
112
+ title: "Index Optimization",
113
+ description: "Review indexes on the '#{table_name}' table. Consider composite indexes for WHERE clauses.",
114
+ priority: "medium"
115
+ }
116
+ end
117
+ end
118
+
119
+ # 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
+
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
+ }
133
+ end
134
+
135
+ suggestions
136
+ end
137
+
138
+ def view_optimization_suggestions
139
+ suggestions = []
140
+
141
+ if @operation.duration > 100
142
+ suggestions << {
143
+ type: "performance",
144
+ icon: "zap",
145
+ title: "Slow View Rendering",
146
+ description: "This view took #{@operation.duration.round(2)}ms to render. Consider fragment caching or reducing database calls.",
147
+ priority: "high"
148
+ }
149
+ end
150
+
151
+ # 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)
157
+
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
+ }
166
+ end
167
+
168
+ suggestions
169
+ end
170
+
171
+ def controller_optimization_suggestions
172
+ suggestions = []
173
+
174
+ if @operation.duration > 500
175
+ suggestions << {
176
+ type: "performance",
177
+ icon: "zap",
178
+ title: "Slow Controller Action",
179
+ description: "This action took #{@operation.duration.round(2)}ms. Consider moving heavy computation to background jobs.",
180
+ priority: "high"
181
+ }
182
+ end
183
+
184
+ suggestions
185
+ end
186
+
187
+ def cache_optimization_suggestions
188
+ suggestions = []
189
+
190
+ if @operation.operation_type == "cache_read" && @operation.duration > 10
191
+ suggestions << {
192
+ type: "performance",
193
+ icon: "clock",
194
+ title: "Slow Cache Read",
195
+ description: "Cache read took #{@operation.duration.round(2)}ms. Check cache backend performance.",
196
+ priority: "medium"
197
+ }
198
+ end
199
+
200
+ suggestions
201
+ end
202
+
203
+ def http_optimization_suggestions
204
+ suggestions = []
205
+
206
+ if @operation.duration > 1000
207
+ suggestions << {
208
+ type: "performance",
209
+ icon: "globe",
210
+ title: "Slow External Request",
211
+ description: "HTTP request took #{@operation.duration.round(2)}ms. Consider caching responses or using background jobs.",
212
+ priority: "high"
213
+ }
214
+ end
215
+
216
+ suggestions
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,121 @@
1
+ module RailsPulse
2
+ class QueriesController < ApplicationController
3
+ include ChartTableConcern
4
+
5
+ before_action :set_query, only: :show
6
+
7
+ def index
8
+ setup_chart_and_table_data
9
+ end
10
+
11
+ def show
12
+ setup_chart_and_table_data
13
+ end
14
+
15
+ private
16
+
17
+ def chart_model
18
+ show_action? ? Operation : Query
19
+ end
20
+
21
+ def table_model
22
+ show_action? ? Operation : Query
23
+ end
24
+
25
+ def chart_class
26
+ Queries::Charts::AverageQueryTimes
27
+ end
28
+
29
+ def chart_options
30
+ show_action? ? { query: @query } : {}
31
+ end
32
+
33
+ def build_chart_ransack_params(ransack_params)
34
+ base_params = ransack_params.except(:s)
35
+
36
+ if show_action?
37
+ base_params.merge(
38
+ query_id_eq: @query.id,
39
+ occurred_at_gteq: @start_time,
40
+ occurred_at_lt: @end_time,
41
+ duration_gteq: @start_duration
42
+ )
43
+ else
44
+ base_params.merge(
45
+ operations_occurred_at_gteq: @start_time,
46
+ operations_occurred_at_lt: @end_time,
47
+ operations_duration_gteq: @start_duration
48
+ )
49
+ end
50
+ end
51
+
52
+ def build_table_ransack_params(ransack_params)
53
+ if show_action?
54
+ ransack_params.merge(
55
+ query_id_eq: @query.id,
56
+ occurred_at_gteq: @table_start_time,
57
+ occurred_at_lt: @table_end_time,
58
+ duration_gteq: @start_duration
59
+ )
60
+ else
61
+ ransack_params.merge(
62
+ operations_occurred_at_gteq: @table_start_time,
63
+ operations_occurred_at_lt: @table_end_time,
64
+ operations_duration_gteq: @start_duration
65
+ )
66
+ end
67
+ end
68
+
69
+ def default_table_sort
70
+ "occurred_at desc"
71
+ end
72
+
73
+ def build_table_results
74
+ if show_action?
75
+ @ransack_query.result.select("id", "occurred_at", "duration")
76
+ else
77
+ # Optimized query: Use INNER JOIN since we only want queries with operations in time range
78
+ # This dramatically reduces the dataset before aggregation
79
+ @ransack_query.result(distinct: false)
80
+ .joins("INNER JOIN rails_pulse_operations ON rails_pulse_operations.query_id = rails_pulse_queries.id")
81
+ .where("rails_pulse_operations.occurred_at >= ? AND rails_pulse_operations.occurred_at < ?",
82
+ @table_start_time, @table_end_time)
83
+ .group("rails_pulse_queries.id, rails_pulse_queries.normalized_sql, rails_pulse_queries.created_at, rails_pulse_queries.updated_at")
84
+ .select(
85
+ "rails_pulse_queries.*",
86
+ optimized_aggregations_sql
87
+ )
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def optimized_aggregations_sql
94
+ # Efficient aggregations that work with our composite indexes
95
+ [
96
+ "COALESCE(AVG(rails_pulse_operations.duration), 0) AS average_query_time_ms",
97
+ "COUNT(rails_pulse_operations.id) AS execution_count",
98
+ "COALESCE(SUM(rails_pulse_operations.duration), 0) AS total_time_consumed",
99
+ "MAX(rails_pulse_operations.occurred_at) AS occurred_at"
100
+ ].join(", ")
101
+ end
102
+
103
+ def show_action?
104
+ action_name == "show"
105
+ end
106
+
107
+ def pagination_method
108
+ show_action? ? :set_pagination_limit : :store_pagination_limit
109
+ end
110
+
111
+ def set_query
112
+ @query = Query.find(params[:id])
113
+ end
114
+
115
+ def setup_metic_cards
116
+ @average_query_times_card = Queries::Cards::AverageQueryTimes.new(query: @query).to_metric_card
117
+ @percentile_response_times_card = Queries::Cards::PercentileQueryTimes.new(query: @query).to_metric_card
118
+ @execution_rate_card = Queries::Cards::ExecutionRate.new(query: @query).to_metric_card
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,69 @@
1
+ module RailsPulse
2
+ class RequestsController < ApplicationController
3
+ include ChartTableConcern
4
+
5
+ before_action :set_request, only: :show
6
+
7
+ def index
8
+ setup_chart_and_table_data
9
+ end
10
+
11
+ def show
12
+ @operation_timeline = RailsPulse::Requests::Charts::OperationsChart.new(@request.operations)
13
+ end
14
+
15
+ private
16
+
17
+ def chart_model
18
+ Request
19
+ end
20
+
21
+ def table_model
22
+ Request
23
+ end
24
+
25
+ def chart_class
26
+ Requests::Charts::AverageResponseTimes
27
+ end
28
+
29
+ def chart_options
30
+ { route: true }
31
+ end
32
+
33
+ def build_chart_ransack_params(ransack_params)
34
+ ransack_params.except(:s).merge(
35
+ occurred_at_gteq: @start_time,
36
+ occurred_at_lt: @end_time,
37
+ duration_gteq: @start_duration
38
+ )
39
+ end
40
+
41
+ def build_table_ransack_params(ransack_params)
42
+ ransack_params.merge(
43
+ occurred_at_gteq: @table_start_time,
44
+ occurred_at_lt: @table_end_time,
45
+ duration_gteq: @start_duration
46
+ )
47
+ end
48
+
49
+ def default_table_sort
50
+ "occurred_at desc"
51
+ end
52
+
53
+ def build_table_results
54
+ @ransack_query.result
55
+ .includes(:route)
56
+ .select(
57
+ "rails_pulse_requests.id",
58
+ "rails_pulse_requests.occurred_at",
59
+ "rails_pulse_requests.duration",
60
+ "rails_pulse_requests.status",
61
+ "rails_pulse_requests.route_id"
62
+ )
63
+ end
64
+
65
+ def set_request
66
+ @request = Request.find(params[:id])
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,99 @@
1
+ module RailsPulse
2
+ class RoutesController < ApplicationController
3
+ include ChartTableConcern
4
+
5
+ before_action :set_route, only: :show
6
+
7
+ def index
8
+ setup_chart_and_table_data
9
+ end
10
+
11
+ def show
12
+ setup_chart_and_table_data
13
+ end
14
+
15
+ private
16
+
17
+ def chart_model
18
+ show_action? ? Request : Route
19
+ end
20
+
21
+ def table_model
22
+ show_action? ? Request : Route
23
+ end
24
+
25
+ def chart_class
26
+ Routes::Charts::AverageResponseTimes
27
+ end
28
+
29
+ def chart_options
30
+ show_action? ? { route: @route } : {}
31
+ end
32
+
33
+ def build_chart_ransack_params(ransack_params)
34
+ base_params = ransack_params.except(:s).merge(duration_field => @start_duration)
35
+
36
+ if show_action?
37
+ base_params.merge(
38
+ route_id_eq: @route.id,
39
+ occurred_at_gteq: @start_time,
40
+ occurred_at_lt: @end_time
41
+ )
42
+ else
43
+ base_params.merge(
44
+ requests_occurred_at_gteq: @start_time,
45
+ requests_occurred_at_lt: @end_time
46
+ )
47
+ end
48
+ end
49
+
50
+ def build_table_ransack_params(ransack_params)
51
+ base_params = ransack_params.merge(duration_field => @start_duration)
52
+
53
+ if show_action?
54
+ base_params.merge(
55
+ route_id_eq: @route.id,
56
+ occurred_at_gteq: @table_start_time,
57
+ occurred_at_lt: @table_end_time
58
+ )
59
+ else
60
+ base_params.merge(
61
+ requests_occurred_at_gteq: @table_start_time,
62
+ requests_occurred_at_lt: @table_end_time
63
+ )
64
+ end
65
+ end
66
+
67
+ def default_table_sort
68
+ show_action? ? "occurred_at desc" : "average_response_time_ms desc"
69
+ end
70
+
71
+ def build_table_results
72
+ if show_action?
73
+ @ransack_query.result.select("id", "route_id", "occurred_at", "duration", "status")
74
+ else
75
+ Routes::Tables::Index.new(
76
+ ransack_query: @ransack_query,
77
+ start_time: @start_time,
78
+ params: params
79
+ ).to_table
80
+ end
81
+ end
82
+
83
+ def duration_field
84
+ show_action? ? :duration_gteq : :requests_duration_gteq
85
+ end
86
+
87
+ def show_action?
88
+ action_name == "show"
89
+ end
90
+
91
+ def pagination_method
92
+ show_action? ? :set_pagination_limit : :store_pagination_limit
93
+ end
94
+
95
+ def set_route
96
+ @route = Route.find(params[:id])
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,111 @@
1
+ module RailsPulse
2
+ module ApplicationHelper
3
+ include Pagy::Frontend
4
+
5
+ include BreadcrumbsHelper
6
+ include CachedComponentHelper
7
+ include ChartHelper
8
+ include FormattingHelper
9
+ include StatusHelper
10
+ include TableHelper
11
+
12
+ # Replacement for lucide_icon helper that works with pre-compiled assets
13
+ # Outputs a custom element that will be hydrated by Stimulus
14
+ def rails_pulse_icon(name, options = {})
15
+ width = options[:width] || options["width"] || 24
16
+ height = options[:height] || options["height"] || 24
17
+ css_class = options[:class] || options["class"] || ""
18
+
19
+ # Additional HTML attributes
20
+ attrs = options.except(:width, :height, :class, "width", "height", "class")
21
+
22
+ content_tag("rails-pulse-icon",
23
+ "",
24
+ data: {
25
+ controller: "rails-pulse--icon",
26
+ 'rails-pulse--icon-name-value': name,
27
+ 'rails-pulse--icon-width-value': width,
28
+ 'rails-pulse--icon-height-value': height
29
+ },
30
+ class: css_class,
31
+ **attrs
32
+ )
33
+ end
34
+
35
+ # Backward compatibility alias - can be removed after migration
36
+ alias_method :lucide_icon, :rails_pulse_icon
37
+
38
+ # Make Rails Pulse routes available as rails_pulse in views
39
+ def rails_pulse
40
+ @rails_pulse_helper ||= RailsPulseHelper.new(self)
41
+ end
42
+
43
+ # Helper class to provide both routes and asset methods
44
+ class RailsPulseHelper
45
+ def initialize(view_context)
46
+ @view_context = view_context
47
+ end
48
+
49
+ # Delegate route methods to engine routes
50
+ def method_missing(method, *args, &block)
51
+ if RailsPulse::Engine.routes.url_helpers.respond_to?(method)
52
+ RailsPulse::Engine.routes.url_helpers.send(method, *args, &block)
53
+ else
54
+ super
55
+ end
56
+ end
57
+
58
+ def respond_to_missing?(method, include_private = false)
59
+ RailsPulse::Engine.routes.url_helpers.respond_to?(method, include_private) || super
60
+ end
61
+
62
+ # Generate asset paths that work with our custom asset serving
63
+ def asset_path(asset_name)
64
+ "/rails-pulse-assets/#{asset_name}"
65
+ end
66
+ end
67
+
68
+ # CSP nonce helper for Rails Pulse
69
+ def rails_pulse_csp_nonce
70
+ # Try various methods to get the CSP nonce from the host application
71
+ nonce = nil
72
+
73
+ # Method 1: Check for Rails 6+ CSP nonce helper
74
+ if respond_to?(:content_security_policy_nonce)
75
+ nonce = content_security_policy_nonce
76
+ end
77
+
78
+ # Method 2: Check for custom csp_nonce helper (common in many apps)
79
+ if nonce.blank? && respond_to?(:csp_nonce)
80
+ nonce = csp_nonce
81
+ end
82
+
83
+ # Method 3: Try to extract from request environment (where CSP gems often store it)
84
+ if nonce.blank? && defined?(request) && request
85
+ nonce = request.env["action_dispatch.content_security_policy_nonce"] ||
86
+ request.env["secure_headers.content_security_policy_nonce"] ||
87
+ request.env["csp_nonce"]
88
+ end
89
+
90
+ # Method 4: Check content_for CSP nonce (some apps set it this way)
91
+ if nonce.blank? && respond_to?(:content_for) && content_for?(:csp_nonce)
92
+ nonce = content_for(:csp_nonce)
93
+ end
94
+
95
+ # Method 5: Extract from meta tag if present (less efficient but works)
96
+ if nonce.blank? && defined?(content_security_policy_nonce_tag)
97
+ begin
98
+ tag_content = content_security_policy_nonce_tag
99
+ if tag_content && tag_content.include?("nonce-")
100
+ nonce = tag_content.match(/nonce-([^"']+)/)[1] if tag_content.match(/nonce-([^"']+)/)
101
+ end
102
+ rescue
103
+ # Ignore parsing errors
104
+ end
105
+ end
106
+
107
+ # Return the nonce or nil (Rails will handle CSP properly with nil)
108
+ nonce.presence
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,62 @@
1
+ module RailsPulse
2
+ module BreadcrumbsHelper
3
+ def breadcrumbs
4
+ # Get the engine's mount point by removing the leading slash and splitting
5
+ mount_point = RailsPulse::Engine.routes.find_script_name({}).sub(/^\//, "")
6
+
7
+ # Split the full path and remove empty segments
8
+ path_segments = request.path.split("/").reject(&:empty?)
9
+
10
+ # Find the index of the mount point in the path segments
11
+ mount_point_index = path_segments.index(mount_point)
12
+
13
+ # If we can't find the mount point or it's the last segment, return empty
14
+ return [] if mount_point_index.nil? || mount_point_index == path_segments.length - 1
15
+
16
+ # Only keep segments after the mount point
17
+ path_segments = path_segments[(mount_point_index + 1)..-1]
18
+
19
+ # Start with the Home link
20
+ crumbs = [ {
21
+ title: "Home",
22
+ path: main_app.rails_pulse_path,
23
+ current: path_segments.empty?
24
+ } ]
25
+
26
+ return crumbs if path_segments.empty?
27
+
28
+ current_path = "/rails_pulse"
29
+
30
+ path_segments.each_with_index do |segment, index|
31
+ current_path += "/#{segment}"
32
+
33
+ # Convert segment to a more readable format
34
+ title = if segment =~ /^\d+$/
35
+ # If it's a numeric ID, try to find a title from the resource
36
+ resource_name = path_segments[index - 1]&.singularize
37
+ # Look up the class in the RailsPulse namespace
38
+ resource_class = "RailsPulse::#{resource_name&.classify}".safe_constantize
39
+ if resource_class
40
+ resource = resource_class.find(segment)
41
+ # Try to_breadcrumb first, fall back to to_s
42
+ resource.try(:to_breadcrumb) || resource.to_s
43
+ else
44
+ segment
45
+ end
46
+ else
47
+ segment.titleize
48
+ end
49
+
50
+ is_last = index == path_segments.length - 1
51
+
52
+ crumbs << {
53
+ title: title,
54
+ path: current_path,
55
+ current: is_last
56
+ }
57
+ end
58
+
59
+ crumbs
60
+ end
61
+ end
62
+ end