rails_pulse 0.1.3 → 0.1.4

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +56 -16
  3. data/Rakefile +169 -86
  4. data/app/controllers/rails_pulse/queries_controller.rb +14 -20
  5. data/app/controllers/rails_pulse/requests_controller.rb +43 -30
  6. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +1 -1
  7. data/app/helpers/rails_pulse/chart_helper.rb +1 -1
  8. data/app/helpers/rails_pulse/formatting_helper.rb +21 -2
  9. data/app/javascript/rails_pulse/controllers/index_controller.js +11 -3
  10. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +1 -1
  11. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
  12. data/app/models/rails_pulse/queries/cards/average_query_times.rb +1 -1
  13. data/app/models/rails_pulse/queries/cards/execution_rate.rb +56 -17
  14. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +1 -1
  15. data/app/models/rails_pulse/queries/charts/average_query_times.rb +3 -7
  16. data/app/models/rails_pulse/request.rb +1 -1
  17. data/app/models/rails_pulse/requests/charts/average_response_times.rb +2 -2
  18. data/app/models/rails_pulse/requests/tables/index.rb +77 -0
  19. data/app/models/rails_pulse/routes/cards/average_response_times.rb +1 -1
  20. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +1 -1
  21. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +1 -1
  22. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +16 -5
  23. data/app/models/rails_pulse/routes/tables/index.rb +4 -2
  24. data/app/models/rails_pulse/summary.rb +7 -7
  25. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +11 -3
  26. data/app/views/rails_pulse/components/_metric_card.html.erb +2 -2
  27. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  28. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +1 -1
  29. data/app/views/rails_pulse/dashboard/index.html.erb +1 -1
  30. data/app/views/rails_pulse/queries/_analysis_results.html.erb +53 -23
  31. data/app/views/rails_pulse/queries/_show_table.html.erb +33 -5
  32. data/app/views/rails_pulse/queries/_table.html.erb +3 -7
  33. data/app/views/rails_pulse/requests/_table.html.erb +30 -19
  34. data/app/views/rails_pulse/requests/index.html.erb +8 -0
  35. data/app/views/rails_pulse/requests/show.html.erb +0 -2
  36. data/app/views/rails_pulse/routes/_requests_table.html.erb +39 -0
  37. data/app/views/rails_pulse/routes/_table.html.erb +3 -9
  38. data/app/views/rails_pulse/routes/show.html.erb +3 -5
  39. data/config/initializers/rails_charts_csp_patch.rb +32 -40
  40. data/db/migrate/20250930105043_install_rails_pulse_tables.rb +23 -0
  41. data/db/rails_pulse_schema.rb +1 -1
  42. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +25 -9
  43. data/lib/generators/rails_pulse/install_generator.rb +9 -5
  44. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +72 -2
  45. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +3 -2
  46. data/lib/generators/rails_pulse/upgrade_generator.rb +2 -1
  47. data/lib/rails_pulse/engine.rb +21 -0
  48. data/lib/rails_pulse/version.rb +1 -1
  49. data/public/rails-pulse-assets/rails-pulse.js +1 -1
  50. data/public/rails-pulse-assets/rails-pulse.js.map +2 -2
  51. metadata +5 -4
  52. data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +0 -54
  53. data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +0 -13
@@ -1,9 +1,7 @@
1
1
  <% columns = [
2
2
  { field: :normalized_sql, label: 'Query', class: 'w-auto' },
3
- { field: :execution_count_sort, label: 'Executions', class: 'w-24' },
4
- { field: :avg_duration_sort, label: 'Avg Time', class: 'w-24' },
5
- { field: :total_time_consumed_sort, label: 'Total Time', class: 'w-28' },
6
- { field: :performance_status, label: 'Status', class: 'w-16', sortable: false }
3
+ { field: :avg_duration_sort, label: 'Average Query Time', class: 'w-44' },
4
+ { field: :execution_count_sort, label: 'Executions', class: 'w-24' }
7
5
  ] %>
8
6
 
9
7
  <table class="table mbs-4" data-controller="rails-pulse--table-sort">
