rails_pulse 0.2.2 → 0.2.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/rails_pulse/components/tags.css +2 -2
  3. data/app/controllers/concerns/chart_table_concern.rb +2 -0
  4. data/app/controllers/rails_pulse/application_controller.rb +1 -0
  5. data/app/controllers/rails_pulse/dashboard_controller.rb +12 -8
  6. data/app/controllers/rails_pulse/queries_controller.rb +12 -7
  7. data/app/controllers/rails_pulse/requests_controller.rb +8 -4
  8. data/app/controllers/rails_pulse/routes_controller.rb +13 -6
  9. data/app/models/concerns/rails_pulse/taggable.rb +63 -0
  10. data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +12 -5
  11. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +12 -5
  12. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +7 -0
  13. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +6 -0
  14. data/app/models/rails_pulse/queries/cards/average_query_times.rb +10 -6
  15. data/app/models/rails_pulse/queries/cards/execution_rate.rb +16 -10
  16. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +10 -6
  17. data/app/models/rails_pulse/queries/charts/average_query_times.rb +5 -2
  18. data/app/models/rails_pulse/queries/tables/index.rb +12 -2
  19. data/app/models/rails_pulse/requests/charts/average_response_times.rb +12 -6
  20. data/app/models/rails_pulse/routes/cards/average_response_times.rb +10 -6
  21. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +10 -6
  22. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +10 -6
  23. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +10 -6
  24. data/app/models/rails_pulse/routes/charts/average_response_times.rb +9 -5
  25. data/app/models/rails_pulse/routes/tables/index.rb +12 -2
  26. data/app/models/rails_pulse/summary.rb +55 -0
  27. data/app/views/layouts/rails_pulse/_global_filters.html.erb +9 -2
  28. data/app/views/rails_pulse/components/_active_filters.html.erb +36 -0
  29. data/app/views/rails_pulse/components/_page_header.html.erb +4 -0
  30. data/app/views/rails_pulse/dashboard/index.html.erb +4 -0
  31. data/app/views/rails_pulse/queries/index.html.erb +1 -1
  32. data/app/views/rails_pulse/requests/index.html.erb +1 -1
  33. data/app/views/rails_pulse/routes/index.html.erb +1 -1
  34. data/app/views/rails_pulse/tags/_tag_manager.html.erb +2 -2
  35. data/config/initializers/rails_charts_csp_patch.rb +9 -9
  36. data/lib/rails_pulse/cleanup_service.rb +8 -0
  37. data/lib/rails_pulse/engine.rb +25 -0
  38. data/lib/rails_pulse/version.rb +1 -1
  39. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  40. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  41. metadata +4 -3
  42. data/app/models/concerns/taggable.rb +0 -61
@@ -2,8 +2,10 @@ module RailsPulse
2
2
  module Routes
3
3
  module Cards
4
4
  class RequestCountTotals
5
- def initialize(route: nil)
5
+ def initialize(route: nil, disabled_tags: [], show_non_tagged: true)
6
6
  @route = route
7
+ @disabled_tags = disabled_tags
8
+ @show_non_tagged = show_non_tagged
7
9
  end
8
10
 
9
11
  def to_metric_card
@@ -11,11 +13,13 @@ module RailsPulse
11
13
  previous_7_days = 14.days.ago.beginning_of_day
12
14
 
13
15
  # Single query to get all count metrics with conditional aggregation
14
- base_query = RailsPulse::Summary.where(
15
- summarizable_type: "RailsPulse::Route",
16
- period_type: "day",
17
- period_start: 2.weeks.ago.beginning_of_day..Time.current
18
- )
16
+ base_query = RailsPulse::Summary
17
+ .with_tag_filters(@disabled_tags, @show_non_tagged)
18
+ .where(
19
+ summarizable_type: "RailsPulse::Route",
20
+ period_type: "day",
21
+ period_start: 2.weeks.ago.beginning_of_day..Time.current
22
+ )
19
23
  base_query = base_query.where(summarizable_id: @route.id) if @route
20
24
 
