rails_pulse 0.1.3 → 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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +134 -16
  3. data/Rakefile +315 -83
  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 +18 -21
  14. data/app/controllers/rails_pulse/requests_controller.rb +80 -35
  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/breadcrumbs_helper.rb +1 -1
  19. data/app/helpers/rails_pulse/chart_helper.rb +1 -1
  20. data/app/helpers/rails_pulse/form_helper.rb +75 -0
  21. data/app/helpers/rails_pulse/formatting_helper.rb +21 -2
  22. data/app/helpers/rails_pulse/tags_helper.rb +29 -0
  23. data/app/javascript/rails_pulse/application.js +6 -0
  24. data/app/javascript/rails_pulse/controllers/custom_range_controller.js +115 -0
  25. data/app/javascript/rails_pulse/controllers/datepicker_controller.js +48 -0
  26. data/app/javascript/rails_pulse/controllers/global_filters_controller.js +110 -0
  27. data/app/javascript/rails_pulse/controllers/index_controller.js +11 -3
  28. data/app/models/concerns/taggable.rb +61 -0
  29. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +1 -1
  30. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
  31. data/app/models/rails_pulse/queries/cards/average_query_times.rb +1 -1
  32. data/app/models/rails_pulse/queries/cards/execution_rate.rb +56 -17
  33. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +1 -1
  34. data/app/models/rails_pulse/queries/charts/average_query_times.rb +3 -7
  35. data/app/models/rails_pulse/queries/tables/index.rb +10 -2
  36. data/app/models/rails_pulse/query.rb +2 -0
  37. data/app/models/rails_pulse/request.rb +10 -2
  38. data/app/models/rails_pulse/requests/charts/average_response_times.rb +2 -2
  39. data/app/models/rails_pulse/requests/tables/index.rb +77 -0
  40. data/app/models/rails_pulse/route.rb +2 -0
  41. data/app/models/rails_pulse/routes/cards/average_response_times.rb +1 -1
  42. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +1 -1
  43. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +1 -1
  44. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +16 -5
  45. data/app/models/rails_pulse/routes/tables/index.rb +14 -4
  46. data/app/models/rails_pulse/summary.rb +7 -7
  47. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +11 -3
  48. data/app/services/rails_pulse/summary_service.rb +2 -0
  49. data/app/views/layouts/rails_pulse/_global_filters.html.erb +84 -0
  50. data/app/views/layouts/rails_pulse/_menu_items.html.erb +5 -5
  51. data/app/views/layouts/rails_pulse/application.html.erb +8 -5
  52. data/app/views/rails_pulse/components/_metric_card.html.erb +2 -2
  53. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  54. data/app/views/rails_pulse/components/_page_header.html.erb +20 -0
  55. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +1 -1
  56. data/app/views/rails_pulse/dashboard/index.html.erb +1 -1
  57. data/app/views/rails_pulse/operations/show.html.erb +1 -1
  58. data/app/views/rails_pulse/queries/_analysis_results.html.erb +53 -23
  59. data/app/views/rails_pulse/queries/_show_table.html.erb +33 -5
  60. data/app/views/rails_pulse/queries/_table.html.erb +4 -6
  61. data/app/views/rails_pulse/queries/index.html.erb +3 -7
  62. data/app/views/rails_pulse/queries/show.html.erb +3 -7
  63. data/app/views/rails_pulse/requests/_table.html.erb +32 -19
  64. data/app/views/rails_pulse/requests/index.html.erb +45 -55
  65. data/app/views/rails_pulse/requests/show.html.erb +1 -3
  66. data/app/views/rails_pulse/routes/_requests_table.html.erb +41 -0
  67. data/app/views/rails_pulse/routes/_table.html.erb +4 -8
  68. data/app/views/rails_pulse/routes/index.html.erb +4 -8
  69. data/app/views/rails_pulse/routes/show.html.erb +6 -12
  70. data/app/views/rails_pulse/tags/_tag_manager.html.erb +73 -0
  71. data/config/initializers/rails_charts_csp_patch.rb +32 -40
  72. data/config/routes.rb +5 -0
  73. data/db/migrate/20250930105043_install_rails_pulse_tables.rb +23 -0
  74. data/db/rails_pulse_schema.rb +4 -1
  75. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +25 -9
  76. data/lib/generators/rails_pulse/install_generator.rb +30 -7
  77. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +75 -2
  78. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +3 -2
  79. data/lib/generators/rails_pulse/templates/rails_pulse.rb +21 -0
  80. data/lib/generators/rails_pulse/upgrade_generator.rb +147 -30
  81. data/lib/rails_pulse/configuration.rb +16 -1
  82. data/lib/rails_pulse/engine.rb +21 -0
  83. data/lib/rails_pulse/version.rb +1 -1
  84. data/public/rails-pulse-assets/rails-pulse-icons.js +16 -15
  85. data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
  86. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  87. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  88. data/public/rails-pulse-assets/rails-pulse.js +73 -69
  89. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  90. metadata +20 -5
  91. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +0 -12
  92. data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +0 -54
  93. data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +0 -13
