rails_pulse 0.1.2 → 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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -4
  3. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  4. data/app/assets/images/rails_pulse/request.png +0 -0
  5. data/app/assets/stylesheets/rails_pulse/application.css +28 -5
  6. data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
  7. data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
  8. data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
  9. data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
  10. data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
  11. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
  12. data/app/controllers/concerns/zoom_range_concern.rb +31 -0
  13. data/app/controllers/rails_pulse/application_controller.rb +5 -1
  14. data/app/controllers/rails_pulse/queries_controller.rb +46 -1
  15. data/app/controllers/rails_pulse/requests_controller.rb +14 -1
  16. data/app/controllers/rails_pulse/routes_controller.rb +40 -1
  17. data/app/helpers/rails_pulse/chart_helper.rb +15 -7
  18. data/app/javascript/rails_pulse/application.js +34 -3
  19. data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
  20. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
  21. data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
  22. data/app/javascript/rails_pulse/controllers/index_controller.js +241 -11
  23. data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
  24. data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
  25. data/app/models/rails_pulse/queries/cards/average_query_times.rb +19 -19
  26. data/app/models/rails_pulse/queries/cards/execution_rate.rb +13 -8
  27. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +13 -8
  28. data/app/models/rails_pulse/query.rb +46 -0
  29. data/app/models/rails_pulse/routes/cards/average_response_times.rb +17 -19
  30. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +13 -8
  31. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +13 -8
  32. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +13 -8
  33. data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
  34. data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
  35. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
  36. data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
  37. data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
  38. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +146 -0
  39. data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
  40. data/app/services/rails_pulse/query_analysis_service.rb +125 -0
  41. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
  42. data/app/views/layouts/rails_pulse/application.html.erb +0 -2
  43. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
  44. data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
  45. data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
  46. data/app/views/rails_pulse/components/_metric_card.html.erb +27 -4
  47. data/app/views/rails_pulse/components/_panel.html.erb +1 -1
  48. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
  49. data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
  50. data/app/views/rails_pulse/dashboard/index.html.erb +1 -1
  51. data/app/views/rails_pulse/operations/show.html.erb +17 -15
  52. data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
  53. data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
  54. data/app/views/rails_pulse/queries/_analysis_results.html.erb +87 -0
  55. data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
  56. data/app/views/rails_pulse/queries/_show_table.html.erb +1 -1
  57. data/app/views/rails_pulse/queries/_table.html.erb +1 -1
  58. data/app/views/rails_pulse/queries/index.html.erb +48 -51
  59. data/app/views/rails_pulse/queries/show.html.erb +56 -52
  60. data/app/views/rails_pulse/requests/_operations.html.erb +30 -43
  61. data/app/views/rails_pulse/requests/_table.html.erb +3 -1
  62. data/app/views/rails_pulse/requests/index.html.erb +48 -51
  63. data/app/views/rails_pulse/routes/_table.html.erb +1 -1
  64. data/app/views/rails_pulse/routes/index.html.erb +49 -52
  65. data/app/views/rails_pulse/routes/show.html.erb +4 -4
  66. data/config/routes.rb +5 -1
  67. data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +13 -0
  68. data/db/rails_pulse_schema.rb +9 -0
  69. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +65 -0
  70. data/lib/generators/rails_pulse/install_generator.rb +71 -18
  71. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +22 -0
  72. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
  73. data/lib/generators/rails_pulse/upgrade_generator.rb +225 -0
  74. data/lib/rails_pulse/version.rb +1 -1
  75. data/lib/tasks/rails_pulse.rake +27 -8
  76. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  77. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  78. data/public/rails-pulse-assets/rails-pulse.js +53 -53
  79. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  80. metadata +23 -5
  81. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  82. data/app/assets/images/rails_pulse/routes.png +0 -0
  83. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
@@ -10,64 +10,61 @@
10
10
  <% end %>
11
11
 
12
12
  <div