@@ -17,10 +15,8 @@
17
15
  <%= link_to html_escape(truncate_sql(summary.normalized_sql)), query_path(summary.query_id), data: { turbo_frame: '_top', } %>
18
16
  </div>
19
17
  </td>
20
- <td class="whitespace-nowrap"><%= number_with_delimiter summary.execution_count %></td>
21
18
  <td class="whitespace-nowrap"><%= summary.avg_duration.to_i %> ms</td>
22
- <td class="whitespace-nowrap"><%= number_with_delimiter summary.total_time_consumed.to_i %> ms</td>
23
- <td class="whitespace-nowrap text-center"><%= query_status_indicator(summary.avg_duration) %></td>
19
+ <td class="whitespace-nowrap"><%= number_with_delimiter summary.execution_count %></td>
24
20
  </tr>
25
21
  <% end %>
26
22
  </tbody>
@@ -1,28 +1,39 @@
1
- <%
2
- columns = []
3
- columns << { field: :route_path, label: 'Route', class: 'w-auto' } if @route.blank?
4
-
5
- columns += [
6
- { field: :occurred_at, label: 'Timestamp', class: 'w-36' },
7
- { field: :duration, label: 'Response Time', class: 'w-24' },
8
- { field: :status, label: 'HTTP Status', class: 'w-20' },
9
- { field: :status_indicator, label: 'Status', class: 'w-16' }
10
- ]
11
- %>
1
+ <% columns = [
2
+ { field: :occurred_at, label: 'Timestamp', class: 'w-36' },
3
+ { field: :route_path, label: 'Route', class: 'w-auto' },
4
+ { field: :duration, label: 'Response Time', class: 'w-36' },
5
+ { field: :status, label: 'Status', class: 'w-20' }
6
+ ] %>
12
7
 
13
8
  <table class="table mbs-4" data-controller="rails-pulse--table-sort">
14
9
  <%= render "rails_pulse/components/table_head", columns: columns %>
15
10
 
16
11
  <tbody>
17
- <% @table_data.each do |route_request| %>
12
+ <% @table_data.each do |request| %>
13
+ <%
14
+ # Determine performance class based on request duration
15
+ performance_class = case request.duration
16
+ when 0..100 then "text-green-600"
17
+ when 100..300 then "text-yellow-600"
18
+ when 300..1000 then "text-orange-600"
19
+ else "text-red-600"
20
+ end
21
+ %>
18
22
  <tr>
19
- <% if @route.blank? %>
20
- <td class="whitespace-nowrap"><%= link_to route_request.route.path_and_method, request_path(route_request), data: { turbo_frame: '_top' } %></td>
21
- <% end %>
22
- <td class="whitespace-nowrap"><%= link_to human_readable_occurred_at(route_request.occurred_at), request_path(route_request), data: { turbo_frame: '_top' } %></td>
23
- <td class="whitespace-nowrap"><%= route_request.duration.round(2) %> ms</td>
24
- <td class="whitespace-nowrap"><%= route_request.status %></td>
25
- <td class="whitespace-nowrap text-center"><%= request_status_indicator(route_request.duration) %></td>
23
+ <td class="whitespace-nowrap">
24
+ <%= link_to human_readable_occurred_at(request.occurred_at), request_path(request), data: { turbo_frame: '_top' } %>
25
+ </td>
26
+ <td class="whitespace-nowrap">
27
+ <%= link_to "#{request.route.path} #{request.route.method}", route_path(request.route), data: { turbo_frame: '_top' } %>
28
+ </td>
29
+ <td class="whitespace-nowrap">
30
+ <span class="<%= performance_class %> font-medium">
31
+ <%= request.duration.round(2) %> ms
32
+ </span>
33
+ </td>
34
+ <td class="whitespace-nowrap">
35
+ <span class="text-green-600"><%= request.status %></span>
36
+ </td>
26
37
  </tr>
27
38
  <% end %>
28
39
  </tbody>
@@ -55,8 +55,16 @@
55
55
  )
56
56
  ) %>
57
57
  </div>
58
+ <% else %>
59
+ <div class="p-4 text-center text-muted">
60
+ No response time data available for the selected filters.
61
+ </div>
58
62
  <% end %>
