rails_pulse 0.1.4 → 0.2.2

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +78 -0
  3. data/Rakefile +152 -3
  4. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  5. data/app/assets/stylesheets/rails_pulse/components/datepicker.css +191 -0
  6. data/app/assets/stylesheets/rails_pulse/components/switch.css +36 -0
  7. data/app/assets/stylesheets/rails_pulse/components/tags.css +98 -0
  8. data/app/assets/stylesheets/rails_pulse/components/utilities.css +26 -0
  9. data/app/controllers/concerns/response_range_concern.rb +15 -2
  10. data/app/controllers/concerns/tag_filter_concern.rb +26 -0
  11. data/app/controllers/concerns/time_range_concern.rb +27 -8
  12. data/app/controllers/rails_pulse/application_controller.rb +73 -0
  13. data/app/controllers/rails_pulse/queries_controller.rb +4 -1
  14. data/app/controllers/rails_pulse/requests_controller.rb +40 -8
  15. data/app/controllers/rails_pulse/routes_controller.rb +4 -2
  16. data/app/controllers/rails_pulse/tags_controller.rb +51 -0
  17. data/app/helpers/rails_pulse/application_helper.rb +2 -0
  18. data/app/helpers/rails_pulse/form_helper.rb +75 -0
  19. data/app/helpers/rails_pulse/tags_helper.rb +29 -0
  20. data/app/javascript/rails_pulse/application.js +6 -0
  21. data/app/javascript/rails_pulse/controllers/custom_range_controller.js +115 -0
  22. data/app/javascript/rails_pulse/controllers/datepicker_controller.js +48 -0
  23. data/app/javascript/rails_pulse/controllers/global_filters_controller.js +110 -0
  24. data/app/models/concerns/taggable.rb +61 -0
  25. data/app/models/rails_pulse/queries/tables/index.rb +10 -2
  26. data/app/models/rails_pulse/query.rb +2 -0
  27. data/app/models/rails_pulse/request.rb +9 -1
  28. data/app/models/rails_pulse/route.rb +2 -0
  29. data/app/models/rails_pulse/routes/tables/index.rb +10 -2
  30. data/app/services/rails_pulse/summary_service.rb +2 -0
  31. data/app/views/layouts/rails_pulse/_global_filters.html.erb +84 -0
  32. data/app/views/layouts/rails_pulse/_menu_items.html.erb +5 -5
  33. data/app/views/layouts/rails_pulse/application.html.erb +8 -5
  34. data/app/views/rails_pulse/components/_page_header.html.erb +20 -0
  35. data/app/views/rails_pulse/operations/show.html.erb +1 -1
  36. data/app/views/rails_pulse/queries/_table.html.erb +3 -1
  37. data/app/views/rails_pulse/queries/index.html.erb +3 -7
  38. data/app/views/rails_pulse/queries/show.html.erb +3 -7
  39. data/app/views/rails_pulse/requests/_table.html.erb +3 -1
  40. data/app/views/rails_pulse/requests/index.html.erb +44 -62
  41. data/app/views/rails_pulse/requests/show.html.erb +1 -1
  42. data/app/views/rails_pulse/routes/_requests_table.html.erb +3 -1
  43. data/app/views/rails_pulse/routes/_table.html.erb +3 -1
  44. data/app/views/rails_pulse/routes/index.html.erb +4 -8
  45. data/app/views/rails_pulse/routes/show.html.erb +3 -7
  46. data/app/views/rails_pulse/tags/_tag_manager.html.erb +73 -0
  47. data/config/routes.rb +5 -0
  48. data/db/rails_pulse_schema.rb +3 -0
  49. data/lib/generators/rails_pulse/install_generator.rb +21 -2
  50. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +3 -0
  51. data/lib/generators/rails_pulse/templates/rails_pulse.rb +21 -0
  52. data/lib/generators/rails_pulse/upgrade_generator.rb +145 -29
  53. data/lib/rails_pulse/configuration.rb +16 -1
  54. data/lib/rails_pulse/version.rb +1 -1
  55. data/public/rails-pulse-assets/rails-pulse-icons.js +16 -15
  56. data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
  57. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  58. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  59. data/public/rails-pulse-assets/rails-pulse.js +73 -69
  60. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  61. metadata +17 -3
  62. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +0 -12