21
25
  metrics = base_query.select(
@@ -2,20 +2,24 @@ module RailsPulse
2
2
  module Routes
3
3
  module Charts
4
4
  class AverageResponseTimes
5
- def initialize(ransack_query:, period_type: nil, route: nil, start_time: nil, end_time: nil, start_duration: nil)
5
+ def initialize(ransack_query:, period_type: nil, route: nil, start_time: nil, end_time: nil, start_duration: nil, disabled_tags: [], show_non_tagged: true)
6
6
  @ransack_query = ransack_query
7
7
  @period_type = period_type
8
8
  @route = route
9
9
  @start_time = start_time
10
10
  @end_time = end_time
11
11
  @start_duration = start_duration
12
+ @disabled_tags = disabled_tags
13
+ @show_non_tagged = show_non_tagged
12
14
  end
13
15
 
14
16
  def to_rails_chart
15
- summaries = @ransack_query.result(distinct: false).where(
16
- summarizable_type: "RailsPulse::Route",
17
- period_type: @period_type
18
- )
17
+ summaries = @ransack_query.result(distinct: false)
18
+ .with_tag_filters(@disabled_tags, @show_non_tagged)
19
+ .where(
20
+ summarizable_type: "RailsPulse::Route",
21
+ period_type: @period_type
22
+ )
19
23
 
20
24
  summaries = summaries.where(summarizable_id: @route.id) if @route
21
25
  summaries = summaries
@@ -2,12 +2,13 @@ module RailsPulse
2
2
  module Routes
3
3
  module Tables
4
4
  class Index
5
- def initialize(ransack_query:, period_type: nil, start_time:, params:, disabled_tags: [])
5
+ def initialize(ransack_query:, period_type: nil, start_time:, params:, disabled_tags: [], show_non_tagged: true)
6
6
  @ransack_query = ransack_query
7
7
  @period_type = period_type
8
8
  @start_time = start_time
9
9
  @params = params
10
10
  @disabled_tags = disabled_tags
11
+ @show_non_tagged = show_non_tagged
11
12
  end
12
13
 
13
14
  def to_table
@@ -24,10 +25,19 @@ module RailsPulse
24
25
  )
25
26
 
26
27
  # Apply tag filters by excluding routes with disabled tags
27
- @disabled_tags.each do |tag|
28
+ # Separate "non_tagged" from actual tags (it's a virtual tag)
29
+ actual_disabled_tags = @disabled_tags.reject { |tag| tag == "non_tagged" }
30
+
31
+ # Exclude routes with actual disabled tags
32
+ actual_disabled_tags.each do |tag|
28
33
  base_query = base_query.where.not("rails_pulse_routes.tags LIKE ?", "%#{tag}%")
29
34
  end
30
35
 
36
+ # Exclude non-tagged routes if show_non_tagged is false
37
+ unless @show_non_tagged
38
+ base_query = base_query.where("rails_pulse_routes.tags IS NOT NULL AND rails_pulse_routes.tags != '[]'")
39
+ end
40
+
31
41
  base_query = base_query.where(summarizable_id: @route.id) if @route
32
42
 
33
43
  # Apply grouping and aggregation
@@ -33,6 +33,61 @@ module RailsPulse
33
33
  where(summarizable_type: "RailsPulse::Request", summarizable_id: 0)
34
34
  }
35
35
 