13
- class="row"
14
13
  data-controller="rails-pulse--index"
15
14
  data-rails-pulse--index-chart-id-value="average_response_times_chart"
16
15
  >
17
- <div class="grid-item">
18
- <%= render 'rails_pulse/components/panel', { title: 'Average Response Time', } do %>
19
- <%= search_form_for @ransack_query, url: routes_path, class: "flex items-center justify-between gap mb-4" do |form| %>
20
- <div class="flex items-center grow gap">
21
- <%= form.search_field :route_path_cont, placeholder: "Filter by route", autocomplete: "off", class: "input", style: "max-inline-size: 250px" %>
22
- <%= form.select :period_start_range,
23
- RailsPulse::RoutesController::TIME_RANGE_OPTIONS,
24
- { selected: @selected_time_range },
25
- { class: "input" }
26
- %>
27
- <%= form.select :avg_duration,
28
- duration_options(:route),
29
- { selected: @selected_response_range },
30
- { class: "input" }
31
- %>
32
- <%= link_to "Reset", routes_path, class: "btn btn--borderless show@md" if params.has_key?(:q) %>
33
- <%= form.submit "Search", class: "btn show@sm" %>
16
+ <%= render 'rails_pulse/components/panel', { title: 'Average Response Time', card_classes: 'b-full' } do %>
17
+ <%= search_form_for @ransack_query, url: routes_path, class: "flex items-center justify-between gap mb-4" do |form| %>
18
+ <div class="flex items-center grow gap">
19
+ <%= form.search_field :route_path_cont, placeholder: "Filter by route", autocomplete: "off", class: "input", style: "max-inline-size: 250px" %>
20
+ <%= form.select :period_start_range,
21
+ RailsPulse::RoutesController::TIME_RANGE_OPTIONS,
22
+ { selected: @selected_time_range },
23
+ { class: "input" }
24
+ %>
25
+ <%= form.select :avg_duration,
26
+ duration_options(:route),
27
+ { selected: @selected_response_range },
28
+ { class: "input" }
29
+ %>
30
+ <%= link_to "Reset", routes_path, class: "btn btn--borderless show@md" if params.has_key?(:q) %>
31
+ <%= form.submit "Search", class: "btn show@sm" %>
32
+ </div>
33
+ <% end %>
34
+
35
+ <% if @has_data %>
36
+ <% if @chart_data && @chart_data.values.any? { |v| v > 0 } %>
37
+ <div
38
+ class="chart-container chart-container--slim"
39
+ data-rails-pulse--index-target="chart"
40
+ >
41
+ <%= bar_chart(
42
+ @chart_data,
43
+ code: false,
44
+ id: "average_response_times_chart",
45
+ height: "100%",
46
+ options: bar_chart_options(
47
+ units: "ms",
48
+ zoom: true,
49
+ chart_start: 0,
50
+ chart_end: @chart_data.length - 1,
51
+ xaxis_formatter: @xaxis_formatter,
52
+ tooltip_formatter: @tooltip_formatter,
53
+ zoom_start: @zoom_start,
54
+ zoom_end: @zoom_end,
55
+ chart_data: @chart_data
56
+ )
57
+ ) %>
34
58
  </div>
35
59
  <% end %>
36
60
 