@@ -1,4 +1,4 @@
1
- <%= render 'rails_pulse/components/breadcrumbs' %>
1
+ <%= render 'rails_pulse/components/page_header' %>
2
2
 
3
3
  <% unless turbo_frame_request? %>
4
4
  <div class="row">
@@ -9,69 +9,51 @@
9
9
  </div>
10
10
  <% end %>
11
11
 
12
- <div
13
- data-controller="rails-pulse--index"
14
- data-rails-pulse--index-chart-id-value="average_response_times_chart"
15
- >
16
- <%= render 'rails_pulse/components/panel', { title: 'Average Response Time', } do %>
17
- <%= search_form_for @ransack_query, url: requests_path, class: "flex items-center justify-between gap mb-4" do |form| %>
18
- <div class="flex items-center grow gap">
19
- <%= form.select :period_start_range,
20
- RailsPulse::RequestsController::TIME_RANGE_OPTIONS,
21
- { selected: @selected_time_range },
22
- { class: "input" }
23
- %>
24
- <%= form.select :avg_duration,
25
- duration_options(:request),
26
- { selected: @selected_response_range },
27
- { class: "input" }
28
- %>
29
- <%= link_to "Reset", requests_path, class: "btn btn--borderless show@md" if params.has_key?(:q) %>
30
- <%= form.submit "Search", class: "btn show@sm" %>
31
- </div>
32
- <% end %>
12
+ <%= render 'rails_pulse/components/panel', { title: 'Requests', } do %>
13
+ <%= search_form_for @ransack_query, url: requests_path, class: "flex items-center justify-between gap mb-4", data: { controller: "rails-pulse--custom-range" } do |form| %>
14
+ <div class="flex items-center grow gap">
15
+ <%= time_range_selector(form,
16
+ time_range_options: RailsPulse::RequestsController::TIME_RANGE_OPTIONS,
17
+ selected_time_range: @selected_time_range,
18
+ mode: :recent_custom
19
+ ) %>
20
+
21
+ <%= form.search_field :route_path_cont,
22
+ placeholder: "Filter by route",
23
+ autocomplete: "off",
24
+ class: "input"
25
+ %>
33
26
 
34
- <% if @has_data %>
35
- <% if @chart_data && @chart_data.values.any? { |v| v > 0 } %>
36
- <div
37
- class="chart-container chart-container--slim"
38
- data-rails-pulse--index-target="chart"
39
- >
40
- <%= bar_chart(
41
- @chart_data,
42
- code: false,
43
- id: "average_response_times_chart",
44
- height: "100%",
45
- options: bar_chart_options(
46
- units: "ms",
47
- zoom: true,
48
- chart_start: 0,
49
- chart_end: @chart_data.length - 1,
50
- xaxis_formatter: @xaxis_formatter,
51
- tooltip_formatter: @tooltip_formatter,
52
- zoom_start: @zoom_start,
53
- zoom_end: @zoom_end,
54
- chart_data: @chart_data
55
- )
56
- ) %>
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>
62
- <% end %>
27
+ <%= form.select :status_category_eq,
28
+ [
29
+ ["All statuses", ""],
30
+ ["2xx Success", "2"],
31
+ ["3xx Redirect", "3"],
32
+ ["4xx Client Error", "4"],
33
+ ["5xx Server Error", "5"]
34
+ ],
35
+ {},
36
+ { class: "input" }
37
+ %>
63
38
 
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>
39
+ <%= form.select :duration_gteq,
40
+ duration_options(:request),
41
+ { selected: @selected_response_range, prompt: "Min duration" },
42
+ { class: "input" }
43
+ %>
67
44
 
