rails_pulse 0.1.1 → 0.1.3

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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +79 -177
  3. data/Rakefile +77 -2
  4. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  5. data/app/assets/images/rails_pulse/request.png +0 -0
  6. data/app/assets/stylesheets/rails_pulse/application.css +28 -17
  7. data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
  8. data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
  9. data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
  10. data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
  11. data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
  12. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
  13. data/app/controllers/concerns/chart_table_concern.rb +21 -4
  14. data/app/controllers/concerns/response_range_concern.rb +6 -3
  15. data/app/controllers/concerns/time_range_concern.rb +5 -10
  16. data/app/controllers/concerns/zoom_range_concern.rb +32 -1
  17. data/app/controllers/rails_pulse/application_controller.rb +13 -5
  18. data/app/controllers/rails_pulse/dashboard_controller.rb +12 -0
  19. data/app/controllers/rails_pulse/queries_controller.rb +111 -51
  20. data/app/controllers/rails_pulse/requests_controller.rb +37 -12
  21. data/app/controllers/rails_pulse/routes_controller.rb +98 -24
  22. data/app/helpers/rails_pulse/application_helper.rb +0 -1
  23. data/app/helpers/rails_pulse/chart_formatters.rb +3 -3
  24. data/app/helpers/rails_pulse/chart_helper.rb +21 -9
  25. data/app/helpers/rails_pulse/status_helper.rb +10 -4
  26. data/app/javascript/rails_pulse/application.js +34 -3
  27. data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
  28. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
  29. data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
  30. data/app/javascript/rails_pulse/controllers/index_controller.js +353 -39
  31. data/app/javascript/rails_pulse/controllers/pagination_controller.js +17 -27
  32. data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
  33. data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
  34. data/app/jobs/rails_pulse/backfill_summaries_job.rb +41 -0
  35. data/app/jobs/rails_pulse/summary_job.rb +53 -0
  36. data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +28 -7
  37. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +18 -22
  38. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +18 -7
  39. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +34 -41
  40. data/app/models/rails_pulse/operation.rb +1 -1
  41. data/app/models/rails_pulse/queries/cards/average_query_times.rb +49 -25
  42. data/app/models/rails_pulse/queries/cards/execution_rate.rb +40 -28
  43. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +37 -43
  44. data/app/models/rails_pulse/queries/charts/average_query_times.rb +23 -97
  45. data/app/models/rails_pulse/queries/tables/index.rb +74 -0
  46. data/app/models/rails_pulse/query.rb +47 -0
  47. data/app/models/rails_pulse/requests/charts/average_response_times.rb +23 -84
  48. data/app/models/rails_pulse/route.rb +1 -6
  49. data/app/models/rails_pulse/routes/cards/average_response_times.rb +45 -25
  50. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +43 -45
  51. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +36 -44
  52. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +37 -27
  53. data/app/models/rails_pulse/routes/charts/average_response_times.rb +23 -100
  54. data/app/models/rails_pulse/routes/tables/index.rb +57 -40
  55. data/app/models/rails_pulse/summary.rb +143 -0
  56. data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
  57. data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
  58. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
  59. data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
  60. data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
  61. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +146 -0
  62. data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
  63. data/app/services/rails_pulse/query_analysis_service.rb +125 -0
  64. data/app/services/rails_pulse/summary_service.rb +199 -0
  65. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
  66. data/app/views/layouts/rails_pulse/application.html.erb +4 -6
  67. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
  68. data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
  69. data/app/views/rails_pulse/components/_empty_state.html.erb +11 -0
  70. data/app/views/rails_pulse/components/_metric_card.html.erb +37 -28
  71. data/app/views/rails_pulse/components/_panel.html.erb +1 -1
  72. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
  73. data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
  74. data/app/views/rails_pulse/dashboard/index.html.erb +55 -37
  75. data/app/views/rails_pulse/operations/show.html.erb +17 -15
  76. data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
  77. data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
  78. data/app/views/rails_pulse/queries/_analysis_results.html.erb +87 -0
  79. data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
  80. data/app/views/rails_pulse/queries/_show_table.html.erb +2 -2
  81. data/app/views/rails_pulse/queries/_table.html.erb +11 -13
  82. data/app/views/rails_pulse/queries/index.html.erb +32 -28
  83. data/app/views/rails_pulse/queries/show.html.erb +45 -34
  84. data/app/views/rails_pulse/requests/_operations.html.erb +38 -45
  85. data/app/views/rails_pulse/requests/_table.html.erb +3 -3
  86. data/app/views/rails_pulse/requests/index.html.erb +33 -28
  87. data/app/views/rails_pulse/routes/_table.html.erb +14 -14
  88. data/app/views/rails_pulse/routes/index.html.erb +34 -29
  89. data/app/views/rails_pulse/routes/show.html.erb +43 -36
  90. data/config/initializers/rails_pulse.rb +0 -12
  91. data/config/routes.rb +5 -1
  92. data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +54 -0
  93. data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +13 -0
  94. data/db/rails_pulse_schema.rb +130 -0
  95. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +65 -0
  96. data/lib/generators/rails_pulse/install_generator.rb +94 -4
  97. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +60 -0
  98. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +22 -0
  99. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
  100. data/lib/generators/rails_pulse/templates/rails_pulse.rb +0 -12
  101. data/lib/generators/rails_pulse/upgrade_generator.rb +225 -0
  102. data/lib/rails_pulse/configuration.rb +0 -11
  103. data/lib/rails_pulse/engine.rb +0 -1
  104. data/lib/rails_pulse/version.rb +1 -1
  105. data/lib/tasks/rails_pulse.rake +77 -0
  106. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  107. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  108. data/public/rails-pulse-assets/rails-pulse.js +53 -53
  109. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  110. data/public/rails-pulse-assets/search.svg +43 -0
  111. metadata +48 -14
  112. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  113. data/app/assets/images/rails_pulse/routes.png +0 -0
  114. data/app/controllers/rails_pulse/caches_controller.rb +0 -115
  115. data/app/helpers/rails_pulse/cached_component_helper.rb +0 -73
  116. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
  117. data/app/models/rails_pulse/component_cache_key.rb +0 -33
  118. data/app/views/rails_pulse/caches/show.html.erb +0 -9
  119. data/db/migrate/20250227235904_create_routes.rb +0 -12
  120. data/db/migrate/20250227235915_create_requests.rb +0 -19
  121. data/db/migrate/20250228000000_create_queries.rb +0 -14
  122. data/db/migrate/20250228000056_create_operations.rb +0 -24
  123. data/lib/rails_pulse/migration.rb +0 -29