36
+ # Tag filtering scope for charts and metrics
37
+ # Filters summaries based on disabled tags in the underlying route/query
38
+ scope :with_tag_filters, ->(disabled_tags = [], show_non_tagged = true) {
39
+ # Separate "non_tagged" from actual tags (it's a virtual tag)
40
+ actual_disabled_tags = disabled_tags.reject { |tag| tag == "non_tagged" }
41
+
42
+ # Return early if no filters are applied
43
+ return all if actual_disabled_tags.empty? && show_non_tagged
44
+
45
+ # Determine which table to join based on summarizable_type
46
+ # We need to handle both Route and Query summaries
47
+ relation = all
48
+
49
+ # Filter route summaries
50
+ route_ids = RailsPulse::Route.all
51
+
52
+ # Exclude routes with disabled tags
53
+ actual_disabled_tags.each do |tag|
54
+ route_ids = route_ids.where.not("tags LIKE ?", "%#{tag}%")
55
+ end
56
+
57
+ # Exclude non-tagged routes if show_non_tagged is false
58
+ route_ids = route_ids.where("tags IS NOT NULL AND tags != '[]'") unless show_non_tagged
59
+
60
+ route_ids = route_ids.pluck(:id)
61
+
62
+ # Filter query summaries
63
+ query_ids = RailsPulse::Query.all
64
+
65
+ # Exclude queries with disabled tags
66
+ actual_disabled_tags.each do |tag|
67
+ query_ids = query_ids.where.not("tags LIKE ?", "%#{tag}%")
68
+ end
69
+
70
+ # Exclude non-tagged queries if show_non_tagged is false
71
+ query_ids = query_ids.where("tags IS NOT NULL AND tags != '[]'") unless show_non_tagged
72
+
73
+ query_ids = query_ids.pluck(:id)
74
+
75
+ # Apply filters: include only summaries for filtered routes/queries
76
+ # If no routes/queries match the filter, we need to ensure nothing is returned
77
+ # Use -1 as an impossible ID instead of 0 (which might be used for aggregates)
78
+ relation = relation.where(
79
+ "(" \
80
+ " (summarizable_type = 'RailsPulse::Route' AND summarizable_id IN (?)) OR " \
81
+ " (summarizable_type = 'RailsPulse::Query' AND summarizable_id IN (?)) OR " \
82
+ " (summarizable_type = 'RailsPulse::Request')" \
83
+ ")",
84
+ route_ids.presence || [ -1 ],
85
+ query_ids.presence || [ -1 ]
86
+ )
87
+
88
+ relation
89
+ }
90
+
36
91
  # Ransack configuration
37
92
  def self.ransackable_attributes(auth_object = nil)
38
93
  %w[
@@ -15,8 +15,15 @@
15
15
  <div data-rails-pulse--global-filters-target="wrapper" data-action="click->rails-pulse--global-filters#closeOnClickOutside" style="display: none; position: fixed; inset: 0; background-color: rgba(0, 0, 0, 0.8); z-index: 1000; align-items: center; justify-content: center;">
16
16
  <div class="dialog" data-rails-pulse--global-filters-target="dialog" style="position: relative; opacity: 1; transform: scale(1);">
17
17
  <div class="dialog__content">
18
- <h2 class="text-lg font-semibold mb-4">Global Filters</h2>
19
- <p class="text-sm text-subtle mb-4">Set default time filters that persist across all pages. These can be overridden by page-specific filters.</p>
18
+ <div class="flex items-start justify-between">
19
+ <div>
20
+ <h2 class="text-lg font-semibold mbe-4">Global Filters</h2>
21
+ <p class="text-sm text-subtle">Set default time filters that persist across all pages. These can be overridden by page-specific filters.</p>
22
+ </div>
23
+ <%= link_to '#', "aria-label": "Close", data: { action: "rails-pulse--global-filters#close" }, class: "text-subtle hover:text-default" do %>
24
+ <%= rails_pulse_icon 'x', width: '20' %>
25
+ <% end %>
26
+ </div>
20
27
 
21
28
  <%= form_with url: settings_global_filters_path, method: :patch, local: true, data: { action: "submit->rails-pulse--global-filters#submit" } do |form| %>
22
29
  <div class="flex flex-col gap mb-4">
@@ -0,0 +1,36 @@
1
+ <% global_filters = session_global_filters %>
2
+ <% disabled_tags = global_filters['disabled_tags'] || [] %>
3
+ <% has_date_filters = global_filters['start_time'].present? && global_filters['end_time'].present? %>
4
+ <% has_performance_filter = global_filters['performance_threshold'].present? %>
5
+ <% has_tag_filters = disabled_tags.any? || session[:show_non_tagged] == false %>
6
+ <% has_any_filters = has_date_filters || has_performance_filter || has_tag_filters %>
7
+
8
+ <% if has_any_filters %>
9
+ <div class="flex items-center gap text-sm">
10
+ Filtered:
11
+ <% if has_date_filters %>
12
+ <% start_time = Time.parse(global_filters['start_time']) %>
13
+ <% end_time = Time.parse(global_filters['end_time']) %>
14
+ <span class="badge badge--secondary"><%= start_time.strftime("%b %d, %Y %-I:%M %p") %> - <%= end_time.strftime("%b %d, %Y %-I:%M %p") %></span>
15
+ <% end %>
16
+
17
+ <% if has_performance_filter %>
18
+ <% threshold_label = case global_filters['performance_threshold']
19
+ when 'slow' then 'Slow and Above'
20
+ when 'very_slow' then 'Very Slow and Above'
21
+ when 'critical' then 'Critical'
22
+ else global_filters['performance_threshold'].titleize
23
+ end %>
24
+ <span class="badge badge--secondary"><%= threshold_label %></span>
25
+ <% end %>
26
+
27
+ <% if has_tag_filters %>
28
+ <% disabled_tags.each do |tag| %>
29
+ <span class="badge badge--secondary"><%= tag.humanize %></span>
30
+ <% end %>
31
+ <% if session[:show_non_tagged] == false %>
32
+ <span class="badge badge--secondary">Non tagged hidden</span>
33
+ <% end %>
34
+ <% end %>
35
+ </div>
36
+ <% end %>
@@ -16,5 +16,9 @@
16
16
  <div class="breadcrumb-tags">
17
17
  <%= render 'rails_pulse/tags/tag_manager', taggable: taggable %>
18
18
  </div>
19
+ <% elsif defined?(show_active_filters) && show_active_filters %>
20
+ <div class="breadcrumb-tags">
21
+ <%= render 'rails_pulse/components/active_filters' %>
22
+ </div>
19
23
  <% end %>
20
24
  </div>
@@ -1,3 +1,7 @@
1
+ <div class="flex justify-end mb-1">
2
+ <%= render 'rails_pulse/components/active_filters' %>
3
+ </div>
4
+
1
5
  <div class="row">
2
6
  <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @average_query_times_metric_card } %>