68
- <%= turbo_frame_tag :index_table, data: { rails_pulse__index_target: "indexTable" } do %>
69
- <%= render 'rails_pulse/requests/table' %>
70
- <% end %>
71
- <% else %>
72
- <%= render 'rails_pulse/components/empty_state',
73
- title: 'No request data found for the selected filters.',
74
- description: 'Try adjusting your time range or filters to see results.' %>
45
+ <%= link_to "Reset", requests_path, class: "btn btn--borderless show@md" if params.has_key?(:q) %>
46
+ <%= form.submit "Search", class: "btn show@sm" %>
47
+ </div>
48
+ <% end %>
49
+
50
+ <% if @has_data %>
51
+ <%= turbo_frame_tag :index_table do %>
52
+ <%= render 'rails_pulse/requests/table' %>
75
53
  <% end %>
54
+ <% else %>
55
+ <%= render 'rails_pulse/components/empty_state',
56
+ title: 'No request data found for the selected filters.',
57
+ description: 'Try adjusting your time range or filters to see results.' %>
76
58
  <% end %>
77
- </div>
59
+ <% end %>
@@ -1,4 +1,4 @@
1
- <%= render 'rails_pulse/components/breadcrumbs' %>
1
+ <%= render 'rails_pulse/components/page_header', taggable: @request %>
2
2
 
3
3
  <div class="row">
4
4
  <div class="grid-item">
@@ -1,7 +1,8 @@
1
1
  <% columns = [
2
2
  { field: :occurred_at, label: 'Timestamp', class: 'w-auto' },
3
3
  { field: :duration, label: 'Response Time', class: 'w-36' },
4
- { field: :status, label: 'Status', class: 'w-20' }
4
+ { field: :status, label: 'Status', class: 'w-20' },
5
+ { field: nil, label: 'Tags', class: 'w-32' }
5
6
  ] %>
6
7
 
7
8
  <table class="table mbs-4" data-controller="rails-pulse--table-sort">
@@ -31,6 +32,7 @@
31
32
  <span class="text-green-600"><%= request.status %></span>
32
33
  <% end %>
33
34
  </td>
35
+ <td class="whitespace-nowrap"><%= display_tag_badges(request) %></td>
34
36
  </tr>
35
37
  <% end %>
36
38
  </tbody>
@@ -2,7 +2,8 @@
2
2
  { field: :route_path, label: 'Route', class: 'w-auto' },
3
3
  { field: :avg_duration_sort, label: 'Average Response Time', class: 'w-48' },
4
4
  { field: :max_duration_sort, label: 'Max Response Time', class: 'w-44' },
5
- { field: :count_sort, label: 'Requests', class: 'w-24' }
5
+ { field: :count_sort, label: 'Requests', class: 'w-24' },
6
+ { field: nil, label: 'Tags', class: 'w-32' }
6
7
  ] %>
7
8
 
8
9
  <table class="table mbs-4" data-controller="rails-pulse--table-sort">
@@ -15,6 +16,7 @@
15
16
  <td class="whitespace-nowrap"><%= summary.avg_duration.to_i %> ms</td>
16
17
  <td class="whitespace-nowrap"><%= summary.max_duration.to_i %> ms</td>
17
18
  <td class="whitespace-nowrap"><%= number_with_delimiter summary.count %></td>
19
+ <td class="whitespace-nowrap"><%= display_tag_badges(summary.tags) %></td>
18
20
  </tr>
19
21
  <% end %>
20
22
  </tbody>
@@ -1,4 +1,4 @@
1
- <%= render 'rails_pulse/components/breadcrumbs' %>
1
+ <%= render 'rails_pulse/components/page_header' %>
2
2
 
3
3
  <% unless turbo_frame_request? %>
4
4
  <div class="row">
@@ -14,14 +14,10 @@
14
14
  data-rails-pulse--index-chart-id-value="average_response_times_chart"
15
15
  >
16
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| %>
17
+ <%= search_form_for @ransack_query, url: routes_path, class: "flex items-center justify-between gap mb-4", data: { controller: "rails-pulse--custom-range" } do |form| %>
18
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
- %>
19
+ <%= time_range_selector(form, time_range_options: RailsPulse::RoutesController::TIME_RANGE_OPTIONS, selected_time_range: @selected_time_range) %>
20
+ <%= form.search_field :route_path_cont, placeholder: "Filter by route", autocomplete: "off", class: "input" %>
25
21
  <%= form.select :avg_duration,