37
- <% if @has_data %>
38
- <% if @chart_data && @chart_data.values.any? { |v| v > 0 } %>
39
- <div
40
- class="chart-container chart-container--slim"
41
- data-rails-pulse--index-target="chart"
42
- >
43
- <%= bar_chart(
44
- @chart_data,
45
- code: false,
46
- id: "average_response_times_chart",
47
- height: "100%",
48
- options: bar_chart_options(
49
- units: "ms",
50
- zoom: true,
51
- chart_start: 0,
52
- chart_end: @chart_data.length - 1,
53
- xaxis_formatter: @xaxis_formatter,
54
- tooltip_formatter: @tooltip_formatter,
55
- zoom_start: @zoom_start,
56
- zoom_end: @zoom_end,
57
- chart_data: @chart_data
58
- )
59
- ) %>
60
- </div>
61
- <% end %>
62
-
63
- <%= turbo_frame_tag :routes_index_table, data: { rails_pulse__index_target: "indexTable" } do %>
64
- <%= render 'rails_pulse/routes/table' %>
65
- <% end %>
66
- <% else %>
67
- <%= render 'rails_pulse/components/empty_state',
68
- title: 'No route data found for the selected filters.',
69
- description: 'Try adjusting your time range or filters to see results.' %>
61
+ <%= turbo_frame_tag :index_table, data: { rails_pulse__index_target: "indexTable" } do %>
62
+ <%= render 'rails_pulse/routes/table' %>
70
63
  <% end %>
64
+ <% else %>
65
+ <%= render 'rails_pulse/components/empty_state',
66
+ title: 'No route data found for the selected filters.',
67
+ description: 'Try adjusting your time range or filters to see results.' %>
71
68
  <% end %>
72
- </div>
69
+ <% end %>
73
70
  </div>
@@ -1,6 +1,6 @@
1
1
  <%= render 'rails_pulse/components/breadcrumbs' %>
2
2
 
3
- <h1 class="text-2xl mbe-2"><%= @route.path_and_method %></h1>
3
+ <h1 class="text-2xl mis-2"><%= @route.path_and_method %></h1>
4
4
 
5
5
  <% unless turbo_frame_request? %>
6
6
  <div class="row">
@@ -61,12 +61,12 @@
61
61
  </div>
62
62
  <% end %>
63
63
 
64
- <%= turbo_frame_tag :index_table do %>
64
+ <%= turbo_frame_tag :index_table, data: { rails_pulse__index_target: "indexTable" } do %>
65
65
  <%= render 'rails_pulse/requests/table' %>
66
66
  <% end %>
67
67
  <% else %>
68
- <%= render 'rails_pulse/components/empty_state',
69
- title: 'No route requests found for the selected filters.',
68
+ <%= render 'rails_pulse/components/empty_state',
69
+ title: 'No route requests found for the selected filters.',
70
70
  description: 'Try adjusting your time range or filters to see results.' %>
71
71
  <% end %>
72
72
  <% end %>
data/config/routes.rb CHANGED
@@ -3,7 +3,11 @@ RailsPulse::Engine.routes.draw do
3
3
 
4
4
  resources :routes, only: %i[index show]
5
5
  resources :requests, only: %i[index show]
6
- resources :queries, only: %i[index show]
6
+ resources :queries, only: %i[index show] do
7
+ member do
8
+ post :analyze
9
+ end
10
+ end
7
11
  resources :operations, only: %i[show]
8
12
  resources :caches, only: %i[show], as: :cache
9
13
  patch "pagination/limit", to: "application#set_pagination_limit"
@@ -0,0 +1,13 @@
1
+ class AddAnalysisToRailsPulseQueries < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :rails_pulse_queries, :analyzed_at, :datetime, comment: "When query analysis was last performed"
4
+ add_column :rails_pulse_queries, :explain_plan, :text, comment: "EXPLAIN output from actual SQL execution"
5
+ add_column :rails_pulse_queries, :issues, :text, comment: "JSON array of detected performance issues"
6
+ add_column :rails_pulse_queries, :metadata, :text, comment: "JSON object containing query complexity metrics"
7
+ add_column :rails_pulse_queries, :query_stats, :text, comment: "JSON object with query characteristics analysis"
8
+ add_column :rails_pulse_queries, :backtrace_analysis, :text, comment: "JSON object with call chain and N+1 detection"
9
+ add_column :rails_pulse_queries, :suggestions, :text, comment: "JSON array of optimization recommendations"
10
+ add_column :rails_pulse_queries, :index_recommendations, :text, comment: "JSON array of database index recommendations"
11
+ add_column :rails_pulse_queries, :n_plus_one_analysis, :text, comment: "JSON object with enhanced N+1 query detection results"
12
+ end
13
+ end
@@ -25,6 +25,15 @@ RailsPulse::Schema = lambda do |connection|
25
25
 