3
7
  <%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @percentile_response_times_metric_card } %>
@@ -1,4 +1,4 @@
1
- <%= render 'rails_pulse/components/page_header' %>
1
+ <%= render 'rails_pulse/components/page_header', show_active_filters: true %>
2
2
 
3
3
  <% unless turbo_frame_request? %>
4
4
  <div class="row">
@@ -1,4 +1,4 @@
1
- <%= render 'rails_pulse/components/page_header' %>
1
+ <%= render 'rails_pulse/components/page_header', show_active_filters: true %>
2
2
 
3
3
  <% unless turbo_frame_request? %>
4
4
  <div class="row">
@@ -1,4 +1,4 @@
1
- <%= render 'rails_pulse/components/page_header' %>
1
+ <%= render 'rails_pulse/components/page_header', show_active_filters: true %>
2
2
 
3
3
  <% unless turbo_frame_request? %>
4
4
  <div class="row">
@@ -11,7 +11,7 @@
11
11
  >
12
12
  <div class="tag-list">
13
13
  <% current_tags.each do |tag| %>
14
- <span class="tag">
14
+ <span class="badge badge--secondary font-normal">
15
15
  <%= tag %>
16
16
  <%= button_to remove_tag_path(taggable_type, taggable.id, tag: tag),
17
17
  method: :delete,
@@ -29,7 +29,7 @@
29
29
  <button
30
30
  type="button"
31
31
  id="tag_menu_button_<%= taggable_type %>_<%= taggable.id %>"
32
- class="btn btn--sm tag-add-button"
32
+ class="badge badge--positive tag-add-button font-normal"
33
33
  data-rails-pulse--popover-target="button"
34
34
  data-action="rails-pulse--popover#toggle"
35
35
  aria-haspopup="true"
@@ -61,15 +61,15 @@ if defined?(RailsCharts)
61
61
  end
62
62
  end
63
63
 