@@ -0,0 +1,217 @@
1
+ # Consolidates analysis results into prioritized, actionable optimization suggestions.
2
+ # Combines insights from all analyzers and categorizes suggestions by impact and implementation complexity.
3
+ module RailsPulse
4
+ module Analysis
5
+ class SuggestionGenerator
6
+ attr_reader :analysis_results
7
+
8
+ def initialize(analysis_results)
9
+ @analysis_results = analysis_results
10
+ end
11
+
12
+ def generate
13
+ suggestions = []
14
+
15
+ # Suggestions from pattern issues
16
+ suggestions.concat(generate_issue_suggestions)
17
+
18
+ # Suggestions from index recommendations
19
+ suggestions.concat(generate_index_suggestions)
20
+
21
+ # Suggestions from N+1 analysis
22
+ suggestions.concat(generate_n_plus_one_suggestions)
23
+
24
+ # Suggestions from query characteristics
25
+ suggestions.concat(generate_query_characteristic_suggestions)
26
+
27
+ # Suggestions from explain plan issues
28
+ suggestions.concat(generate_explain_plan_suggestions)
29
+
30
+ # Prioritize and deduplicate suggestions
31
+ prioritize_suggestions(suggestions)
32
+ end
33
+
34
+ private
35
+
36
+ def generate_issue_suggestions
37
+ issues = analysis_results.dig(:query_characteristics, :pattern_issues) || []
38
+
39
+ issues.map do |issue|
40
+ case issue[:type]
41
+ when "select_star"
42
+ {
43
+ type: "optimization",
44
+ action: "Specify only needed columns instead of SELECT *",
45
+ benefit: "Reduced memory usage and faster data transfer",
46
+ priority: "medium",
47
+ category: "sql_optimization"
48
+ }
49
+ when "missing_limit"
50
+ {
51
+ type: "optimization",
52
+ action: "Add LIMIT clause to prevent large result sets",
53
+ benefit: "Controlled memory usage and faster response times",
54
+ priority: "high",
55
+ category: "sql_optimization"
56
+ }
57
+ when "missing_where_clause"
58
+ {
59
+ type: "optimization",
60
+ action: "Add WHERE clause to filter results",
61
+ benefit: "Avoid full table scans and reduce data transfer",
62
+ priority: "high",
63
+ category: "sql_optimization"
64
+ }
65
+ when "complex_where_clause"
66
+ {
67
+ type: "refactoring",
68
+ action: "Simplify WHERE clause by breaking into multiple queries or using views",
69
+ benefit: "Easier maintenance and potentially better performance",
70
+ priority: "medium",
71
+ category: "code_quality"
72
+ }
73
+ end
74
+ end.compact
75
+ end
76
+
77
+ def generate_index_suggestions
78
+ recommendations = analysis_results[:index_recommendations] || []
79
+
80
+ recommendations.map do |rec|
81
+ {
82
+ type: "index",
83
+ action: "Add #{rec[:type]} index: #{rec[:migration_code]}",
84
+ benefit: rec[:estimated_benefit],
85
+ priority: rec[:priority],
86
+ migration_code: rec[:migration_code],
87
+ table: rec[:table],
88
+ columns: rec[:columns],
89
+ category: "database_optimization"
90
+ }
91
+ end
92
+ end
93
+
94
+ def generate_n_plus_one_suggestions
95
+ n_plus_one = analysis_results[:n_plus_one_analysis] || {}
96
+ return [] unless n_plus_one[:is_likely_n_plus_one]
97
+
98
+ suggestions = n_plus_one[:suggested_fixes] || []
99
+
100
+ suggestions.map do |fix|
101
+ {
102
+ type: "n_plus_one",
103
+ action: fix[:description],
104
+ benefit: "Eliminate N+1 queries and reduce database load",
105
+ priority: "high",
106
+ code_example: fix[:code_example],
107
+ confidence: n_plus_one[:confidence_score],
108
+ category: "performance_critical"
109
+ }
110
+ end
111
+ end
112
+
113
+ def generate_query_characteristic_suggestions
114
+ stats = analysis_results[:query_characteristics] || {}
115
+ suggestions = []
116
+
117
+ if stats[:join_count] && stats[:join_count] > 3
118
+ suggestions << {
119
+ type: "optimization",
120
+ action: "Review if all #{stats[:join_count]} JOINs are necessary",
121
+ benefit: "Simplified query execution and better performance",
122
+ priority: "medium",
123
+ category: "sql_optimization"
124
+ }
125
+ end
126
+
127
+ if stats[:estimated_complexity] && stats[:estimated_complexity] > 10
128
+ suggestions << {
129
+ type: "refactoring",
130
+ action: "Consider breaking complex query (complexity: #{stats[:estimated_complexity]}) into smaller parts",
131
+ benefit: "Easier maintenance and potentially better performance",
132
+ priority: "medium",
133
+ category: "code_quality"
134
+ }
135
+ end
136
+
137
+ if stats[:has_subqueries] && stats[:join_count] && stats[:join_count] > 1
138
+ suggestions << {
139
+ type: "optimization",
140
+ action: "Consider converting subqueries to JOINs for better performance",
141
+ benefit: "More efficient query execution in most databases",
142
+ priority: "medium",
143
+ category: "sql_optimization"
144
+ }
145
+ end
146
+
147
+ suggestions
148
+ end
149
+
150
+ def generate_explain_plan_suggestions
151
+ explain_issues = analysis_results.dig(:explain_plan, :issues) || []
152
+
153
+ explain_issues.map do |issue|
154
+ case issue[:type]
155
+ when "sequential_scan"
156
+ {
157
+ type: "index",
158
+ action: "Consider adding database indexes for WHERE clause columns",
159
+ benefit: "Dramatically faster query execution",
160
+ priority: "high",
161
+ category: "database_optimization"
162
+ }
163
+ when "temporary_table"
164
+ {
165
+ type: "optimization",
166
+ action: "Optimize query to avoid temporary tables and filesort operations",
167
+ benefit: "Reduced memory usage and faster execution",
168
+ priority: "medium",
169
+ category: "sql_optimization"
170
+ }
171
+ when "high_cost_operation"
172
+ {
173
+ type: "optimization",
174
+ action: "Review query execution plan for high-cost operations",
175
+ benefit: "Identify specific bottlenecks for targeted optimization",
176
+ priority: "high",
177
+ category: "performance_critical"
178
+ }
179
+ when "where_without_index"
180
+ {
181
+ type: "index",
182
+ action: "Add indexes to support WHERE clause conditions",
183
+ benefit: "Eliminate row-by-row filtering during query execution",
184
+ priority: "high",
185
+ category: "database_optimization"
186
+ }
187
+ end
188
+ end.compact
189
+ end
190
+
191
+ def prioritize_suggestions(suggestions)
192
+ # Remove duplicates based on action
193
+ unique_suggestions = suggestions.uniq { |s| s[:action] }
194
+
195
+ # Sort by priority and category
196
+ unique_suggestions.sort_by do |suggestion|
197
+ priority_score = case suggestion[:priority]
198
+ when "high" then 3
199
+ when "medium" then 2
200
+ when "low" then 1
201
+ else 0
202
+ end
203
+
204
+ category_score = case suggestion[:category]
205
+ when "performance_critical" then 4
206
+ when "database_optimization" then 3
207
+ when "sql_optimization" then 2
208
+ when "code_quality" then 1
209
+ else 0
210
+ end
211
+
212
+ [ -priority_score, -category_score, suggestion[:action] ]
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,125 @@
1
+ # Orchestrates comprehensive query analysis using modular analyzers.
2
+ # Coordinates multiple specialized analyzers and consolidates results into actionable insights.
3
+ module RailsPulse
4
+ class QueryAnalysisService
5
+ def self.analyze_query(query_id)
6
+ query = RailsPulse::Query.find(query_id)
7
+ new(query).analyze
8
+ end
9
+
10
+ def initialize(query)
11
+ @query = query
12
+ @operations = fetch_recent_operations
13
+ end
14
+
15
+ def analyze
16
+ # Run all analyzers
17
+ results = {
18
+ analyzed_at: Time.current,
19
+ query_characteristics: analyze_query_characteristics,
20
+ index_recommendations: analyze_index_recommendations,
21
+ n_plus_one_analysis: analyze_n_plus_one,
22
+ explain_plan: analyze_explain_plan,
23
+ backtrace_analysis: analyze_backtraces
24
+ }
25
+
26
+ # Generate consolidated suggestions
27
+ results[:suggestions] = generate_suggestions(results)
28
+
29
+ # Build compatible format for query model
30
+ compatible_results = build_compatible_results(results)
31
+
32
+ # Save results to query
33
+ save_results_to_query(compatible_results)
34
+
35
+ results
36
+ end
37
+
38
+ private
39
+
40
+ def fetch_recent_operations
41
+ @query.operations
42
+ .where("occurred_at > ?", 48.hours.ago)
43
+ .order(occurred_at: :desc)
44
+ .limit(50)
45
+ end
46
+
47
+ def analyze_query_characteristics
48
+ Analysis::QueryCharacteristicsAnalyzer.new(@query, @operations).analyze
49
+ end
50
+
51
+ def analyze_index_recommendations
52
+ Analysis::IndexRecommendationEngine.new(@query, @operations).analyze
53
+ end
54
+
55
+ def analyze_n_plus_one
56
+ Analysis::NPlusOneDetector.new(@query, @operations).analyze
57
+ end
58
+
59
+ def analyze_explain_plan
60
+ return { explain_plan: nil, issues: [] } if @operations.empty?
61
+ Analysis::ExplainPlanAnalyzer.new(@query, @operations).analyze
62
+ end
63
+
64
+ def analyze_backtraces
65
+ return {} if @operations.empty?
66
+ Analysis::BacktraceAnalyzer.new(@query, @operations).analyze
67
+ end
68
+
69
+ def generate_suggestions(analysis_results)
70
+ Analysis::SuggestionGenerator.new(analysis_results).generate
71
+ end
72
+
73
+ # Build compatible format for query model storage
74
+ def build_compatible_results(results)
75
+ characteristics = results[:query_characteristics]
76
+ explain_result = results[:explain_plan]
77
+
78
+ {
79
+ analyzed_at: results[:analyzed_at],
80
+ explain_plan: explain_result[:explain_plan],
81
+ issues: extract_all_issues(characteristics, explain_result),
82
+ metadata: build_metadata(results),
83
+ query_stats: extract_query_stats(characteristics),
84
+ backtrace_analysis: results[:backtrace_analysis],
85
+ index_recommendations: results[:index_recommendations],
86
+ n_plus_one_analysis: results[:n_plus_one_analysis],
87
+ suggestions: results[:suggestions]
88
+ }
89
+ end
90
+
91
+ def extract_all_issues(characteristics, explain_result)
92
+ issues = []
93
+ issues.concat(characteristics[:pattern_issues] || [])
94
+ issues.concat(explain_result[:issues] || [])
95
+ issues
96
+ end
97
+
98
+ def extract_query_stats(characteristics)
99
+ characteristics.except(:pattern_issues)
100
+ end
101
+
102
+ def build_metadata(results)
103
+ {
104
+ analyzers_used: results.keys.reject { |k| k.in?([ :analyzed_at, :suggestions ]) },
105
+ analysis_version: "2.0",
106
+ total_recommendations: results[:index_recommendations]&.count || 0,
107
+ n_plus_one_detected: results.dig(:n_plus_one_analysis, :is_likely_n_plus_one) || false
108
+ }
109
+ end
110
+
111
+ def save_results_to_query(results)
112
+ @query.update!(
113
+ analyzed_at: results[:analyzed_at],
114
+ explain_plan: results[:explain_plan],
115
+ issues: results[:issues],
116
+ metadata: results[:metadata],
117
+ query_stats: results[:query_stats],
118
+ backtrace_analysis: results[:backtrace_analysis],
119
+ index_recommendations: results[:index_recommendations],
120
+ n_plus_one_analysis: results[:n_plus_one_analysis],
121
+ suggestions: results[:suggestions]
122
+ )
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,199 @@
1
+
2
+ module RailsPulse
3
+ class SummaryService
4
+ attr_reader :period_type, :start_time, :end_time
5
+
6
+ def initialize(period_type, start_time)
7
+ @period_type = period_type
8
+ @start_time = Summary.normalize_period_start(period_type, start_time)
9
+ @end_time = Summary.calculate_period_end(period_type, @start_time)
10
+ end
11
+
12
+ def perform
13
+ Rails.logger.info "[RailsPulse] Starting #{period_type} summary for #{start_time}"
14
+
15
+ ActiveRecord::Base.transaction do
16
+ aggregate_requests # Overall system metrics
17
+ aggregate_routes # Per-route metrics
18
+ aggregate_queries # Per-query metrics
19
+ end
20
+
21
+ Rails.logger.info "[RailsPulse] Completed #{period_type} summary"
22
+ rescue => e
23
+ Rails.logger.error "[RailsPulse] Summary failed: #{e.message}"
24
+ raise
25
+ end
26
+
27
+ private
28
+
29
+ def aggregate_requests
30
+ # Create a single summary for ALL requests in this period
31
+ requests = Request.where(occurred_at: start_time...end_time)
32
+
33
+ return if requests.empty?
34
+
35
+ # Get all durations and statuses for percentile calculations
36
+ request_data = requests.pluck(:duration, :status)
37
+ durations = request_data.map(&:first).compact.sort
38
+ statuses = request_data.map(&:second)
39
+
40
+ # Find or create the overall request summary
41
+ summary = Summary.find_or_initialize_by(
42
+ summarizable_type: "RailsPulse::Request",
43
+ summarizable_id: 0, # Use 0 as a special ID for overall summaries
44
+ period_type: period_type,
45
+ period_start: start_time
46
+ )
47
+
48
+ summary.assign_attributes(
49
+ period_end: end_time,
50
+ count: durations.size,
51
+ avg_duration: durations.any? ? durations.sum.to_f / durations.size : 0,
52
+ min_duration: durations.min,
53
+ max_duration: durations.max,
54
+ total_duration: durations.sum,
55
+ p50_duration: calculate_percentile(durations, 0.5),
56
+ p95_duration: calculate_percentile(durations, 0.95),
57
+ p99_duration: calculate_percentile(durations, 0.99),
58
+ stddev_duration: calculate_stddev(durations, durations.sum.to_f / durations.size),
59
+ error_count: statuses.count { |s| s >= 400 },
60
+ success_count: statuses.count { |s| s < 400 },
61
+ status_2xx: statuses.count { |s| s.between?(200, 299) },
62
+ status_3xx: statuses.count { |s| s.between?(300, 399) },
63
+ status_4xx: statuses.count { |s| s.between?(400, 499) },
64
+ status_5xx: statuses.count { |s| s >= 500 }
65
+ )
66
+
67
+ summary.save!
68
+ end
69
+
70
+ private
71
+
72
+ def aggregate_routes
73
+ # Use ActiveRecord for cross-database compatibility
74
+ route_groups = Request
75
+ .where(occurred_at: start_time...end_time)
76
+ .where.not(route_id: nil)
77
+ .group(:route_id)
78
+
79
+ # Calculate basic aggregates
80
+ basic_stats = route_groups.pluck(
81
+ :route_id,
82
+ Arel.sql("COUNT(*) as request_count"),
83
+ Arel.sql("AVG(duration) as avg_duration"),
84
+ Arel.sql("MIN(duration) as min_duration"),
85
+ Arel.sql("MAX(duration) as max_duration"),
86
+ Arel.sql("SUM(duration) as total_duration")
87
+ )
88
+
89
+ basic_stats.each do |stats|
90
+ route_id = stats[0]
91
+
92
+ # Calculate percentiles and status counts separately for cross-DB compatibility
93
+ durations = Request
94
+ .where(occurred_at: start_time...end_time)
95
+ .where(route_id: route_id)
96
+ .pluck(:duration, :status)
97
+
98
+ sorted_durations = durations.map(&:first).compact.sort
99
+ statuses = durations.map(&:last)
100
+
101
+ summary = Summary.find_or_initialize_by(
102
+ summarizable_type: "RailsPulse::Route",
103
+ summarizable_id: route_id,
104
+ period_type: period_type,
105
+ period_start: start_time
106
+ )
107
+
108
+ summary.assign_attributes(
109
+ period_end: end_time,
110
+ count: stats[1],
111
+ avg_duration: stats[2],
112
+ min_duration: stats[3],
113
+ max_duration: stats[4],
114
+ total_duration: stats[5],
115
+ p50_duration: calculate_percentile(sorted_durations, 0.5),
116
+ p95_duration: calculate_percentile(sorted_durations, 0.95),
117
+ p99_duration: calculate_percentile(sorted_durations, 0.99),
118
+ stddev_duration: calculate_stddev(sorted_durations, stats[2]),
119
+ error_count: statuses.count { |s| s >= 400 },
120
+ success_count: statuses.count { |s| s < 400 },
121
+ status_2xx: statuses.count { |s| s.between?(200, 299) },
122
+ status_3xx: statuses.count { |s| s.between?(300, 399) },
123
+ status_4xx: statuses.count { |s| s.between?(400, 499) },
124
+ status_5xx: statuses.count { |s| s >= 500 }
125
+ )
126
+
127
+ summary.save!
128
+ end
129
+ end
130
+
131
+ def aggregate_queries
132
+ query_groups = Operation
133
+ .where(occurred_at: start_time...end_time)
134
+ .where.not(query_id: nil)
135
+ .group(:query_id)
136
+
137
+ basic_stats = query_groups.pluck(
138
+ :query_id,
139
+ Arel.sql("COUNT(*) as execution_count"),
140
+ Arel.sql("AVG(duration) as avg_duration"),
141
+ Arel.sql("MIN(duration) as min_duration"),
142
+ Arel.sql("MAX(duration) as max_duration"),
143
+ Arel.sql("SUM(duration) as total_duration")
144
+ )
145
+
146
+ basic_stats.each do |stats|
147
+ query_id = stats[0]
148
+
149
+ # Calculate percentiles separately
150
+ durations = Operation
151
+ .where(occurred_at: start_time...end_time)
152
+ .where(query_id: query_id)
153
+ .pluck(:duration)
154
+ .compact
155
+ .sort
156
+
157
+ summary = Summary.find_or_initialize_by(
158
+ summarizable_type: "RailsPulse::Query",
159
+ summarizable_id: query_id,
160
+ period_type: period_type,
161
+ period_start: start_time
162
+ )
163
+
164
+ summary.assign_attributes(
165
+ period_end: end_time,
166
+ count: stats[1],
167
+ avg_duration: stats[2],
168
+ min_duration: stats[3],
169
+ max_duration: stats[4],
170
+ total_duration: stats[5],
171
+ p50_duration: calculate_percentile(durations, 0.5),
172
+ p95_duration: calculate_percentile(durations, 0.95),
173
+ p99_duration: calculate_percentile(durations, 0.99),
174
+ stddev_duration: calculate_stddev(durations, stats[2])
175
+ )
176
+
177
+ summary.save!
178
+ end
179
+ end
180
+
181
+ def calculate_percentile(sorted_array, percentile)
182
+ return nil if sorted_array.empty?
183
+
184
+ k = (percentile * (sorted_array.length - 1)).floor
185
+ f = (percentile * (sorted_array.length - 1)) - k
186
+
187
+ return sorted_array[k] if f == 0 || k + 1 >= sorted_array.length
188
+
189
+ sorted_array[k] + (sorted_array[k + 1] - sorted_array[k]) * f
190
+ end
191
+
192
+ def calculate_stddev(values, mean)
193
+ return nil if values.empty? || values.size == 1
194
+
195
+ sum_of_squares = values.sum { |v| (v - mean) ** 2 }
196
+ Math.sqrt(sum_of_squares / (values.size - 1))
197
+ end
198
+ end
199
+ end
@@ -5,7 +5,6 @@
5
5
  </div>