26
22
  duration_options(:route),
27
23
  { selected: @selected_response_range },
@@ -1,4 +1,4 @@
1
- <%= render 'rails_pulse/components/breadcrumbs' %>
1
+ <%= render 'rails_pulse/components/page_header', taggable: @route %>
2
2
 
3
3
  <% unless turbo_frame_request? %>
4
4
  <div class="row">
@@ -16,13 +16,9 @@
16
16
  >
17
17
  <div class="grid-item">
18
18
  <%= render 'rails_pulse/components/panel', { title: 'Route Reqeusts', } do %>
19
- <%= search_form_for @ransack_query, url: route_path(@route), class: "flex items-center justify-between gap mb-4" do |form| %>
19
+ <%= search_form_for @ransack_query, url: route_path(@route), class: "flex items-center justify-between gap mb-4", data: { controller: "rails-pulse--custom-range" } do |form| %>
20
20
  <div class="flex items-center grow gap">
21
- <%= form.select :period_start_range,
22
- RailsPulse::RoutesController::TIME_RANGE_OPTIONS,
23
- { selected: @selected_time_range },
24
- { class: "input" }
25
- %>
21
+ <%= time_range_selector(form, time_range_options: RailsPulse::RoutesController::TIME_RANGE_OPTIONS, selected_time_range: @selected_time_range) %>
26
22
  <%= form.select :avg_duration,
27
23
  duration_options(:route),
28
24
  { selected: @selected_response_range },
@@ -0,0 +1,73 @@
1
+ <%
2
+ taggable_type = taggable.class.name.demodulize.underscore
3
+ available_tags = RailsPulse.configuration.tags
4
+ current_tags = taggable.tag_list
5
+ available_to_add = available_tags - current_tags
6
+ %>
7
+
8
+ <div
9
+ id="tag_manager_<%= taggable_type %>_<%= taggable.id %>"
10
+ class="tag-manager"
11
+ >
12
+ <div class="tag-list">
13
+ <% current_tags.each do |tag| %>
14
+ <span class="tag">
15
+ <%= tag %>
16
+ <%= button_to remove_tag_path(taggable_type, taggable.id, tag: tag),
17
+ method: :delete,
18
+ class: "tag-remove",
19
+ data: {
20
+ turbo_frame: "_top"
21
+ } do %>
22
+ <span aria-hidden="true">&times;</span>
23
+ <% end %>
24
+ </span>
25
+ <% end %>
26
+
27
+ <% if available_to_add.any? %>
28
+ <div class="tag-add-container" data-controller="rails-pulse--popover" data-rails-pulse--popover-placement-value="bottom-start">
29
+ <button
30
+ type="button"
31
+ id="tag_menu_button_<%= taggable_type %>_<%= taggable.id %>"
32
+ class="btn btn--sm tag-add-button"
33
+ data-rails-pulse--popover-target="button"
34
+ data-action="rails-pulse--popover#toggle"
35
+ aria-haspopup="true"
36
+ aria-controls="tag_menu_<%= taggable_type %>_<%= taggable.id %>"
37
+ >
38
+ + tag
39
+ </button>
40
+
41
+ <div
42
+ popover
43
+ class="popover"
44
+ style="--popover-size: 12rem;"
45
+ data-rails-pulse--popover-target="menu"
46
+ >
47
+ <div
48
+ id="tag_menu_<%= taggable_type %>_<%= taggable.id %>"
49
+ class="menu"
50
+ data-controller="rails-pulse--menu"
51
+ data-action="keydown.up->rails-pulse--menu#prev keydown.down->rails-pulse--menu#next"
52
+ role="menu"
53
+ aria-labelledby="tag_menu_button_<%= taggable_type %>_<%= taggable.id %>"
54
+ >
55
+ <% available_to_add.each do |tag| %>
56
+ <%= button_to add_tag_path(taggable_type, taggable.id, tag: tag),
57
+ method: :post,
58
+ class: "btn menu__item i-full",
59
+ data: {
60
+ turbo_frame: "_top",
61
+ rails_pulse__menu_target: "item"
62
+ },
63
+ style: "cursor: pointer;",
64
+ role: "menuitem" do %>
65
+ <%= tag %>
66
+ <% end %>
67
+ <% end %>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ <% end %>
72
+ </div>
73
+ </div>
data/config/routes.rb CHANGED
@@ -11,6 +11,11 @@ RailsPulse::Engine.routes.draw do
11
11
  resources :operations, only: %i[show]