26
26
  connection.create_table :rails_pulse_queries do |t|
27
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"
28
37
  t.timestamps
29
38
  end
30
39
 
@@ -0,0 +1,65 @@
1
+ module RailsPulse
2
+ module Generators
3
+ class ConvertToMigrationsGenerator < Rails::Generators::Base
4
+ include Rails::Generators::Migration
5
+ source_root File.expand_path("templates", __dir__)
6
+
7
+ desc "Convert Rails Pulse schema file to migrations for single database setup"
8
+
9
+ def self.next_migration_number(path)
10
+ next_migration_number = current_migration_number(path) + 1
11
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
12
+ end
13
+
14
+ def check_schema_file
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
18
+ end
19
+
20
+ 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
24
+ end
25
+ end
26
+
27
+ def create_conversion_migration
28
+ say "Converting db/rails_pulse_schema.rb to migration...", :green
29
+
30
+ migration_template(
31
+ "migrations/install_rails_pulse_tables.rb",
32
+ "db/migrate/install_rails_pulse_tables.rb"
33
+ )
34
+ end
35
+
36
+ def display_completion_message
37
+ say <<~MESSAGE
38
+
39
+ Conversion complete!
40
+
41
+ Next steps:
42
+ 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
46
+
47
+ Future Rails Pulse updates will come as regular migrations.
48
+
49
+ MESSAGE
50
+ end
51
+
52
+ private
53
+
54
+ def rails_pulse_tables_exist?
55
+ return false unless defined?(ActiveRecord::Base)
56
+
57
+ connection = ActiveRecord::Base.connection
58
+ %w[rails_pulse_routes rails_pulse_queries rails_pulse_requests rails_pulse_operations rails_pulse_summaries]
59
+ .all? { |table| connection.table_exists?(table) }
60
+ rescue
61
+ false
62
+ end
63
+ end
64
+ end
65
+ end
@@ -1,9 +1,18 @@
1
1
  module RailsPulse
2
2
  module Generators
3
3
  class InstallGenerator < Rails::Generators::Base
4
+ include Rails::Generators::Migration
4
5
  source_root File.expand_path("templates", __dir__)
5
6
 
6
- desc "Install Rails Pulse with schema-based setup"
7
+ desc "Install Rails Pulse with flexible database setup options"
8
+
9
+ class_option :database, type: :string, default: "single",
10
+ desc: "Database setup: 'single' (default) or 'separate'"
11
+
12
+ def self.next_migration_number(path)
13
+ next_migration_number = current_migration_number(path) + 1
14
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
15
+ end
7
16
 
8
17
  def copy_schema
9
18
  copy_file "db/rails_pulse_schema.rb", "db/rails_pulse_schema.rb"
@@ -13,41 +22,85 @@ module RailsPulse
13
22
  copy_file "rails_pulse.rb", "config/initializers/rails_pulse.rb"
14
23
  end
15
24
 
16
- def create_database_migration_paths
25
+ def setup_database_configuration
17
26
  if separate_database?
18
- create_file "db/rails_pulse_migrate/.keep"
27
+ create_separate_database_setup
28
+ else
29
+ create_single_database_setup
19
30
  end
20
31
  end
21
32
 
22
33
  def display_post_install_message