59
63
 
64
+ <div class="alert flex items-start mbs-8" role="alert">
65
+ <p>The chart above shows aggregated average response times grouped by time periods while the table below shows specific request details.</p>
66
+ </div>
67
+
60
68
  <%= turbo_frame_tag :index_table, data: { rails_pulse__index_target: "indexTable" } do %>
61
69
  <%= render 'rails_pulse/requests/table' %>
62
70
  <% end %>
@@ -8,8 +8,6 @@
8
8
  <dd><%= link_to @request.route.path_and_method, route_path(@request.route) %></dd>
9
9
  <dt>Timestamp</dt>
10
10
  <dd><%= human_readable_occurred_at(@request.occurred_at) %></dd>
11
- <dt>Request UUID</dt>
12
- <dd><code><%= @request.request_uuid %></code></dd>
13
11
  <dt>Duration</dt>
14
12
  <dd><%= @request.duration.round(2) %> ms</dd>
15
13
  <dt>Status</dt>
@@ -0,0 +1,39 @@
1
+ <% columns = [
2
+ { field: :occurred_at, label: 'Timestamp', class: 'w-auto' },
3
+ { field: :duration, label: 'Response Time', class: 'w-36' },
4
+ { field: :status, label: 'Status', class: 'w-20' }
5
+ ] %>
6
+
7
+ <table class="table mbs-4" data-controller="rails-pulse--table-sort">
8
+ <%= render "rails_pulse/components/table_head", columns: columns %>
9
+
10
+ <tbody>
11
+ <% @table_data.each do |request| %>
12
+ <tr>
13
+ <td class="whitespace-nowrap">
14
+ <%= link_to human_readable_occurred_at(request.occurred_at), request_path(request), data: { turbo_frame: '_top' } %>
15
+ </td>
16
+ <td class="whitespace-nowrap">
17
+ <% performance_class = case request.duration
18
+ when 0..100 then "text-green-600"
19
+ when 100..300 then "text-yellow-600"
20
+ when 300..1000 then "text-orange-600"
21
+ else "text-red-600"
22
+ end %>
23
+ <span class="<%= performance_class %> font-medium">
24
+ <%= request.duration.round(2) %> ms
25
+ </span>
26
+ </td>
27
+ <td class="whitespace-nowrap">
28
+ <% if request.is_error? %>
29
+ <span class="text-red-600">Error (<%= request.status %>)</span>
30
+ <% else %>
31
+ <span class="text-green-600"><%= request.status %></span>
32
+ <% end %>
33
+ </td>
34
+ </tr>
35
+ <% end %>
36
+ </tbody>
37
+ </table>
38
+
39
+ <%= render "rails_pulse/components/table_pagination" %>
@@ -1,11 +1,8 @@
1
1
  <% columns = [
2
2
  { field: :route_path, label: 'Route', class: 'w-auto' },
3
- { field: :avg_duration_sort, label: 'Average Response Time', class: 'w-36' },
4
- { field: :max_duration_sort, label: 'Max Response Time', class: 'w-32' },
5
- { field: :count_sort, label: 'Requests', class: 'w-24' },
6
- { field: :requests_per_minute, label: 'Requests Per Minute', class: 'w-28' },
7
- { field: :error_rate_percentage, label: 'Error Rate (%)', class: 'w-20' },
8
- { field: :status_indicator, label: 'Status', class: 'w-16', sortable: false }
3
+ { field: :avg_duration_sort, label: 'Average Response Time', class: 'w-48' },
4
+ { field: :max_duration_sort, label: 'Max Response Time', class: 'w-44' },
5
+ { field: :count_sort, label: 'Requests', class: 'w-24' }
9
6
  ] %>
10
7
 
11
8
  <table class="table mbs-4" data-controller="rails-pulse--table-sort">
@@ -18,9 +15,6 @@
18
15
  <td class="whitespace-nowrap"><%= summary.avg_duration.to_i %> ms</td>
19
16
  <td class="whitespace-nowrap"><%= summary.max_duration.to_i %> ms</td>
20
17
  <td class="whitespace-nowrap"><%= number_with_delimiter summary.count %></td>