12
12
  resources :caches, only: %i[show], as: :cache
13
13
  patch "pagination/limit", to: "application#set_pagination_limit"
14
+ patch "settings/global_filters", to: "application#set_global_filters"
15
+
16
+ # Tag management
17
+ post "tags/:taggable_type/:taggable_id/add", to: "tags#create", as: :add_tag
18
+ delete "tags/:taggable_type/:taggable_id/remove", to: "tags#destroy", as: :remove_tag
14
19
 
15
20
  # CSP compliance testing
16
21
  get "csp_test", to: "csp_test#show", as: :csp_test
@@ -18,6 +18,7 @@ RailsPulse::Schema = lambda do |connection|
18
18
  connection.create_table :rails_pulse_routes do |t|
19
19
  t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)"
20
20
  t.string :path, null: false, comment: "Request path (e.g., /posts/index)"
21
+ t.text :tags, comment: "JSON array of tags for filtering and categorization"
21
22
  t.timestamps
22
23
  end
23
24
 
@@ -34,6 +35,7 @@ RailsPulse::Schema = lambda do |connection|
34
35
  t.text :index_recommendations, comment: "JSON array of database index recommendations"
35
36
  t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results"
36
37
  t.text :suggestions, comment: "JSON array of optimization recommendations"
38
+ t.text :tags, comment: "JSON array of tags for filtering and categorization"
37
39
  t.timestamps
38
40
  end
39
41
 
@@ -47,6 +49,7 @@ RailsPulse::Schema = lambda do |connection|
47
49
  t.string :request_uuid, null: false, comment: "Unique identifier for the request (e.g., UUID)"
48
50
  t.string :controller_action, comment: "Controller and action handling the request (e.g., PostsController#show)"
49
51
  t.timestamp :occurred_at, null: false, comment: "When the request started"
52
+ t.text :tags, comment: "JSON array of tags for filtering and categorization"
50
53
  t.timestamps
51
54
  end
52
55
 
@@ -22,6 +22,25 @@ module RailsPulse
22
22
  create_file "db/rails_pulse_migrate/.keep"
23
23
  end
24
24
 
25
+ def copy_gem_migrations
26
+ gem_migrations_path = File.expand_path("../../../db/rails_pulse_migrate", __dir__)
27
+ destination_dir = separate_database? ? "db/rails_pulse_migrate" : "db/migrate"
28
+
29
+ if File.directory?(gem_migrations_path)
30
+ Dir.glob("#{gem_migrations_path}/*.rb").each do |migration_file|
31
+ migration_name = File.basename(migration_file)
32
+ destination_path = File.join(destination_dir, migration_name)
33
+
34
+ # Only copy if it doesn't already exist in the destination
35
+ # Use File.join with destination_root to check the actual location
36
+ full_destination_path = File.join(destination_root, destination_path)
37
+ unless File.exist?(full_destination_path)
38
+ copy_file migration_file, destination_path
39
+ end
40
+ end
41
+ end
42
+ end
43
+
25
44
  def copy_initializer
26
45
  copy_file "rails_pulse.rb", "config/initializers/rails_pulse.rb"
27
46
  end
@@ -84,7 +103,7 @@ module RailsPulse
84
103
  3. Restart your Rails server
85
104
 
86
105
  The schema file db/rails_pulse_schema.rb is your single source of truth.
87
- Future schema changes will come as regular migrations in db/rails_pulse_migrate/
106
+ Future upgrades will automatically copy new migrations to db/rails_pulse_migrate/
88
107
 
89
108
  MESSAGE
90
109
  end
@@ -99,7 +118,7 @@ module RailsPulse
99
118
  2. Restart your Rails server
100
119
 
101
120
  The schema file db/rails_pulse_schema.rb is your single source of truth.