@@ -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,61 +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
+ %>
26
+
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
+ %>
33
38
 
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
- <% end %>
39
+ <%= form.select :duration_gteq,
40
+ duration_options(:request),
41
+ { selected: @selected_response_range, prompt: "Min duration" },
42
+ { class: "input" }
43
+ %>
59
44
 
60
- <%= turbo_frame_tag :index_table, data: { rails_pulse__index_target: "indexTable" } do %>
61
- <%= render 'rails_pulse/requests/table' %>
62
- <% end %>
63
- <% else %>
64
- <%= render 'rails_pulse/components/empty_state',
65
- title: 'No request data found for the selected filters.',
66
- 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' %>
67
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.' %>
68
58
  <% end %>
69
- </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">
@@ -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,41 @@
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
+ { field: nil, label: 'Tags', class: 'w-32' }
6
+ ] %>
7
+
8
+ <table class="table mbs-4" data-controller="rails-pulse--table-sort">
9
+ <%= render "rails_pulse/components/table_head", columns: columns %>
10
+
11
+ <tbody>
12
+ <% @table_data.each do |request| %>
13
+ <tr>
14
+ <td class="whitespace-nowrap">
15
+ <%= link_to human_readable_occurred_at(request.occurred_at), request_path(request), data: { turbo_frame: '_top' } %>
16
+ </td>
17
+ <td class="whitespace-nowrap">
18
+ <% performance_class = case request.duration
19
+ when 0..100 then "text-green-600"
20
+ when 100..300 then "text-yellow-600"
21
+ when 300..1000 then "text-orange-600"
22
+ else "text-red-600"
23
+ end %>
24
+ <span class="<%= performance_class %> font-medium">
25
+ <%= request.duration.round(2) %> ms
26
+ </span>
27
+ </td>
28
+ <td class="whitespace-nowrap">
29
+ <% if request.is_error? %>
30
+ <span class="text-red-600">Error (<%= request.status %>)</span>
31
+ <% else %>
32
+ <span class="text-green-600"><%= request.status %></span>
33
+ <% end %>
34
+ </td>
35
+ <td class="whitespace-nowrap"><%= display_tag_badges(request) %></td>
36
+ </tr>
37
+ <% end %>
38
+ </tbody>
39
+ </table>
40
+
41
+ <%= render "rails_pulse/components/table_pagination" %>
@@ -1,11 +1,9 @@
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' },
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
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 }
6
+ { field: nil, label: 'Tags', class: 'w-32' }
9
7
  ] %>
10
8
 
11
9
  <table class="table mbs-4" data-controller="rails-pulse--table-sort">
@@ -18,9 +16,7 @@
18
16
  <td class="whitespace-nowrap"><%= summary.avg_duration.to_i %> ms</td>