6
6
  <div class="flex flex-col text-start leading-tight overflow-hidden">
7
7
  <span class="overflow-ellipsis font-semibold">Rails Pulse</span>
8
- <span class="overflow-ellipsis text-xs">Open Source</span>
9
8
  </div>
10
9
  </a>
11
10
 
@@ -22,18 +22,16 @@
22
22
  <div class="hide@md" data-controller="rails-pulse--dialog">
23
23
  <button type="button" class="btn btn--icon" data-action="rails-pulse--dialog#showModal">
24
24
  <%= rails_pulse_icon 'menu', width: '20' %>
25
- <span class="sr-only">Open menu</span>
26
25
  </button>
27
26
 
28
27
  <dialog class="sheet sheet--left" style="--sheet-size: 288px;" data-rails-pulse--dialog-target="menu" data-action="click->rails-pulse--dialog#closeOnClickOutside">
29
28
  <div class="sheet__content p-2">
30
29
  <div class="sidebar-menu">
31
- <a class="btn sidebar-menu__button" href="#">
30
+ <%= link_to rails_pulse.root_path, class: "btn sidebar-menu__button" do %>
32
31
  <div class="flex flex-col text-start leading-tight overflow-hidden">
33
32
  <span class="overflow-ellipsis font-semibold">Rails Pulse</span>