102
- Future schema changes will come as regular migrations in db/migrate/
121
+ Future upgrades will automatically copy new migrations to db/migrate/
103
122
 
104
123
  Note: The installation migration loads from db/rails_pulse_schema.rb
105
124
  and includes all current Rails Pulse tables and columns.
@@ -18,6 +18,7 @@ RailsPulse::Schema = lambda do |connection|
18
18
  connection.create_table :rails_pulse_routes do |t|
19
19
  t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)"
20
20
  t.string :path, null: false, comment: "Request path (e.g., /posts/index)"
21
+ t.text :tags, comment: "JSON array of tags for filtering and categorization"
21
22
  t.timestamps
22
23
  end
23
24
 
@@ -34,6 +35,7 @@ RailsPulse::Schema = lambda do |connection|
34
35
  t.text :index_recommendations, comment: "JSON array of database index recommendations"
35
36
  t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results"
36
37
  t.text :suggestions, comment: "JSON array of optimization recommendations"
38
+ t.text :tags, comment: "JSON array of tags for filtering and categorization"
37
39
  t.timestamps
38
40
  end
39
41
 
@@ -47,6 +49,7 @@ RailsPulse::Schema = lambda do |connection|
47
49
  t.string :request_uuid, null: false, comment: "Unique identifier for the request (e.g., UUID)"
48
50
  t.string :controller_action, comment: "Controller and action handling the request (e.g., PostsController#show)"
49
51
  t.timestamp :occurred_at, null: false, comment: "When the request started"
52
+ t.text :tags, comment: "JSON array of tags for filtering and categorization"
50
53
  t.timestamps
51
54
  end
52
55
 
@@ -71,6 +71,27 @@ RailsPulse.configure do |config|
71
71
  config.ignored_requests = []
72
72
  config.ignored_queries = []
73
73
 
74
+ # ====================================================================================================
75
+ # TAGGING
76
+ # ====================================================================================================
77
+ # Define custom tags for categorizing routes, requests, and queries.
78
+ # You can add any custom tags you want for filtering and organization.
79
+ #
80
+ # Tag names should be in present tense and describe the current state or category.
81
+ # Examples of good tag names:
82
+ # - "critical" (for high-priority endpoints)
83
+ # - "experimental" (for routes under development)
84
+ # - "deprecated" (for routes being phased out)
85
+ # - "external" (for third-party API calls)
86
+ # - "background" (for async job-related operations)
87
+ # - "admin" (for administrative routes)
88
+ # - "public" (for public-facing routes)
89
+ #
90
+ # Example configuration:
91
+ # config.tags = ["ignored", "critical", "experimental", "deprecated", "external", "admin"]
92
+
93
+ config.tags = [ "ignored", "critical", "experimental" ]
94
+
74
95
  # ====================================================================================================
75
96
  # DATABASE CONFIGURATION
76
97
  # ====================================================================================================
@@ -41,17 +41,36 @@ module RailsPulse
41
41
  # Check for existing Rails Pulse tables
42
42
  tables_exist = rails_pulse_tables_exist?
43
43
 
44
- if !tables_exist && File.exist?("db/rails_pulse_schema.rb")
44
+ root_path = respond_to?(:destination_root) ? destination_root : Rails.root
45
+ schema_path = File.join(root_path, "db/rails_pulse_schema.rb")
46
+
47
+ if !tables_exist && File.exist?(schema_path)
45
48
  :schema_only
46
49
  elsif !tables_exist
47
50
  :not_installed
48
- elsif File.exist?("db/rails_pulse_migrate")
51
+ elsif has_separate_database_config?
49
52
  :separate
50
53
  else
51
54
  :single
52
55
  end
53
56
  end
54
57
 
58
+ def has_separate_database_config?
59
+ root_path = respond_to?(:destination_root) ? destination_root : Rails.root
60
+ config_path = File.join(root_path, "config/database.yml")
61
+
62
+ return false unless File.exist?(config_path)
63
+
64
+ require "yaml"
65
+ db_config = YAML.load_file(config_path)
66
+
67
+ # Check if any environment has a rails_pulse database configuration
68
+ db_config.values.any? { |env| env.is_a?(Hash) && env.key?("rails_pulse") }
69
+ rescue => e
70
+ # If we can't read the file, assume single database
71
+ false
72
+ end
73
+
55
74
  def rails_pulse_tables_exist?