19
17
  <td class="whitespace-nowrap"><%= summary.max_duration.to_i %> ms</td>
20
18
  <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>
19
+ <td class="whitespace-nowrap"><%= display_tag_badges(summary.tags) %></td>
24
20
  </tr>
25
21
  <% end %>
26
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,6 +1,4 @@
1
- <%= render 'rails_pulse/components/breadcrumbs' %>
2
-
3
- <h1 class="text-2xl mis-2"><%= @route.path_and_method %></h1>
1
+ <%= render 'rails_pulse/components/page_header', taggable: @route %>
4
2
 
5
3
  <% unless turbo_frame_request? %>
6
4
  <div class="row">
@@ -14,17 +12,13 @@
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 %>
21
- <%= 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| %>
22
20
  <div class="flex items-center grow gap">
23
- <%= form.select :period_start_range,
24
- RailsPulse::RoutesController::TIME_RANGE_OPTIONS,
25
- { selected: @selected_time_range },
26
- { class: "input" }
27
- %>
21
+ <%= time_range_selector(form, time_range_options: RailsPulse::RoutesController::TIME_RANGE_OPTIONS, selected_time_range: @selected_time_range) %>
28
22
  <%= form.select :avg_duration,
29
23
  duration_options(:route),
30
24
  { selected: @selected_response_range },
@@ -44,7 +38,7 @@
44
38
  <%= bar_chart(
45
39
  @chart_data,
46
40
  code: false,
47
- id: "route_repsonses_chart",
41
+ id: "route_responses_chart",
48
42
  height: "100%",
49
43
  options: bar_chart_options(
50
44
  units: "ms",
@@ -62,7 +56,7 @@
62
56
  <% end %>
63
57
 
64
58
  <%= turbo_frame_tag :index_table, data: { rails_pulse__index_target: "indexTable" } do %>
65
- <%= render 'rails_pulse/requests/table' %>
59
+ <%= render 'rails_pulse/routes/requests_table' %>
66
60
  <% end %>
67
61
  <% else %>
68
62
  <%= render 'rails_pulse/components/empty_state',
@@ -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>
@@ -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}\">"
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
@@ -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) }
@@ -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
 
@@ -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,29 @@ 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
+
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
+
21
44
  def copy_initializer
22
45
  copy_file "rails_pulse.rb", "config/initializers/rails_pulse.rb"
23
46
  end
@@ -45,10 +68,9 @@ module RailsPulse
45
68
  end
46
69
 
47
70
  def create_separate_database_setup
48
- create_file "db/rails_pulse_migrate/.keep"
49
-
50
71
  say "Setting up separate database configuration...", :green
51
72
 
73
+ # Migration directory already created by create_migration_directory
52
74
  # Could add database.yml configuration here if needed
53
75
  # For now, users will configure manually
54
76
  end
@@ -80,7 +102,8 @@ module RailsPulse
80
102
  2. Run: rails db:prepare (creates database and loads schema)
81
103
  3. Restart your Rails server
82
104
 
83
- Future schema changes will come as regular migrations in db/rails_pulse_migrate/
105
+ The schema file db/rails_pulse_schema.rb is your single source of truth.
106
+ Future upgrades will automatically copy new migrations to db/rails_pulse_migrate/
84
107
 
85
108
  MESSAGE
86
109
  end
@@ -92,12 +115,12 @@ module RailsPulse
92
115
 
93
116
  Next steps:
94
117
  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
118
+ 2. Restart your Rails server
97
119
 
98
- Future schema changes will come as regular migrations in db/migrate/
120
+ The schema file db/rails_pulse_schema.rb is your single source of truth.
121
+ Future upgrades will automatically copy new migrations to db/migrate/
99
122
 
100
- Note: The installation migration was created from db/rails_pulse_schema.rb
123
+ Note: The installation migration loads from db/rails_pulse_schema.rb
101
124
  and includes all current Rails Pulse tables and columns.
102
125
 
103
126
  MESSAGE