34
- <span class="overflow-ellipsis text-xs">Open Source</span>
35
33
  </div>
36
- </a>
34
+ <% end %>
37
35
  <div class="sidebar-menu__content">
38
36
  <div class="sidebar-menu__group">
39
37
  <nav class="sidebar-menu__items">
@@ -47,9 +45,9 @@
47
45
  </div>
48
46
 
49
47
  <div class="flex items-center gap">
50
- <a class="flex items-center gap mie-2" href="#">
48
+ <%= link_to rails_pulse.root_path, class: "flex items-center gap mie-2" do %>
51
49
  <span class="font-bold">Rails Pulse</span>
52
- </a>
50
+ <% end %>
53
51
  <nav class="flex items-center gap text-sm text-subtle show@md" style="--column-gap: 1rem">
54
52
  <%= render 'layouts/rails_pulse/menu_items' %>
55
53
  </nav>
@@ -1,4 +1,4 @@
1
- <nav class="breadcrumb" aria-label="Breadcrumb">
1
+ <nav class="breadcrumb mis-2" aria-label="Breadcrumb">
2
2
  <% breadcrumbs.each_with_index do |crumb, index| %>
3
3
  <% if crumb[:current] %>
4
4
  <span class="text-primary" aria-disabled="true" aria-current="page" role="link"><%= crumb[:title] %></span>