56
75
  return false unless defined?(ActiveRecord::Base)
57
76
 
@@ -65,7 +84,8 @@ module RailsPulse
65
84
 
66
85
  def get_rails_pulse_table_names
67
86
  # Load the schema file to get the table names dynamically
68
- schema_file = File.join(Rails.root, "db/rails_pulse_schema.rb")
87
+ root_path = respond_to?(:destination_root) ? destination_root : Rails.root
88
+ schema_file = File.join(root_path, "db/rails_pulse_schema.rb")
69
89
 
70
90
  if File.exist?(schema_file)
71
91
  # Read the schema file and extract the required_tables array
@@ -84,44 +104,114 @@ module RailsPulse
84
104
  end
85
105
 
86
106
  def upgrade_single_database
87
- missing_columns = detect_missing_columns
107
+ # Check for new migrations in gem
108
+ gem_migrations = get_gem_migrations
109
+ existing_migrations = get_user_migrations("db/migrate")
110
+ new_migrations = gem_migrations - existing_migrations
111
+
112
+ if new_migrations.any?
113
+ say "Found #{new_migrations.size} new migration(s) to copy:", :blue
114
+ new_migrations.each do |migration|
115
+ say " - #{migration}", :blue
116
+ copy_gem_migration_to(migration, "db/migrate")
117
+ end
88
118
 
89
- if missing_columns.empty?
90
- say "Rails Pulse is up to date! No migration needed.", :green
91
- return
92
- end
119
+ say "\nMigrations copied successfully!", :green
120
+ say "\nNext steps:", :green
121
+ say "1. Run: rails db:migrate"
122
+ say "2. Restart your Rails server"
123
+ else
124
+ # Fall back to detecting missing columns
125
+ missing_columns = detect_missing_columns
93
126
 
94
- # Format missing columns by table for the template
95
- missing_by_table = format_missing_columns_by_table(missing_columns)
127
+ if missing_columns.empty?
128
+ say "Rails Pulse is up to date! No migration needed.", :green
129
+ return
130
+ end
96
131
 
97
- say "Creating upgrade migration for missing columns: #{missing_columns.keys.join(', ')}", :blue
132
+ # Format missing columns by table for the template
133
+ missing_by_table = format_missing_columns_by_table(missing_columns)
98
134
 
99
- # Set instance variables for template
100
- @migration_version = ActiveRecord::Migration.current_version
101
- @missing_columns = missing_by_table
135
+ say "Creating upgrade migration for missing columns: #{missing_columns.keys.join(', ')}", :blue
102
136
 
103
- migration_template(
104
- "migrations/upgrade_rails_pulse_tables.rb",
105
- "db/migrate/upgrade_rails_pulse_tables.rb"
106
- )
137
+ # Set instance variables for template
138
+ @migration_version = ActiveRecord::Migration.current_version
139
+ @missing_columns = missing_by_table
107
140
 
108
- say <<~MESSAGE
141
+ migration_template(
142
+ "migrations/upgrade_rails_pulse_tables.rb",
143
+ "db/migrate/upgrade_rails_pulse_tables.rb"
144
+ )
109
145
 
110
- Upgrade migration created successfully!
146
+ say <<~MESSAGE
111
147
 
112
- Next steps:
113
- 1. Run: rails db:migrate
114
- 2. Restart your Rails server
148
+ Upgrade migration created successfully!
115
149
 
116
- This migration will add: #{missing_columns.keys.join(', ')}
150
+ Next steps:
151
+ 1. Run: rails db:migrate
152
+ 2. Restart your Rails server
117
153
 
118
- MESSAGE
154
+ This migration will add: #{missing_columns.keys.join(', ')}
155
+
156
+ MESSAGE
157
+ end
119
158
  end
120
159
 
121
160
  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