21
- <td class="whitespace-nowrap"><%= summary.count < 1 ? '< 1' : (summary.count / 60.0).round(2) %></td>
22
- <td class="whitespace-nowrap"><%= ((summary.error_count.to_f / summary.count) * 100).round(2) %>%</td>
23
- <td class="whitespace-nowrap text-center"><%= route_status_indicator(summary.avg_duration >= 500 ? 1 : 0) %></td>
24
18
  </tr>
25
19
  <% end %>
26
20
  </tbody>
@@ -1,7 +1,5 @@
1
1
  <%= render 'rails_pulse/components/breadcrumbs' %>
2
2
 
3
- <h1 class="text-2xl mis-2"><%= @route.path_and_method %></h1>
4
-
5
3
  <% unless turbo_frame_request? %>
6
4
  <div class="row">
7
5
  <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @average_query_times_metric_card } %>
@@ -14,7 +12,7 @@
14
12
  <div
15
13
  class="row"
16
14
  data-controller="rails-pulse--index"
17
- data-rails-pulse--index-chart-id-value="route_repsonses_chart"
15
+ data-rails-pulse--index-chart-id-value="route_responses_chart"
18
16
  >
19
17
  <div class="grid-item">
20
18
  <%= render 'rails_pulse/components/panel', { title: 'Route Reqeusts', } do %>