34
+ if separate_database?
35
+ display_separate_database_message
36
+ else
37
+ display_single_database_message
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def separate_database?
44
+ options[:database] == "separate"
45
+ end
46
+
47
+ def create_separate_database_setup
48
+ create_file "db/rails_pulse_migrate/.keep"
49
+
50
+ say "Setting up separate database configuration...", :green
51
+
52
+ # Could add database.yml configuration here if needed
53
+ # For now, users will configure manually
54
+ end
55
+
56
+ def create_single_database_setup
57
+ say "Setting up single database configuration...", :green
58
+
59
+ # Create a migration that loads the schema
60
+ migration_template(
61
+ "migrations/install_rails_pulse_tables.rb",
62
+ "db/migrate/install_rails_pulse_tables.rb"
63
+ )
64
+ end
65
+
66
+ def display_separate_database_message
23
67
  say <<~MESSAGE
24
68
 
25
- Rails Pulse installation complete!
69
+ Rails Pulse installation complete! (Separate Database Setup)
26
70
 
27
71
  Next steps:
28
- 1. Configure your database in config/database.yml (see README for examples)
72
+ 1. Add Rails Pulse database configuration to config/database.yml:
73
+
74
+ #{Rails.env}:
75
+ rails_pulse:
76
+ <<: *default
77
+ database: storage/#{Rails.env}_rails_pulse.sqlite3
78
+ migrations_paths: db/rails_pulse_migrate
79
+
29
80
  2. Run: rails db:prepare (creates database and loads schema)
30
81
  3. Restart your Rails server
31
82
 
32
- For separate database setup, add to config/database.yml:
33
- #{environment}:
34
- rails_pulse:
35
- <<: *default
36
- database: storage/#{environment}_rails_pulse.sqlite3
37
- migrations_paths: db/rails_pulse_migrate
83
+ Future schema changes will come as regular migrations in db/rails_pulse_migrate/
38
84
 
39
85
  MESSAGE
40
86
  end
41
87
 
42
- private
88
+ def display_single_database_message
89
+ say <<~MESSAGE
43
90
 
44
- def separate_database?
45
- # Could make this configurable via options
46
- false
47
- end
91
+ Rails Pulse installation complete! (Single Database Setup)
92
+
93
+ Next steps:
94
+ 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
48
97
 
49
- def environment
50
- Rails.env.production? ? "production" : "development"
98
+ Future schema changes will come as regular migrations in db/migrate/
99
+
100
+ Note: The installation migration was created from db/rails_pulse_schema.rb
101
+ and includes all current Rails Pulse tables and columns.
102
+
103
+ MESSAGE
51
104
  end
52
105
  end
53
106
  end
