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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +638 -0
- data/Rakefile +207 -0
- data/app/assets/images/rails_pulse/dashboard.png +0 -0
- data/app/assets/images/rails_pulse/menu.svg +1 -0
- data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
- data/app/assets/images/rails_pulse/request.png +0 -0
- data/app/assets/images/rails_pulse/routes.png +0 -0
- data/app/assets/stylesheets/rails_pulse/application.css +102 -0
- data/app/assets/stylesheets/rails_pulse/components/alert.css +24 -0
- data/app/assets/stylesheets/rails_pulse/components/badge.css +58 -0
- data/app/assets/stylesheets/rails_pulse/components/base.css +79 -0
- data/app/assets/stylesheets/rails_pulse/components/breadcrumb.css +31 -0
- data/app/assets/stylesheets/rails_pulse/components/button.css +99 -0
- data/app/assets/stylesheets/rails_pulse/components/card.css +19 -0
- data/app/assets/stylesheets/rails_pulse/components/chart.css +18 -0
- data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +86 -0
- data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +9 -0
- data/app/assets/stylesheets/rails_pulse/components/dialog.css +56 -0
- data/app/assets/stylesheets/rails_pulse/components/flash.css +47 -0
- data/app/assets/stylesheets/rails_pulse/components/input.css +80 -0
- data/app/assets/stylesheets/rails_pulse/components/layouts.css +63 -0
- data/app/assets/stylesheets/rails_pulse/components/menu.css +43 -0
- data/app/assets/stylesheets/rails_pulse/components/popover.css +36 -0
- data/app/assets/stylesheets/rails_pulse/components/prose.css +144 -0
- data/app/assets/stylesheets/rails_pulse/components/row.css +24 -0
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +79 -0
- data/app/assets/stylesheets/rails_pulse/components/skeleton.css +5 -0
- data/app/assets/stylesheets/rails_pulse/components/table.css +37 -0
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +36 -0
- data/app/controllers/concerns/chart_table_concern.rb +82 -0
- data/app/controllers/concerns/response_range_concern.rb +24 -0
- data/app/controllers/concerns/time_range_concern.rb +67 -0
- data/app/controllers/concerns/zoom_range_concern.rb +40 -0
- data/app/controllers/rails_pulse/application_controller.rb +67 -0
- data/app/controllers/rails_pulse/assets_controller.rb +33 -0
- data/app/controllers/rails_pulse/caches_controller.rb +115 -0
- data/app/controllers/rails_pulse/csp_test_controller.rb +57 -0
- data/app/controllers/rails_pulse/dashboard_controller.rb +6 -0
- data/app/controllers/rails_pulse/operations_controller.rb +219 -0
- data/app/controllers/rails_pulse/queries_controller.rb +121 -0
- data/app/controllers/rails_pulse/requests_controller.rb +69 -0
- data/app/controllers/rails_pulse/routes_controller.rb +99 -0
- data/app/helpers/rails_pulse/application_helper.rb +111 -0
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +62 -0
- data/app/helpers/rails_pulse/cached_component_helper.rb +73 -0
- data/app/helpers/rails_pulse/chart_formatters.rb +43 -0
- data/app/helpers/rails_pulse/chart_helper.rb +140 -0
- data/app/helpers/rails_pulse/formatting_helper.rb +29 -0
- data/app/helpers/rails_pulse/status_helper.rb +279 -0
- data/app/helpers/rails_pulse/table_helper.rb +54 -0
- data/app/javascript/rails_pulse/application.js +119 -0
- data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +20 -0
- data/app/javascript/rails_pulse/controllers/context_menu_controller.js +16 -0
- data/app/javascript/rails_pulse/controllers/dialog_controller.js +21 -0
- data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +67 -0
- data/app/javascript/rails_pulse/controllers/form_controller.js +39 -0
- data/app/javascript/rails_pulse/controllers/icon_controller.js +170 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +230 -0
- data/app/javascript/rails_pulse/controllers/menu_controller.js +60 -0
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +69 -0
- data/app/javascript/rails_pulse/controllers/popover_controller.js +91 -0
- data/app/javascript/rails_pulse/controllers/timezone_controller.js +106 -0
- data/app/javascript/rails_pulse/theme.js +416 -0
- data/app/jobs/rails_pulse/application_job.rb +4 -0
- data/app/jobs/rails_pulse/cleanup_job.rb +21 -0
- data/app/mailers/rails_pulse/application_mailer.rb +6 -0
- data/app/models/rails_pulse/application_record.rb +7 -0
- data/app/models/rails_pulse/component_cache_key.rb +33 -0
- data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +27 -0
- data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +37 -0
- data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +59 -0
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +45 -0
- data/app/models/rails_pulse/operation.rb +87 -0
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +52 -0
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +57 -0
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +71 -0
- data/app/models/rails_pulse/queries/charts/average_query_times.rb +112 -0
- data/app/models/rails_pulse/query.rb +58 -0
- data/app/models/rails_pulse/request.rb +64 -0
- data/app/models/rails_pulse/requests/charts/average_response_times.rb +99 -0
- data/app/models/rails_pulse/requests/charts/operations_chart.rb +35 -0
- data/app/models/rails_pulse/route.rb +77 -0
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +54 -0
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +73 -0
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +73 -0
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +59 -0
- data/app/models/rails_pulse/routes/charts/average_response_times.rb +115 -0
- data/app/models/rails_pulse/routes/tables/index.rb +63 -0
- data/app/services/rails_pulse/sql_query_normalizer.rb +124 -0
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +19 -0
- data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +44 -0
- data/app/views/layouts/rails_pulse/application.html.erb +72 -0
- data/app/views/rails_pulse/caches/show.html.erb +9 -0
- data/app/views/rails_pulse/components/_breadcrumbs.html.erb +12 -0
- data/app/views/rails_pulse/components/_code_panel.html.erb +12 -0
- data/app/views/rails_pulse/components/_metric_card.html.erb +55 -0
- data/app/views/rails_pulse/components/_metric_row.html.erb +9 -0
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +241 -0
- data/app/views/rails_pulse/components/_panel.html.erb +56 -0
- data/app/views/rails_pulse/components/_sparkline_stats.html.erb +15 -0
- data/app/views/rails_pulse/components/_table.html.erb +50 -0
- data/app/views/rails_pulse/components/_table_head.html.erb +20 -0
- data/app/views/rails_pulse/components/_table_pagination.html.erb +45 -0
- data/app/views/rails_pulse/components/_time_period.html.erb +16 -0
- data/app/views/rails_pulse/csp_test/show.html.erb +207 -0
- data/app/views/rails_pulse/dashboard/charts/_bar_chart.html.erb +1 -0
- data/app/views/rails_pulse/dashboard/index.html.erb +64 -0
- data/app/views/rails_pulse/dashboard/tables/_routes_table.html.erb +32 -0
- data/app/views/rails_pulse/dashboard/tables/_standard_table.html.erb +1 -0
- data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +43 -0
- data/app/views/rails_pulse/operations/_operation_analysis_database.html.erb +12 -0
- data/app/views/rails_pulse/operations/_operation_analysis_generic.html.erb +15 -0
- data/app/views/rails_pulse/operations/_operation_analysis_other.html.erb +69 -0
- data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +39 -0
- data/app/views/rails_pulse/operations/show.html.erb +79 -0
- data/app/views/rails_pulse/queries/_show_table.html.erb +19 -0
- data/app/views/rails_pulse/queries/_table.html.erb +31 -0
- data/app/views/rails_pulse/queries/index.html.erb +64 -0
- data/app/views/rails_pulse/queries/show.html.erb +86 -0
- data/app/views/rails_pulse/requests/_operations.html.erb +85 -0
- data/app/views/rails_pulse/requests/_table.html.erb +31 -0
- data/app/views/rails_pulse/requests/index.html.erb +64 -0
- data/app/views/rails_pulse/requests/show.html.erb +44 -0
- data/app/views/rails_pulse/routes/_table.html.erb +29 -0
- data/app/views/rails_pulse/routes/index.html.erb +65 -0
- data/app/views/rails_pulse/routes/show.html.erb +67 -0
- data/app/views/rails_pulse/skeletons/_chart.html.erb +3 -0
- data/app/views/rails_pulse/skeletons/_metric_card.html.erb +20 -0
- data/app/views/rails_pulse/skeletons/_panel.html.erb +19 -0
- data/app/views/rails_pulse/skeletons/_table.html.erb +8 -0
- data/config/importmap.rb +12 -0
- data/config/initializers/rails_charts_csp_patch.rb +83 -0
- data/config/initializers/rails_pulse.rb +198 -0
- data/config/routes.rb +16 -0
- data/db/migrate/20250227235904_create_routes.rb +12 -0
- data/db/migrate/20250227235915_create_requests.rb +19 -0
- data/db/migrate/20250228000000_create_queries.rb +14 -0
- data/db/migrate/20250228000056_create_operations.rb +24 -0
- data/lib/generators/rails_pulse/install_generator.rb +17 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +198 -0
- data/lib/rails_pulse/cleanup_service.rb +212 -0
- data/lib/rails_pulse/configuration.rb +176 -0
- data/lib/rails_pulse/engine.rb +88 -0
- data/lib/rails_pulse/middleware/asset_server.rb +84 -0
- data/lib/rails_pulse/middleware/request_collector.rb +120 -0
- data/lib/rails_pulse/migration.rb +29 -0
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +280 -0
- data/lib/rails_pulse/version.rb +3 -0
- data/lib/rails_pulse.rb +38 -0
- data/lib/tasks/rails_pulse_tasks.rake +138 -0
- data/public/rails-pulse-assets/csp-test.js +110 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js +89 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +13 -0
- data/public/rails-pulse-assets/rails-pulse.css +1 -0
- data/public/rails-pulse-assets/rails-pulse.css.map +1 -0
- data/public/rails-pulse-assets/rails-pulse.js +183 -0
- data/public/rails-pulse-assets/rails-pulse.js.map +7 -0
- 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
|