@@ -44,7 +42,7 @@
44
42
  <%= bar_chart(
45
43
  @chart_data,
46
44
  code: false,
47
- id: "route_repsonses_chart",
45
+ id: "route_responses_chart",
48
46
  height: "100%",
49
47
  options: bar_chart_options(
50
48
  units: "ms",
@@ -62,7 +60,7 @@
62
60
  <% end %>
63
61
 
64
62
  <%= turbo_frame_tag :index_table, data: { rails_pulse__index_target: "indexTable" } do %>
65
- <%= render 'rails_pulse/requests/table' %>
63
+ <%= render 'rails_pulse/routes/requests_table' %>
66
64
  <% end %>
67
65
  <% else %>
68
66
  <%= render 'rails_pulse/components/empty_state',
@@ -1,62 +1,54 @@
1
- # Monkey patch for RailsCharts CSP compliance
2
- # This adds nonce attributes to script tags generated by the RailsCharts gem
1
+ # CSP patch for RailsCharts gem
2
+ # Adds nonce attributes to script tags generated by RailsCharts for CSP compliance
3
3
 
4
4
  if defined?(RailsCharts)
5
5
  module RailsCharts
6
6
  module CspPatch
7
7
  def line_chart(data_source, options = {})
8
- # Get the original chart HTML
9
8
  chart_html = super(data_source, options)
9
+ add_csp_nonce_to_chart(chart_html)
10
+ end
10
11
 
11
- # Try to get CSP nonce from various sources
12
- nonce = get_csp_nonce
13
-
14
- if nonce.present? && chart_html.present?
15
- # Add nonce to all script tags in the chart HTML
16
- chart_html = add_nonce_to_scripts(chart_html.to_s, nonce)
17
- # Ensure the HTML is marked as safe for Rails to render
18
- chart_html = chart_html.html_safe if chart_html.respond_to?(:html_safe)
19
- end
20
-
21
- chart_html
12
+ def bar_chart(data_source, options = {})
13
+ chart_html = super(data_source, options)
14
+ add_csp_nonce_to_chart(chart_html)
22
15
  end
23
16
 
24
17
  private
25
18
 
26
- def get_csp_nonce
27
- # Try various methods to get the CSP nonce
28
- nonce = nil
19
+ def add_csp_nonce_to_chart(chart_html)
20
+ return chart_html unless chart_html.present?
29
21
 
30
- # Method 1: Check for Rails 6+ CSP nonce helper
31
- if respond_to?(:content_security_policy_nonce)
32
- nonce = content_security_policy_nonce
33
- end
34
-
35
- # Method 2: Check for custom csp_nonce helper
36
- if nonce.blank? && respond_to?(:csp_nonce)
37
- nonce = csp_nonce
38
- end
22
+ nonce = get_csp_nonce
23
+ return chart_html unless nonce.present?
39
24
 
40
- # Method 3: Check request environment
41
- if nonce.blank? && defined?(request) && request
42
- nonce = request.env["action_dispatch.content_security_policy_nonce"] ||
43
- request.env["secure_headers.content_security_policy_nonce"] ||
44
- request.env["csp_nonce"]
45
- end
25
+ # Add nonce to script tags and mark as safe
26
+ modified_html = add_nonce_to_scripts(chart_html.to_s, nonce)
27
+ modified_html.html_safe if modified_html.respond_to?(:html_safe)
28
+ end
46
29
 
47
- # Method 4: Check thread/request store
48
- if nonce.blank?
49
- nonce = Thread.current[:rails_pulse_csp_nonce] ||
50
- (defined?(RequestStore) && RequestStore.store[:rails_pulse_csp_nonce])
30
+ def get_csp_nonce
31
+ # Try common CSP nonce sources in order of preference
32
+ if respond_to?(:content_security_policy_nonce)
33
+ content_security_policy_nonce
34
+ elsif respond_to?(:csp_nonce)
35
+ csp_nonce
36
+ elsif defined?(request) && request
37
+ request.env["action_dispatch.content_security_policy_nonce"] ||
38
+ request.env["secure_headers.content_security_policy_nonce"] ||
39
+ request.env["csp_nonce"]
40
+ elsif respond_to?(:controller) && controller.respond_to?(:content_security_policy_nonce)
41
+ controller.content_security_policy_nonce
42
+ elsif defined?(@view_context) && @view_context.respond_to?(:content_security_policy_nonce)
43
+ @view_context.content_security_policy_nonce
44
+ else
45
+ Thread.current[:rails_pulse_csp_nonce] ||
46
+ (defined?(RequestStore) && RequestStore.store[:rails_pulse_csp_nonce])
51
47
  end
52
-
53
- nonce.presence
54
48
  end
55
49
 
56
50
  def add_nonce_to_scripts(html, nonce)
57
- # Use regex to add nonce to script tags that don't already have one
58
51
  html.gsub(/<script(?![^>]*\snonce=)([^>]*)>/i) do |match|
59
- # Insert nonce attribute before the closing >
60
52
  attributes = $1
61
53
  if attributes.strip.empty?
62
54
  "<script nonce=\"#{nonce}\">"
@@ -0,0 +1,23 @@
1
+ # Generated from Rails Pulse schema - automatically loads current schema definition
2
+ class InstallRailsPulseTables < ActiveRecord::Migration["#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}".to_f]
3
+ def change
4
+ # Load and execute the Rails Pulse schema directly
5
+ # This ensures the migration is always in sync with the schema file
6
+ schema_file = File.join(File.dirname(__FILE__), "..", "rails_pulse_schema.rb")
7
+
8
+ if File.exist?(schema_file)
9
+ say "Loading Rails Pulse schema from db/rails_pulse_schema.rb"
10
+
11
+ # Load the schema file to define RailsPulse::Schema
12
+ load schema_file
13
+
14
+ # Execute the schema in the context of this migration
15
+ RailsPulse::Schema.call(connection)
16
+
17
+ say "Rails Pulse tables created successfully"
18
+ say "The schema file db/rails_pulse_schema.rb remains as your single source of truth"
19
+ else
20
+ raise "Rails Pulse schema file not found at db/rails_pulse_schema.rb"
21
+ end
22
+ end
23
+ end
@@ -4,7 +4,7 @@
4
4
 
5
5
  RailsPulse::Schema = lambda do |connection|
6
6
  # Skip if all tables already exist to prevent conflicts
7
- required_tables = [:rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_summaries]
7
+ required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_summaries ]
8
8
 
9
9
  if ENV["CI"] == "true"
10
10
  existing_tables = required_tables.select { |table| connection.table_exists?(table) }
@@ -13,18 +13,32 @@ module RailsPulse
13
13
 
14
14
  def check_schema_file
15
15
  unless File.exist?("db/rails_pulse_schema.rb")
16
- say "No db/rails_pulse_schema.rb file found. Run 'rails generate rails_pulse:install' first.", :red
17
- exit 1
16
+ # Only show message in non-test environments to reduce test noise
17
+ unless Rails.env.test?
18
+ say "No db/rails_pulse_schema.rb file found. Run 'rails generate rails_pulse:install' first.", :red
19
+ exit 1
20
+ else
21
+ return false
22
+ end
18
23
  end
19
24
 
20
25
  if rails_pulse_tables_exist?
21
- say "Rails Pulse tables already exist. No conversion needed.", :yellow
22
- say "Use 'rails generate rails_pulse:upgrade' to update existing installation.", :blue
23
- exit 0
26
+ unless Rails.env.test?
27
+ say "Rails Pulse tables already exist. No conversion needed.", :yellow
28
+ say "Use 'rails generate rails_pulse:upgrade' to update existing installation.", :blue
29
+ exit 0
30
+ else
31
+ return false
32
+ end
24
33
  end
34
+
35
+ true
25
36
  end
26
37
 
27
38
  def create_conversion_migration
39
+ # Only create migration if schema file check passes
40
+ return unless check_schema_file
41
+
28
42
  say "Converting db/rails_pulse_schema.rb to migration...", :green
29
43
 
30
44
  migration_template(
@@ -34,17 +48,19 @@ module RailsPulse
34
48
  end
35
49
 
36
50
  def display_completion_message
51
+ # Only display completion message if migration was created
52
+ return unless File.exist?("db/rails_pulse_schema.rb")
53
+
37
54
  say <<~MESSAGE
38
55
 
39
56
  Conversion complete!
40
57
 
41
58
  Next steps:
42
59
  1. Run: rails db:migrate
43
- 2. Delete: db/rails_pulse_schema.rb (no longer needed)
44
- 3. Remove db/rails_pulse_migrate/ directory if it exists
45
- 4. Restart your Rails server
60
+ 2. Restart your Rails server
46
61
 
47
- Future Rails Pulse updates will come as regular migrations.
62
+ The schema file db/rails_pulse_schema.rb remains as your single source of truth.
63
+ Future Rails Pulse updates will come as regular migrations in db/migrate/
48
64
 
49
65
  MESSAGE
50
66
  end
@@ -18,6 +18,10 @@ module RailsPulse
18
18
  copy_file "db/rails_pulse_schema.rb", "db/rails_pulse_schema.rb"
19
19
  end
20
20
 
21
+ def create_migration_directory
22
+ create_file "db/rails_pulse_migrate/.keep"
23
+ end
24
+
21
25
  def copy_initializer
22
26
  copy_file "rails_pulse.rb", "config/initializers/rails_pulse.rb"
23
27
  end
@@ -45,10 +49,9 @@ module RailsPulse
45
49
  end
46
50
 
47
51
  def create_separate_database_setup
48
- create_file "db/rails_pulse_migrate/.keep"
49
-
50
52
  say "Setting up separate database configuration...", :green
51
53
 
54
+ # Migration directory already created by create_migration_directory
52
55
  # Could add database.yml configuration here if needed
53
56
  # For now, users will configure manually
54
57
  end
@@ -80,6 +83,7 @@ module RailsPulse
80
83
  2. Run: rails db:prepare (creates database and loads schema)
81
84
  3. Restart your Rails server
82
85
 
86
+ The schema file db/rails_pulse_schema.rb is your single source of truth.
83
87
  Future schema changes will come as regular migrations in db/rails_pulse_migrate/
84
88
 
85
89
  MESSAGE
@@ -92,12 +96,12 @@ module RailsPulse
92
96
 
93
97
  Next steps:
94
98
  1. Run: rails db:migrate (creates Rails Pulse tables in your main database)
95
- 2. Delete: db/rails_pulse_schema.rb (no longer needed)
96
- 3. Restart your Rails server
99
+ 2. Restart your Rails server
97
100
 
101
+ The schema file db/rails_pulse_schema.rb is your single source of truth.
98
102
  Future schema changes will come as regular migrations in db/migrate/
99
103
 
100
- Note: The installation migration was created from db/rails_pulse_schema.rb
104
+ Note: The installation migration loads from db/rails_pulse_schema.rb
101
105
  and includes all current Rails Pulse tables and columns.
102
106
 
103
107
  MESSAGE
@@ -3,8 +3,17 @@
3
3
  # Load with: rails db:schema:load:rails_pulse or db:prepare
4
4
 
5
5
  RailsPulse::Schema = lambda do |connection|
6
- # Skip if tables already exist to prevent conflicts
7
- return if connection.table_exists?(:rails_pulse_routes)
6
+ # Skip if all tables already exist to prevent conflicts
7
+ required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_summaries ]
8
+
9
+ if ENV["CI"] == "true"
10
+ existing_tables = required_tables.select { |table| connection.table_exists?(table) }
11
+ missing_tables = required_tables - existing_tables
12
+ puts "[RailsPulse::Schema] Existing tables: #{existing_tables.join(', ')}" if existing_tables.any?
13
+ puts "[RailsPulse::Schema] Missing tables: #{missing_tables.join(', ')}" if missing_tables.any?
14
+ end
15
+
16
+ return if required_tables.all? { |table| connection.table_exists?(table) }
8
17
 
9
18
  connection.create_table :rails_pulse_routes do |t|
10
19
  t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)"