64
- # Apply the patch to ApplicationHelper and any modules that include chart helpers
64
+ # Apply the CSP patch only to Rails Pulse helpers, not the entire application
65
+ # By prepending to ChartHelper instead of ApplicationHelper, we scope the patch to RailsPulse
66
+ # namespace only, avoiding conflicts with any chart libraries in the host application
67
+ # (Chartkick, Highcharts, Google Charts, ApexCharts, custom helpers, etc.)
65
68
  Rails.application.config.to_prepare do
66
- if defined?(RailsCharts)
67
- # Patch ActionView::Base to include our CSP patch for line_chart
68
- ActionView::Base.prepend(RailsCharts::CspPatch)
69
-
70
- # Also patch any Rails Pulse helpers that might use charts
71
- if defined?(RailsPulse::ApplicationHelper)
72
- RailsPulse::ApplicationHelper.prepend(RailsCharts::CspPatch)
73
- end
69
+ if defined?(RailsCharts) && defined?(RailsPulse::ChartHelper)
70
+ # Prepend CSP patch to RailsPulse::ChartHelper
71
+ # This wraps only the rails_charts methods, ensuring clean CSP nonce injection
72
+ # without affecting the host application's chart helpers
73
+ RailsPulse::ChartHelper.prepend(RailsCharts::CspPatch)
74
74
  end
75
75
  end
@@ -69,6 +69,11 @@ module RailsPulse
69
69
  return 0 unless defined?(RailsPulse::Request)
70
70
 
71
71
  count = RailsPulse::Request.where("occurred_at < ?", cutoff_time).count
72
+ request_ids = RailsPulse::Request.where("occurred_at < ?", cutoff_time).pluck(:id)
73
+
74
+ # Delete associated operations first to respect foreign key constraints
75
+ RailsPulse::Operation.where(request_id: request_ids).delete_all
76
+
72
77
  RailsPulse::Request.where("occurred_at < ?", cutoff_time).delete_all
73
78
  count
74
79
  end
@@ -140,6 +145,9 @@ module RailsPulse
140
145
  .limit(records_to_delete)
141
146
  .pluck(:id)
142
147
 
148
+ # Delete associated operations first to respect foreign key constraints
149
+ RailsPulse::Operation.where(request_id: ids_to_delete).delete_all
150
+
143
151
  RailsPulse::Request.where(id: ids_to_delete).delete_all
144
152
  records_to_delete
145
153
  end
@@ -14,6 +14,17 @@ module RailsPulse
14
14
  class Engine < ::Rails::Engine
15
15
  isolate_namespace RailsPulse
16
16
 
17
+ # Prevent rails_charts from polluting the global ActionView namespace
18
+ # This MUST happen before any initializers run to avoid conflicts with host apps
19
+ # that use Chartkick or other chart libraries
20
+ if defined?(RailsCharts::Engine)
21
+ # Find and remove the rails_charts.helpers initializer
22
+ RailsCharts::Engine.initializers.delete_if do |init|
23
+ init.name == "rails_charts.helpers"
24
+ end
25
+ end
26
+
27
+
17
28
  # Load Rake tasks
18
29
  rake_tasks do
19
30
  Dir.glob(File.expand_path("../tasks/**/*.rake", __FILE__)).each { |file| load file }
@@ -50,6 +61,20 @@ module RailsPulse
50
61
  RailsCharts.options[:theme] = "railspulse"
51
62
  end
52
63
 
64
+ # Manually include RailsCharts helpers only in RailsPulse views
65
+ # This ensures rails_charts methods are only available in RailsPulse namespace,
66
+ # not in the host application
67
+ initializer "rails_pulse.include_rails_charts_helpers" do
68
+ ActiveSupport.on_load :action_view do
69
+ if defined?(RailsCharts::Helpers) && defined?(RailsPulse::ChartHelper)
70
+ unless RailsPulse::ChartHelper.include?(RailsCharts::Helpers)
71
+ RailsPulse::ChartHelper.include(RailsCharts::Helpers)
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+
53
78
  initializer "rails_pulse.ransack", after: "ransack.initialize" do
54
79
  # Ensure Ransack is loaded before our models
55
80
  end
@@ -1,3 +1,3 @@
1
1
  module RailsPulse
2
- VERSION = "0.2.2"
2
+ VERSION = "0.2.3"
3
3
  end