@@ -0,0 +1,22 @@
1
+ # Generated from Rails Pulse schema - automatically loads current schema definition
2
+ class InstallRailsPulseTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
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(::Rails.root.to_s, "db/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
+ else
19
+ raise "Rails Pulse schema file not found at db/rails_pulse_schema.rb"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ # Upgrade Rails Pulse tables with new features
2
+ class UpgradeRailsPulseTables < ActiveRecord::Migration[<%= @migration_version %>]
3
+ def change
4
+ <% @missing_columns.each do |table_name, columns| %>
5
+ <% columns.each do |column_name, definition| %>
6
+ # Add <%= column_name %> column to <%= table_name %>
7
+ add_column :<%= table_name %>, :<%= column_name %>, :<%= definition[:type] %><% if definition[:comment] %>, comment: "<%= definition[:comment] %>"<% end %>
8
+ <% end %>
9
+ <% end %>
10
+ end
11
+
12
+ def down
13
+ <% @missing_columns.each do |table_name, columns| %>
14
+ <% columns.each do |column_name, _definition| %>
15
+ remove_column :<%= table_name %>, :<%= column_name %>
16
+ <% end %>
17
+ <% end %>
18
+ end
19
+ end
@@ -0,0 +1,225 @@
1
+ module RailsPulse
2
+ module Generators
3
+ class UpgradeGenerator < Rails::Generators::Base
4
+ include Rails::Generators::Migration
5
+ source_root File.expand_path("templates", __dir__)
6
+
7
+ desc "Upgrade Rails Pulse database schema to the latest version"
8
+
9
+ class_option :database, type: :string, default: "detect",
10
+ desc: "Database setup: 'single', 'separate', or 'detect' (default)"
11
+
12
+ def self.next_migration_number(path)
13
+ next_migration_number = current_migration_number(path) + 1
14
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
15
+ end
16
+
17
+ def check_current_installation
18
+ @database_type = detect_database_setup
19
+
20
+ say "Detected database setup: #{@database_type}", :green
21
+
22
+ case @database_type
23
+ when :single
24
+ upgrade_single_database
25
+ when :separate
26
+ upgrade_separate_database
27
+ when :schema_only
28
+ offer_conversion_to_migrations
29
+ when :not_installed
30
+ say "Rails Pulse not detected. Run 'rails generate rails_pulse:install' first.", :red
31
+ exit 1
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def detect_database_setup
38
+ # Override with command line option if provided
39
+ return options[:database].to_sym if options[:database] != "detect"
40
+
41
+ # Check for existing Rails Pulse tables
42
+ tables_exist = rails_pulse_tables_exist?
43
+
44
+ if !tables_exist && File.exist?("db/rails_pulse_schema.rb")
45
+ :schema_only
46
+ elsif !tables_exist
47
+ :not_installed
48
+ elsif File.exist?("db/rails_pulse_migrate")
49
+ :separate
50
+ else
51
+ :single
52
+ end
53
+ end
54
+
55
+ def rails_pulse_tables_exist?
56
+ return false unless defined?(ActiveRecord::Base)
57
+
58
+ connection = ActiveRecord::Base.connection
59
+ required_tables = get_rails_pulse_table_names
60
+
61
+ required_tables.all? { |table| connection.table_exists?(table) }
62
+ rescue
63
+ false
64
+ end
65
+
66
+ def get_rails_pulse_table_names
67
+ # Load the schema file to get the table names dynamically
68
+ schema_file = File.join(Rails.root, "db/rails_pulse_schema.rb")
69
+
70
+ if File.exist?(schema_file)
71
+ # Read the schema file and extract the required_tables array
72
+ schema_content = File.read(schema_file)
73
+
74
+ # Extract the required_tables line using regex
75
+ if match = schema_content.match(/required_tables\s*=\s*\[(.*?)\]/m)
76
+ # Parse the array content, handling symbols and strings
77
+ table_names = match[1].scan(/:(\w+)/).flatten
78
+ return table_names.map(&:to_s)
79
+ end
80
+ end
81
+
82
+ # Fallback to default table names if schema file parsing fails
83
+ %w[rails_pulse_routes rails_pulse_queries rails_pulse_requests rails_pulse_operations rails_pulse_summaries]
84
+ end
85
+
86
+ def upgrade_single_database
87
+ missing_columns = detect_missing_columns
88
+
89
+ if missing_columns.empty?
90
+ say "Rails Pulse is up to date! No migration needed.", :green
91
+ return
92
+ end
93
+
94
+ # Format missing columns by table for the template
95
+ missing_by_table = format_missing_columns_by_table(missing_columns)
96
+
97
+ say "Creating upgrade migration for missing columns: #{missing_columns.keys.join(', ')}", :blue
98
+
99
+ # Set instance variables for template
100
+ @migration_version = ActiveRecord::Migration.current_version
101
+ @missing_columns = missing_by_table
102
+
103
+ migration_template(
104
+ "migrations/upgrade_rails_pulse_tables.rb",
105
+ "db/migrate/upgrade_rails_pulse_tables.rb"
106
+ )
107
+
108
+ say <<~MESSAGE
109
+
110
+ Upgrade migration created successfully!
111
+
112
+ Next steps:
113
+ 1. Run: rails db:migrate
114
+ 2. Restart your Rails server
115
+
116
+ This migration will add: #{missing_columns.keys.join(', ')}
117
+
118
+ MESSAGE
119
+ end
120
+
121
+ def upgrade_separate_database
122
+ # For separate database, we'd need to check the schema file and generate migrations
123
+ # in db/rails_pulse_migrate/ directory
124
+ say "Separate database upgrade not implemented yet. Please check db/rails_pulse_schema.rb for updates.", :yellow
125
+ end
126
+
127
+ def offer_conversion_to_migrations
128
+ say <<~MESSAGE
129
+
130
+ Rails Pulse schema detected but no tables found.
131
+
132
+ To convert to single database setup:
133
+ 1. Run: rails generate rails_pulse:convert_to_migrations
134
+ 2. Run: rails db:migrate
135
+ 3. Delete: db/rails_pulse_schema.rb
136
+
137
+ MESSAGE
138
+ end
139
+
140
+ def detect_missing_columns
141
+ return {} unless rails_pulse_tables_exist?
142
+
143
+ connection = ActiveRecord::Base.connection
144
+ missing = {}
145
+
146
+ # Get expected schema from the schema file
147
+ expected_schema = get_expected_schema_from_file
148
+
149
+ expected_schema.each do |table_name, columns|
150
+ table_symbol = table_name.to_sym
151
+
152
+ if connection.table_exists?(table_symbol)
153
+ existing_columns = connection.columns(table_symbol).map(&:name)
154
+
155
+ columns.each do |column_name, definition|
156
+ unless existing_columns.include?(column_name)
157
+ missing[column_name] = definition
158
+ end
159
+ end
160
+ end
161
+ end
162
+
163
+ missing
164
+ end
165
+
166
+ def get_expected_schema_from_file
167
+ schema_file = File.join(Rails.root, "db/rails_pulse_schema.rb")
168
+ return {} unless File.exist?(schema_file)
169
+
170
+ schema_content = File.read(schema_file)
171
+ expected_columns = {}
172
+
173
+ # Find each create_table block and parse its contents
174
+ table_blocks = schema_content.scan(/connection\.create_table\s+:(\w+).*?do\s*\|t\|(.*?)(?:connection\.add_index|connection\.create_table|\z)/m)
175
+
176
+ table_blocks.each do |table_name, table_block|
177
+ columns = {}
178
+
179
+ # Split the table block into lines and process each line
180
+ table_block.split("\n").each do |line|
181
+ # Match column definitions like: t.text :index_recommendations, comment: "..."
182
+ if match = line.match(/t\.(\w+)\s+:([a-zA-Z_][a-zA-Z0-9_]*)(?:.*?comment:\s*"([^"]*)")?/)
183
+ column_type, column_name, comment = match.captures
184
+
185
+ # Skip timestamps and references as they're handled by Rails
186
+ next if %w[timestamps references].include?(column_type)
187
+
188
+ columns[column_name] = {
189
+ type: column_type.to_sym,
190
+ comment: comment
191
+ }.compact
192
+ end
193
+ end
194
+
195
+ expected_columns[table_name] = columns if columns.any?
196
+ end
197
+
198
+ expected_columns
199
+ end
200
+
201
+ def format_missing_columns_by_table(missing_columns)
202
+ # The missing_columns are already organized by table from detect_missing_columns
203
+ # but we need to restructure them for the template
204
+ missing_by_table = {}
205
+
206
+ # Get expected schema to find which table each missing column belongs to
207
+ expected_schema = get_expected_schema_from_file
208
+
209
+ expected_schema.each do |table_name, expected_columns|
210
+ table_missing = {}
211
+
212
+ expected_columns.each do |column_name, definition|
213
+ if missing_columns.key?(column_name)
214
+ table_missing[column_name] = definition
215
+ end
216
+ end
217
+
218
+ missing_by_table[table_name] = table_missing if table_missing.any?
219
+ end
220
+
221
+ missing_by_table
222
+ end
223
+ end
224
+ end
225
+ end
@@ -1,3 +1,3 @@
1
1
  module RailsPulse
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.3"
3
3
  end