@@ -16,6 +25,15 @@ RailsPulse::Schema = lambda do |connection|
16
25
 
17
26
  connection.create_table :rails_pulse_queries do |t|
18
27
  t.string :normalized_sql, limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)"
28
+ t.datetime :analyzed_at, comment: "When query analysis was last performed"
29
+ t.text :explain_plan, comment: "EXPLAIN output from actual SQL execution"
30
+ t.text :issues, comment: "JSON array of detected performance issues"
31
+ t.text :metadata, comment: "JSON object containing query complexity metrics"
32
+ t.text :query_stats, comment: "JSON object with query characteristics analysis"
33
+ t.text :backtrace_analysis, comment: "JSON object with call chain and N+1 detection"
34
+ t.text :index_recommendations, comment: "JSON array of database index recommendations"
35
+ t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results"
36
+ t.text :suggestions, comment: "JSON array of optimization recommendations"
19
37
  t.timestamps
20
38
  end
21
39
 
@@ -53,6 +71,58 @@ RailsPulse::Schema = lambda do |connection|
53
71
  connection.add_index :rails_pulse_operations, [ :query_id, :occurred_at ], name: "index_rails_pulse_operations_on_query_and_time"
54
72
  connection.add_index :rails_pulse_operations, [ :query_id, :duration, :occurred_at ], name: "index_rails_pulse_operations_query_performance"