161
+ # Check for new migrations in gem
162
+ gem_migrations = get_gem_migrations
163
+ existing_migrations = get_user_migrations("db/rails_pulse_migrate")
164
+ new_migrations = gem_migrations - existing_migrations
165
+
166
+ if new_migrations.any?
167
+ say "Found #{new_migrations.size} new migration(s) to copy:", :blue
168
+ new_migrations.each do |migration|
169
+ say " - #{migration}", :blue
170
+ copy_gem_migration_to(migration, "db/rails_pulse_migrate")
171
+ end
172
+
173
+ say "\nMigrations copied successfully!", :green
174
+ say "\nNext steps:", :green
175
+ say "1. Run migrations for the rails_pulse database:"
176
+ say " rails db:migrate (will run migrations for all databases)"
177
+ say " OR manually run the migration files in db/rails_pulse_migrate/"
178
+ say "2. Restart your Rails server"
179
+ else
180
+ # Fall back to detecting missing columns
181
+ missing_columns = detect_missing_columns
182
+
183
+ if missing_columns.empty?
184
+ say "Rails Pulse is up to date! No migrations needed.", :green
185
+ else
186
+ # Format missing columns by table for the template
187
+ missing_by_table = format_missing_columns_by_table(missing_columns)
188
+
189
+ say "Creating upgrade migration for missing columns: #{missing_columns.keys.join(', ')}", :blue
190
+
191
+ # Set instance variables for template
192
+ @migration_version = ActiveRecord::Migration.current_version
193
+ @missing_columns = missing_by_table
194
+
195
+ migration_template(
196
+ "migrations/upgrade_rails_pulse_tables.rb",
197
+ "db/rails_pulse_migrate/upgrade_rails_pulse_tables.rb"
198
+ )
199
+
200
+ say <<~MESSAGE
201
+
202
+ Upgrade migration created successfully!
203
+
204
+ Next steps:
205
+ 1. Run migrations for the rails_pulse database:
206
+ rails db:migrate (will run migrations for all databases)
207
+ OR manually run the migration files in db/rails_pulse_migrate/
208
+ 2. Restart your Rails server
209
+
210
+ This migration will add: #{missing_columns.keys.join(', ')}
211
+
212
+ MESSAGE
213
+ end
214
+ end
125
215
  end
126
216
 
127
217
  def offer_conversion_to_migrations
@@ -165,7 +255,8 @@ module RailsPulse
165
255
  end
166
256
 
167
257
  def get_expected_schema_from_file
168
- schema_file = File.join(Rails.root, "db/rails_pulse_schema.rb")
258
+ root_path = respond_to?(:destination_root) ? destination_root : Rails.root
259
+ schema_file = File.join(root_path, "db/rails_pulse_schema.rb")
169
260
  return {} unless File.exist?(schema_file)
170
261
 
171
262
  schema_content = File.read(schema_file)
@@ -221,6 +312,31 @@ module RailsPulse
221
312
 
222
313
  missing_by_table
223
314
  end
315
+
316
+ def get_gem_migrations
317
+ gem_migrations_path = File.expand_path("../../../db/rails_pulse_migrate", __dir__)
318
+ return [] unless File.directory?(gem_migrations_path)
319
+
320
+ Dir.glob("#{gem_migrations_path}/*.rb").map { |f| File.basename(f) }
321
+ end
322
+
323
+ def get_user_migrations(directory)
324
+ # Use destination_root in tests, Rails.root in production
325
+ root_path = respond_to?(:destination_root) ? destination_root : Rails.root
326
+ full_directory = File.join(root_path, directory)
327
+
328
+ return [] unless File.directory?(full_directory)
329
+
330
+ Dir.glob("#{full_directory}/*.rb").map { |f| File.basename(f) }
331
+ end
332
+
333
+ def copy_gem_migration_to(migration_name, destination)
334
+ gem_migrations_path = File.expand_path("../../../db/rails_pulse_migrate", __dir__)
335
+ source_file = File.join(gem_migrations_path, migration_name)
336
+ destination_file = File.join(destination, migration_name)
337
+
338
+ copy_file source_file, destination_file
339
+ end
224
340
  end
225
341
  end
226
342
  end