@@ -2,11 +2,25 @@
2
2
  title ||= nil
3
3
  %>
4
4
 
5
- <div>
5
+ <div
6
+ data-controller="rails-pulse--collapsible"
7
+ data-rails-pulse--collapsible-collapsed-class="collapsed"
8
+ class="collapsible-code"
9
+ >
6
10
  <% if title %>
7
- <h2 class="grow font-semibold leading-none mbe-1 uppercase text-xs"><%= title %></h2>
11
+ <h2 class="grow font-semibold leading-none mbe-1 uppercase text-xs">
12
+ <%= title %>
13
+ <button
14
+ type="button"
15
+ class="collapsible-toggle"
16
+ data-rails-pulse--collapsible-target="toggle"
17
+ data-action="click->rails-pulse--collapsible#toggle"
18
+ >
19
+ show more
20
+ </button>
21
+ </h2>
8
22
  <% end %>
9
- <div class="prose max-i-none">
23
+ <div class="prose max-i-none" data-rails-pulse--collapsible-target="content">
10
24
  <pre class="mbs-0" style="white-space: normal; margin-block-end: 0; margin-block-start: 0;"><code><%= html_escape(value) %></code></pre>
11
25
  </div>
12
26
  </div>
@@ -0,0 +1,11 @@
1
+ <div class="flex items-center justify-center pbs-12 pbe-12 pis-6 pie-6 mb-8">
2
+ <div class="flex items-center gap">
3
+ <div class="shrink-0">
4
+ <img src="<%= asset_path('search.svg') %>" class="w-48 h-48" alt="No data available" />
5
+ </div>
6
+ <div class="text-subtle pis-8">
7
+ <p class="text-lg font-semibold mbe-2"><%= title %></p>
8
+ <p class="text-sm"><%= description %></p>
9
+ </div>
10
+ </div>
11
+ </div>