55
73
  connection.add_index :rails_pulse_operations, [ :occurred_at, :duration, :operation_type ], name: "index_rails_pulse_operations_on_time_duration_type"
74
+
75
+ connection.create_table :rails_pulse_summaries do |t|
76
+ # Time fields
77
+ t.datetime :period_start, null: false, comment: "Start of the aggregation period"
78
+ t.datetime :period_end, null: false, comment: "End of the aggregation period"
79
+ t.string :period_type, null: false, comment: "Aggregation period type: hour, day, week, month"
80
+
81
+ # Polymorphic association to handle both routes and queries
82
+ t.references :summarizable, polymorphic: true, null: false, index: true, comment: "Link to Route or Query"
83
+ # This creates summarizable_type (e.g., 'RailsPulse::Route', 'RailsPulse::Query')
84
+ # and summarizable_id (route_id or query_id)
85
+
86
+ # Universal metrics
87
+ t.integer :count, default: 0, null: false, comment: "Total number of requests/operations"
88
+ t.float :avg_duration, comment: "Average duration in milliseconds"
89
+ t.float :min_duration, comment: "Minimum duration in milliseconds"
90
+ t.float :max_duration, comment: "Maximum duration in milliseconds"
91
+ t.float :p50_duration, comment: "50th percentile duration"
92
+ t.float :p95_duration, comment: "95th percentile duration"
93
+ t.float :p99_duration, comment: "99th percentile duration"
94
+ t.float :total_duration, comment: "Total duration in milliseconds"
95
+ t.float :stddev_duration, comment: "Standard deviation of duration"
96
+
97
+ # Request/Route specific metrics
98
+ t.integer :error_count, default: 0, comment: "Number of error responses (5xx)"
99
+ t.integer :success_count, default: 0, comment: "Number of successful responses"
100
+ t.integer :status_2xx, default: 0, comment: "Number of 2xx responses"
101
+ t.integer :status_3xx, default: 0, comment: "Number of 3xx responses"
102
+ t.integer :status_4xx, default: 0, comment: "Number of 4xx responses"
103
+ t.integer :status_5xx, default: 0, comment: "Number of 5xx responses"
104
+
105
+ t.timestamps
106
+ end
107
+
108
+ # Unique constraint and indexes for summaries
109
+ connection.add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ],
110
+ unique: true,
111
+ name: "idx_pulse_summaries_unique"
112
+ connection.add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period"
113
+ connection.add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at"
114
+
115
+ # Add indexes to existing tables for efficient aggregation
116
+ connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation"
117
+ connection.add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at"
118
+
119
+ connection.add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation"
120
+ connection.add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at"
121
+
122
+ if ENV["CI"] == "true"
123
+ created_tables = required_tables.select { |table| connection.table_exists?(table) }
124
+ puts "[RailsPulse::Schema] Successfully created tables: #{created_tables.join(', ')}"
125
+ end
56
126
  end
57
127
 
58
128
  if defined?(RailsPulse::ApplicationRecord)
@@ -1,5 +1,5 @@
1
1
  # Generated from Rails Pulse schema - automatically loads current schema definition
2
- class InstallRailsPulseTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ class InstallRailsPulseTables < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
3
3
  def change
4
4
  # Load and execute the Rails Pulse schema directly
5
5
  # This ensures the migration is always in sync with the schema file
@@ -15,8 +15,9 @@ class InstallRailsPulseTables < ActiveRecord::Migration[<%= ActiveRecord::Migrat
15
15
  RailsPulse::Schema.call(connection)
16
16
 
17
17
  say "Rails Pulse tables created successfully"
18
+ say "The schema file db/rails_pulse_schema.rb remains as your single source of truth"
18
19
  else
19
20
  raise "Rails Pulse schema file not found at db/rails_pulse_schema.rb"
20
21
  end
21
22
  end
22
- end
23
+ end
@@ -132,7 +132,8 @@ module RailsPulse
132
132
  To convert to single database setup:
133
133
  1. Run: rails generate rails_pulse:convert_to_migrations
134
134
  2. Run: rails db:migrate
135
- 3. Delete: db/rails_pulse_schema.rb
135
+
136
+ The schema file db/rails_pulse_schema.rb will remain as your single source of truth.
136
137
 
137
138
  MESSAGE
138
139
  end
@@ -66,6 +66,27 @@ module RailsPulse
66
66
  # Instead, we explicitly use time_zone: "UTC" in all groupdate calls
67
67
  end
68
68
 
69
+ initializer "rails_pulse.disable_turbo" do
70
+ # Disable Turbo navigation globally for Rails Pulse to avoid CSP issues with charts
71
+ # This ensures all navigation within Rails Pulse uses full page refreshes
72
+ ActiveSupport.on_load(:action_view) do
73
+ ActionView::Helpers::UrlHelper.module_eval do
74
+ alias_method :original_link_to, :link_to
75
+
76
+ def link_to(*args, &block)
77
+ # Only modify links within Rails Pulse namespace
78
+ if respond_to?(:controller) && controller.class.name.start_with?("RailsPulse::")
79
+ options = args.extract_options!
80
+ options[:data] ||= {}
81
+ options[:data][:turbo] = false unless options[:data].key?(:turbo)
82
+ args << options
83
+ end
84
+ original_link_to(*args, &block)
85
+ end
86
+ end
87
+ end
88
+ end
89
+
69
90
  # CSP helper methods
70
91
  def self.csp_sources
71
92
  {
@@ -1,3 +1,3 @@
1
1
  module RailsPulse
2
- VERSION = "0.1.3"
2
+ VERSION = "0.1.4"
3
3
  end