rails_pulse 0.1.4 → 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 (84) 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/chart_table_concern.rb +2 -0
  10. data/app/controllers/concerns/response_range_concern.rb +15 -2
  11. data/app/controllers/concerns/tag_filter_concern.rb +26 -0
  12. data/app/controllers/concerns/time_range_concern.rb +27 -8
  13. data/app/controllers/rails_pulse/application_controller.rb +74 -0
  14. data/app/controllers/rails_pulse/dashboard_controller.rb +12 -8
  15. data/app/controllers/rails_pulse/queries_controller.rb +15 -7
  16. data/app/controllers/rails_pulse/requests_controller.rb +48 -12
  17. data/app/controllers/rails_pulse/routes_controller.rb +14 -5
  18. data/app/controllers/rails_pulse/tags_controller.rb +51 -0
  19. data/app/helpers/rails_pulse/application_helper.rb +2 -0
  20. data/app/helpers/rails_pulse/form_helper.rb +75 -0
  21. data/app/helpers/rails_pulse/tags_helper.rb +29 -0
  22. data/app/javascript/rails_pulse/application.js +6 -0
  23. data/app/javascript/rails_pulse/controllers/custom_range_controller.js +115 -0
  24. data/app/javascript/rails_pulse/controllers/datepicker_controller.js +48 -0
  25. data/app/javascript/rails_pulse/controllers/global_filters_controller.js +110 -0
  26. data/app/models/concerns/rails_pulse/taggable.rb +63 -0
  27. data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +12 -5
  28. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +12 -5
  29. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +7 -0
  30. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +6 -0
  31. data/app/models/rails_pulse/queries/cards/average_query_times.rb +10 -6
  32. data/app/models/rails_pulse/queries/cards/execution_rate.rb +16 -10
  33. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +10 -6
  34. data/app/models/rails_pulse/queries/charts/average_query_times.rb +5 -2
  35. data/app/models/rails_pulse/queries/tables/index.rb +20 -2
  36. data/app/models/rails_pulse/query.rb +2 -0
  37. data/app/models/rails_pulse/request.rb +9 -1
  38. data/app/models/rails_pulse/requests/charts/average_response_times.rb +12 -6
  39. data/app/models/rails_pulse/route.rb +2 -0
  40. data/app/models/rails_pulse/routes/cards/average_response_times.rb +10 -6
  41. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +10 -6
  42. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +10 -6
  43. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +10 -6
  44. data/app/models/rails_pulse/routes/charts/average_response_times.rb +9 -5
  45. data/app/models/rails_pulse/routes/tables/index.rb +20 -2
  46. data/app/models/rails_pulse/summary.rb +55 -0
  47. data/app/services/rails_pulse/summary_service.rb +2 -0
  48. data/app/views/layouts/rails_pulse/_global_filters.html.erb +91 -0
  49. data/app/views/layouts/rails_pulse/_menu_items.html.erb +5 -5
  50. data/app/views/layouts/rails_pulse/application.html.erb +8 -5
  51. data/app/views/rails_pulse/components/_active_filters.html.erb +36 -0
  52. data/app/views/rails_pulse/components/_page_header.html.erb +24 -0
  53. data/app/views/rails_pulse/dashboard/index.html.erb +4 -0
  54. data/app/views/rails_pulse/operations/show.html.erb +1 -1
  55. data/app/views/rails_pulse/queries/_table.html.erb +3 -1
  56. data/app/views/rails_pulse/queries/index.html.erb +3 -7
  57. data/app/views/rails_pulse/queries/show.html.erb +3 -7
  58. data/app/views/rails_pulse/requests/_table.html.erb +3 -1
  59. data/app/views/rails_pulse/requests/index.html.erb +44 -62
  60. data/app/views/rails_pulse/requests/show.html.erb +1 -1
  61. data/app/views/rails_pulse/routes/_requests_table.html.erb +3 -1
  62. data/app/views/rails_pulse/routes/_table.html.erb +3 -1
  63. data/app/views/rails_pulse/routes/index.html.erb +4 -8
  64. data/app/views/rails_pulse/routes/show.html.erb +3 -7
  65. data/app/views/rails_pulse/tags/_tag_manager.html.erb +73 -0
  66. data/config/initializers/rails_charts_csp_patch.rb +9 -9
  67. data/config/routes.rb +5 -0
  68. data/db/rails_pulse_schema.rb +3 -0
  69. data/lib/generators/rails_pulse/install_generator.rb +21 -2
  70. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +3 -0
  71. data/lib/generators/rails_pulse/templates/rails_pulse.rb +21 -0
  72. data/lib/generators/rails_pulse/upgrade_generator.rb +145 -29
  73. data/lib/rails_pulse/cleanup_service.rb +8 -0
  74. data/lib/rails_pulse/configuration.rb +16 -1
  75. data/lib/rails_pulse/engine.rb +25 -0
  76. data/lib/rails_pulse/version.rb +1 -1
  77. data/public/rails-pulse-assets/rails-pulse-icons.js +16 -15
  78. data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
  79. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  80. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  81. data/public/rails-pulse-assets/rails-pulse.js +73 -69
  82. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  83. metadata +18 -3
  84. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +0 -12
@@ -0,0 +1,26 @@
1
+ module TagFilterConcern
2
+ extend ActiveSupport::Concern
3
+
4
+ private
5
+
6
+ # Apply tag filters to a query
7
+ # Excludes records that have ANY of the disabled tags
8
+ def apply_tag_filters(query)
9
+ disabled_tags = session_disabled_tags
10
+ query = disabled_tags.reduce(query) do |q, tag|
11
+ q.without_tag(tag)
12
+ end
13
+
14
+ apply_non_tagged_filter(query)
15
+ end
16
+
17
+ # Apply non-tagged filter to a query
18
+ # If show_non_tagged is false, exclude records with no tags
19
+ def apply_non_tagged_filter(query)
20
+ if session[:show_non_tagged] == false
21
+ query.with_tags
22
+ else
23
+ query
24
+ end
25
+ end
26
+ end
@@ -6,7 +6,8 @@ module TimeRangeConcern
6
6
  const_set(:TIME_RANGE_OPTIONS, [
7
7
  [ "Last 24 hours", :last_day ],
8
8
  [ "Last Week", :last_week ],
9
- [ "Last Month", :last_month ]
9
+ [ "Last Month", :last_month ],
10
+ [ "Custom Range...", :custom ]
10
11
  ].freeze)
11
12
  end
12
13
 
@@ -17,11 +18,8 @@ module TimeRangeConcern
17
18
 
18
19
  ransack_params = params[:q] || {}
19
20
 
20
- if ransack_params[:occurred_at_gteq].present?
21
- # Custom time range from chart zoom where there is no association
22
- start_time = parse_time_param(ransack_params[:occurred_at_gteq])
23
- end_time = parse_time_param(ransack_params[:occurred_at_lt])
24
- elsif ransack_params[:period_start_range]
21
+ # Priority 1: Page-specific preset from dropdown (check this first!)
22
+ if ransack_params[:period_start_range].present? && ransack_params[:period_start_range].to_sym != :custom
25
23
  # Predefined time range from dropdown
26
24
  selected_time_range = ransack_params[:period_start_range]
27
25
  start_time =
@@ -31,7 +29,26 @@ module TimeRangeConcern
31
29
  when :last_month then 1.month.ago
32
30
  else 1.day.ago # Default fallback
33
31
  end
32
+ # Priority 2: Page-specific custom datetime range from picker (only if period_start_range is :custom)
33
+ elsif ransack_params[:period_start_range].present? && ransack_params[:period_start_range].to_sym == :custom && ransack_params[:custom_date_range].present? && ransack_params[:custom_date_range].include?(" to ")
34
+ # Custom datetime range from custom range picker
35
+ dates = ransack_params[:custom_date_range].split(" to ")
36
+ start_time = parse_time_param(dates[0].strip)
37
+ end_time = parse_time_param(dates[1].strip)
38
+ selected_time_range = :custom
39
+ # Priority 3: Page-specific filters (chart zoom)
40
+ elsif ransack_params[:occurred_at_gteq].present? && ransack_params[:occurred_at_lt].present?
41
+ # Custom time range from chart zoom
42
+ start_time = parse_time_param(ransack_params[:occurred_at_gteq])
43
+ end_time = parse_time_param(ransack_params[:occurred_at_lt])
44
+ selected_time_range = :custom
45
+ # Priority 4: Global filters (from session)
46
+ elsif session_global_filters["start_time"].present? || session_global_filters["end_time"].present?
47
+ start_time = parse_time_param(session_global_filters["start_time"]) if session_global_filters["start_time"].present?
48
+ end_time = parse_time_param(session_global_filters["end_time"]) if session_global_filters["end_time"].present?
49
+ selected_time_range = :custom
34
50
  end
51
+ # Priority 5: Default time range (already set above)
35
52
 
36
53
  time_diff = (end_time.to_i - start_time.to_i) / 3600.0
37
54
 
@@ -43,7 +60,7 @@ module TimeRangeConcern
43
60
  end_time = end_time.end_of_day
44
61
  end
45
62
 
46
- [ start_time, end_time, selected_time_range, time_diff ]
63
+ [ start_time.to_i, end_time.to_i, selected_time_range, time_diff ]
47
64
  end
48
65
 
49
66
  private
@@ -53,7 +70,9 @@ module TimeRangeConcern
53
70
  when Time, DateTime
54
71
  param.in_time_zone
55
72
  when String
56
- Time.zone.parse(param)
73
+ # Parse as server local time (not UTC, not Time.zone)
74
+ # This ensures flatpickr datetime strings are interpreted in server's timezone
75
+ Time.parse(param).localtime
57
76
  else
58
77
  # Assume it's an integer timestamp
59
78
  Time.zone.at(param.to_i)
@@ -1,6 +1,8 @@
1
1
  module RailsPulse
2
2
  class ApplicationController < ActionController::Base
3
3
  before_action :authenticate_rails_pulse_user!
4
+ before_action :set_show_non_tagged_default
5
+ helper_method :session_global_filters, :session_disabled_tags
4
6
 
5
7
  def set_pagination_limit(limit = nil)
6
8
  limit = limit || params[:limit]
@@ -12,6 +14,49 @@ module RailsPulse
12
14
  end
13
15
  end
14
16
 
17
+ def set_global_filters
18
+ if params[:clear] == "true"
19
+ session.delete(:global_filters)
20
+ session[:show_non_tagged] = true # Reset show_non_tagged to default
21
+ else
22
+ filters = session[:global_filters] || {}
23
+
24
+ # Update time filters if provided
25
+ if params[:start_time].present? && params[:end_time].present?
26
+ filters["start_time"] = params[:start_time]
27
+ filters["end_time"] = params[:end_time]
28
+ end
29
+
30
+ # Update performance threshold if provided (or remove if empty)
31
+ if params[:performance_threshold].present?
32
+ filters["performance_threshold"] = params[:performance_threshold]
33
+ else
34
+ filters.delete("performance_threshold")
35
+ end
36
+
37
+ # Update tag visibility - convert enabled tags to disabled tags
38
+ all_tags = RailsPulse.configuration.tags
39
+ enabled_tags = params[:enabled_tags] || []
40
+
41
+ # Handle "non_tagged" separately
42
+ session[:show_non_tagged] = enabled_tags.include?("non_tagged")
43
+ enabled_tags = enabled_tags - [ "non_tagged" ]
44
+
45
+ disabled_tags = all_tags - enabled_tags
46
+
47
+ if disabled_tags.any?
48
+ filters["disabled_tags"] = disabled_tags
49
+ else
50
+ filters.delete("disabled_tags")
51
+ end
52
+
53
+ session[:global_filters] = filters
54
+ end
55
+
56
+ # Redirect back to the referring page or root
57
+ redirect_back(fallback_location: root_path)
58
+ end
59
+
15
60
  private
16
61
 
17
62
  def authenticate_rails_pulse_user!
@@ -71,5 +116,34 @@ module RailsPulse
71
116
  validated_limit = limit.to_i.clamp(5, 50)
72
117
  session[:pagination_limit] = validated_limit if limit.present?
73
118
  end
119
+
120
+ def session_global_filters
121
+ session[:global_filters] || {}
122
+ end
123
+
124
+ def session_disabled_tags
125
+ session_global_filters["disabled_tags"] || []
126
+ end
127
+
128
+ # Get the minimum duration based on global performance threshold
129
+ # Returns nil if no threshold is set (show all)
130
+ # context: :route, :request, or :query
131
+ def global_performance_threshold_duration(context)
132
+ threshold = session_global_filters["performance_threshold"]
133
+ return nil unless threshold.present?
134
+
135
+ config_key = "#{context}_thresholds".to_sym
136
+ thresholds = RailsPulse.configuration.public_send(config_key)
137
+
138
+ thresholds[threshold.to_sym]
139
+ rescue StandardError => e
140
+ Rails.logger.warn "Failed to get performance threshold: #{e.message}"
141
+ nil
142
+ end
143
+
144
+ # Set default value for show_non_tagged if not already set
145
+ def set_show_non_tagged_default
146
+ session[:show_non_tagged] = true if session[:show_non_tagged].nil?
147
+ end
74
148
  end
75
149
  end
@@ -1,18 +1,22 @@
1
1
  module RailsPulse
2
2
  class DashboardController < ApplicationController
3
3
  def index
4
- @average_query_times_metric_card = RailsPulse::Routes::Cards::AverageResponseTimes.new(route: nil).to_metric_card
5
- @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil).to_metric_card
6
- @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil).to_metric_card
7
- @error_rate_per_route_metric_card = RailsPulse::Routes::Cards::ErrorRatePerRoute.new(route: nil).to_metric_card
4
+ # Get tag filter values from session
5
+ disabled_tags = session_disabled_tags
6
+ show_non_tagged = session[:show_non_tagged] != false
7
+
8
+ @average_query_times_metric_card = RailsPulse::Routes::Cards::AverageResponseTimes.new(route: nil, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_metric_card
9
+ @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_metric_card
10
+ @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_metric_card
11
+ @error_rate_per_route_metric_card = RailsPulse::Routes::Cards::ErrorRatePerRoute.new(route: nil, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_metric_card
8
12
 
9
13
  # Generate chart data for inline rendering
10
- @average_response_time_chart_data = RailsPulse::Dashboard::Charts::AverageResponseTime.new.to_chart_data
11
- @p95_response_time_chart_data = RailsPulse::Dashboard::Charts::P95ResponseTime.new.to_chart_data
14
+ @average_response_time_chart_data = RailsPulse::Dashboard::Charts::AverageResponseTime.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_chart_data
15
+ @p95_response_time_chart_data = RailsPulse::Dashboard::Charts::P95ResponseTime.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_chart_data
12
16
 
13
17
  # Generate table data for inline rendering
14
- @slow_routes_table_data = RailsPulse::Dashboard::Tables::SlowRoutes.new.to_table_data
15
- @slow_queries_table_data = RailsPulse::Dashboard::Tables::SlowQueries.new.to_table_data
18
+ @slow_routes_table_data = RailsPulse::Dashboard::Tables::SlowRoutes.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_table_data
19
+ @slow_queries_table_data = RailsPulse::Dashboard::Tables::SlowQueries.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_table_data
16
20
  end
17
21
  end
18
22
  end
@@ -1,6 +1,7 @@
1
1
  module RailsPulse
2
2
  class QueriesController < ApplicationController
3
3
  include ChartTableConcern
4
+ include TagFilterConcern
4
5
 
5
6
  before_action :set_query, only: [ :show, :analyze ]
6
7
 
@@ -69,7 +70,8 @@ module RailsPulse
69
70
  def build_chart_ransack_params(ransack_params)
70
71
  base_params = ransack_params.except(:s).merge(
71
72
  period_start_gteq: Time.at(@start_time),
72
- period_start_lt: Time.at(@end_time)
73
+ period_start_lt: Time.at(@end_time),
74
+ summarizable_type_eq: "RailsPulse::Query"
73
75
  )
74
76
 
75
77
  # Only add duration filter if we have a meaningful threshold
@@ -77,8 +79,7 @@ module RailsPulse
77
79
 
78
80
  if show_action?
79
81
  base_params.merge(
80
- summarizable_id_eq: @query.id,
81
- summarizable_type_eq: "RailsPulse::Query"
82
+ summarizable_id_eq: @query.id
82
83
  )
83
84
  else
84
85
  base_params
@@ -114,13 +115,16 @@ module RailsPulse
114
115
  def build_table_results
115
116
  if show_action?
116
117
  # For Summary model on show page - ransack params already include query ID and type filters
118
+ # Summaries aren't taggable, so we don't apply tag filters here
117
119
  @ransack_query.result.where(period_type: period_type)
118
120
  else
119
121
  Queries::Tables::Index.new(
120
122
  ransack_query: @ransack_query,
121
123
  period_type: period_type,
122
124
  start_time: @start_time,
123
- params: params
125
+ params: params,
126
+ disabled_tags: session_disabled_tags,
127
+ show_non_tagged: session[:show_non_tagged] != false
124
128
  ).to_table
125
129
  end
126
130
  end
@@ -130,9 +134,13 @@ module RailsPulse
130
134
  def setup_metric_cards
131
135
  return if turbo_frame_request?
132
136
 
133
- @average_query_times_metric_card = RailsPulse::Queries::Cards::AverageQueryTimes.new(query: @query).to_metric_card
134
- @percentile_query_times_metric_card = RailsPulse::Queries::Cards::PercentileQueryTimes.new(query: @query).to_metric_card
135
- @execution_rate_metric_card = RailsPulse::Queries::Cards::ExecutionRate.new(query: @query).to_metric_card
137
+ # Get tag filter values from session
138
+ disabled_tags = session_disabled_tags
139
+ show_non_tagged = session[:show_non_tagged] != false
140
+
141
+ @average_query_times_metric_card = RailsPulse::Queries::Cards::AverageQueryTimes.new(query: @query, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_metric_card
142
+ @percentile_query_times_metric_card = RailsPulse::Queries::Cards::PercentileQueryTimes.new(query: @query, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_metric_card
143
+ @execution_rate_metric_card = RailsPulse::Queries::Cards::ExecutionRate.new(query: @query, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_metric_card
136
144
  end
137
145
 
138
146
  def show_action?
@@ -1,6 +1,14 @@
1
1
  module RailsPulse
2
2
  class RequestsController < ApplicationController
3
3
  include ChartTableConcern
4
+ include TagFilterConcern
5
+
6
+ # Override TIME_RANGE_OPTIONS from TimeRangeConcern with requests-specific options
7
+ remove_const(:TIME_RANGE_OPTIONS) if const_defined?(:TIME_RANGE_OPTIONS)
8
+ TIME_RANGE_OPTIONS = [
9
+ [ "Recent", "recent" ],
10
+ [ "Custom Range", "custom" ]
11
+ ].freeze
4
12
 
5
13
  before_action :set_request, only: :show
6
14
 
@@ -18,10 +26,14 @@ module RailsPulse
18
26
  def setup_metric_cards
19
27
  return if turbo_frame_request?
20
28
 
21
- @average_response_times_metric_card = RailsPulse::Routes::Cards::AverageResponseTimes.new(route: nil).to_metric_card
22
- @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil).to_metric_card
23
- @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil).to_metric_card
24
- @error_rate_per_route_metric_card = RailsPulse::Routes::Cards::ErrorRatePerRoute.new(route: nil).to_metric_card
29
+ # Get tag filter values from session
30
+ disabled_tags = session_disabled_tags
31
+ show_non_tagged = session[:show_non_tagged] != false
32
+
33
+ @average_response_times_metric_card = RailsPulse::Routes::Cards::AverageResponseTimes.new(route: nil, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_metric_card
34
+ @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_metric_card
35
+ @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_metric_card
36
+ @error_rate_per_route_metric_card = RailsPulse::Routes::Cards::ErrorRatePerRoute.new(route: nil, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_metric_card
25
37
  end
26
38
 
27
39
 
@@ -55,11 +67,32 @@ module RailsPulse
55
67
  end
56
68
 
57
69
  def build_table_ransack_params(ransack_params)
58
- params = ransack_params.merge(
59
- occurred_at_gteq: Time.at(@table_start_time),
60
- occurred_at_lt: Time.at(@table_end_time)
61
- )
62
- params[:duration_gteq] = @start_duration if @start_duration && @start_duration > 0
70
+ params = ransack_params.dup
71
+
72
+ # Handle time mode - check if recent mode is selected
73
+ time_mode = params[:period_start_range] || "recent"
74
+
75
+ if time_mode != "recent"
76
+ # Custom mode - apply time filters
77
+ params.merge!(
78
+ occurred_at_gteq: Time.at(@table_start_time),
79
+ occurred_at_lt: Time.at(@table_end_time)
80
+ )
81
+ end
82
+ # else: Recent mode - no time filters, just rely on sort + pagination
83
+
84
+ # Duration filter - convert symbol to numeric threshold or use @start_duration
85
+ if params[:duration_gteq].present?
86
+ # If it's a symbol like :slow, convert it to the numeric threshold
87
+ if params[:duration_gteq].to_s.in?(%w[slow very_slow critical])
88
+ params[:duration_gteq] = @start_duration
89
+ end
90
+ # else: it's already a number, keep it as is
91
+ elsif @start_duration && @start_duration > 0
92
+ # No duration_gteq param, use @start_duration from concern
93
+ params[:duration_gteq] = @start_duration
94
+ end
95
+
63
96
  params
64
97
  end
65
98
 
@@ -68,10 +101,13 @@ module RailsPulse
68
101
  end
69
102
 
70
103
  def build_table_results
71
- base_query = @ransack_query.result.includes(:route)
104
+ base_query = apply_tag_filters(@ransack_query.result.includes(:route))
105
+
106
+ # If filtering or sorting by route_path, we need to join the routes table
107
+ needs_join = @ransack_query.sorts.any? { |sort| sort.name == "route_path" } ||
108
+ params.dig(:q, :route_path_cont).present?
72
109
 
73
- # If sorting by route_path, we need to join the routes table
74
- if @ransack_query.sorts.any? { |sort| sort.name == "route_path" }
110
+ if needs_join
75
111
  base_query = base_query.joins(:route)
76
112
  end
77
113
 
@@ -1,6 +1,7 @@
1
1
  module RailsPulse
2
2
  class RoutesController < ApplicationController
3
3
  include ChartTableConcern
4
+ include TagFilterConcern
4
5
 
5
6
  before_action :set_route, only: :show
6
7
 
@@ -19,10 +20,14 @@ module RailsPulse
19
20
  def setup_metric_cards
20
21
  return if turbo_frame_request?
21
22
 
22
- @average_query_times_metric_card = RailsPulse::Routes::Cards::AverageResponseTimes.new(route: @route).to_metric_card
23
- @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: @route).to_metric_card
24
- @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: @route).to_metric_card
25
- @error_rate_per_route_metric_card = RailsPulse::Routes::Cards::ErrorRatePerRoute.new(route: @route).to_metric_card
23
+ # Get tag filter values from session
24
+ disabled_tags = session_disabled_tags
25
+ show_non_tagged = session[:show_non_tagged] != false
26
+
27
+ @average_query_times_metric_card = RailsPulse::Routes::Cards::AverageResponseTimes.new(route: @route, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_metric_card
28
+ @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: @route, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_metric_card
29
+ @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: @route, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_metric_card
30
+ @error_rate_per_route_metric_card = RailsPulse::Routes::Cards::ErrorRatePerRoute.new(route: @route, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged).to_metric_card
26
31
  end
27
32
 
28
33
  def chart_model
@@ -86,6 +91,8 @@ module RailsPulse
86
91
  if show_action?
87
92
  # Only show requests that belong to time periods where we have route summaries
88
93
  # This ensures the table data is consistent with the chart data
94
+ # Note: We don't apply tag filters here because we want to show all requests
95
+ # for this specific route, regardless of individual request tags
89
96
  base_query = @ransack_query.result
90
97
  .joins(<<~SQL)
91
98
  INNER JOIN rails_pulse_summaries ON
@@ -108,7 +115,9 @@ module RailsPulse
108
115
  ransack_query: @ransack_query,
109
116
  period_type: period_type,
110
117
  start_time: @start_time,
111
- params: params
118
+ params: params,
119
+ disabled_tags: session_disabled_tags,
120
+ show_non_tagged: session[:show_non_tagged] != false
112
121
  ).to_table
113
122
  end
114
123
  end
@@ -0,0 +1,51 @@
1
+ module RailsPulse
2
+ class TagsController < ApplicationController
3
+ before_action :set_taggable
4
+
5
+ def create
6
+ tag = params[:tag]
7
+
8
+ if tag.blank?
9
+ render turbo_stream: turbo_stream.replace("tag_manager_#{@taggable.class.name.demodulize.underscore}_#{@taggable.id}",
10
+ partial: "rails_pulse/tags/tag_manager",
11
+ locals: { taggable: @taggable, error: "Tag cannot be blank" })
12
+ return
13
+ end
14
+
15
+ @taggable.add_tag(tag)
16
+ @taggable.reload
17
+
18
+ render turbo_stream: turbo_stream.replace("tag_manager_#{@taggable.class.name.demodulize.underscore}_#{@taggable.id}",
19
+ partial: "rails_pulse/tags/tag_manager",
20
+ locals: { taggable: @taggable })
21
+ end
22
+
23
+ def destroy
24
+ tag = params[:tag]
25
+ @taggable.remove_tag(tag)
26
+ @taggable.reload
27
+
28
+ render turbo_stream: turbo_stream.replace("tag_manager_#{@taggable.class.name.demodulize.underscore}_#{@taggable.id}",
29
+ partial: "rails_pulse/tags/tag_manager",
30
+ locals: { taggable: @taggable })
31
+ end
32
+
33
+ private
34
+
35
+ def set_taggable
36
+ @taggable_type = params[:taggable_type]
37
+ @taggable_id = params[:taggable_id]
38
+
39
+ @taggable = case @taggable_type
40
+ when "route"
41
+ Route.find(@taggable_id)
42
+ when "request"
43
+ Request.find(@taggable_id)
44
+ when "query"
45
+ Query.find(@taggable_id)
46
+ else
47
+ head :not_found
48
+ end
49
+ end
50
+ end
51
+ end
@@ -7,6 +7,8 @@ module RailsPulse
7
7
  include FormattingHelper
8
8
  include StatusHelper
9
9
  include TableHelper
10
+ include FormHelper
11
+ include TagsHelper
10
12
 
11
13
  # Replacement for lucide_icon helper that works with pre-compiled assets
12
14
  # Outputs a custom element that will be hydrated by Stimulus
@@ -0,0 +1,75 @@
1
+ module RailsPulse
2
+ module FormHelper
3
+ # Renders a time range selector that can switch between preset ranges and a custom datetime picker
4
+ #
5
+ # @param form [ActionView::Helpers::FormBuilder] The form builder instance
6
+ # @param time_range_options [Array] Array of [label, value] pairs for the select options
7
+ # @param selected_time_range [Symbol, String] Currently selected time range
8
+ # @param mode [Symbol] :preset (default) for preset time ranges, :recent_custom for Recent/Custom toggle
9
+ # @return [String] HTML for the time range selector
10
+ def time_range_selector(form, time_range_options:, selected_time_range:, mode: :preset)
11
+ global_filters = session_global_filters
12
+ has_global_date_range = global_filters["start_time"].present? && global_filters["end_time"].present?
13
+ global_date_range = has_global_date_range ? "#{global_filters["start_time"]} to #{global_filters["end_time"]}" : ""
14
+ show_custom_picker = selected_time_range.to_sym == :custom
15
+ custom_date_value = params.dig(:q, :custom_date_range) || (show_custom_picker && has_global_date_range ? global_date_range : "")
16
+
17
+ content_tag(:div, class: "time-range-selector", data: { mode: mode }) do
18
+ concat time_range_select_wrapper(form, time_range_options, selected_time_range, mode)
19
+ concat time_range_picker_wrapper(form, custom_date_value)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def time_range_select_wrapper(form, time_range_options, selected_time_range, mode)
26
+ content_tag(:div, data: { rails_pulse__custom_range_target: "selectWrapper" }, style: "min-width: 150px;") do
27
+ form.select :period_start_range,
28
+ time_range_options,
29
+ { selected: selected_time_range },
30
+ {
31
+ class: "input",
32
+ data: {
33
+ action: "change->rails-pulse--custom-range#handleChange",
34
+ mode: mode
35
+ }
36
+ }
37
+ end
38
+ end
39
+
40
+ def time_range_picker_wrapper(form, custom_date_value)
41
+ content_tag(:div,
42
+ data: { rails_pulse__custom_range_target: "pickerWrapper" },
43
+ style: "display: none; position: relative; min-width: 360px;"
44
+ ) do
45
+ concat time_range_picker_input(form, custom_date_value)
46
+ concat time_range_close_button
47
+ end
48
+ end
49
+
50
+ def time_range_picker_input(form, custom_date_value)
51
+ form.text_field :custom_date_range,
52
+ value: custom_date_value,
53
+ placeholder: "Pick date range",
54
+ class: "input",
55
+ style: "padding-inline-end: 2.5rem;",
56
+ data: {
57
+ controller: "rails-pulse--datepicker",
58
+ rails_pulse__datepicker_mode_value: "range",
59
+ rails_pulse__datepicker_show_months_value: 2,
60
+ rails_pulse__datepicker_type_value: "datetime"
61
+ }
62
+ end
63
+
64
+ def time_range_close_button
65
+ button_tag(
66
+ rails_pulse_icon("x", width: "18"),
67
+ type: "button",
68
+ class: "btn btn--borderless",
69
+ style: "position: absolute; inset-inline-end: 0; inset-block-start: 0; inset-block-end: 0; padding: 0.5rem; background: transparent; border: none;",
70
+ data: { action: "click->rails-pulse--custom-range#showSelect" },
71
+ aria: { label: "Close custom range" }
72
+ )
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,29 @@
1
+ module RailsPulse
2
+ module TagsHelper
3
+ # Display tags as badge elements
4
+ # Accepts:
5
+ # - Taggable objects (with tag_list method)
6
+ # - Raw JSON strings from aggregated queries
7
+ # - Arrays of tags
8
+ def display_tag_badges(tags)
9
+ tag_array = case tags
10
+ when String
11
+ # Parse JSON string from database
12
+ begin
13
+ JSON.parse(tags)
14
+ rescue JSON::ParserError
15
+ []
16
+ end
17
+ when Array
18
+ tags
19
+ else
20
+ # Handle Taggable objects
21
+ tags.respond_to?(:tag_list) ? tags.tag_list : []
22
+ end
23
+
24
+ return content_tag(:span, "-", class: "text-subtle") if tag_array.empty?
25
+
26
+ safe_join(tag_array.map { |tag| content_tag(:div, tag, class: "badge") }, " ")
27
+ end
28
+ end
29
+ end
@@ -5,6 +5,7 @@ import { Application } from "@hotwired/stimulus";
5
5
 
6
6
  // CSS Zero Controllers
7
7
  import ContextMenuController from "./controllers/context_menu_controller";
8
+ import DatePickerController from "./controllers/datepicker_controller";
8
9
  import DialogController from "./controllers/dialog_controller";
9
10
  import MenuController from "./controllers/menu_controller";
10
11
  import PopoverController from "./controllers/popover_controller";
@@ -19,6 +20,8 @@ import IconController from "./controllers/icon_controller";
19
20
  import ExpandableRowsController from "./controllers/expandable_rows_controller";
20
21
  import CollapsibleController from "./controllers/collapsible_controller";
21
22
  import TableSortController from "./controllers/table_sort_controller";
23
+ import GlobalFiltersController from "./controllers/global_filters_controller";
24
+ import CustomRangeController from "./controllers/custom_range_controller";
22
25
 
23
26
  const application = Application.start();
24
27
 
@@ -33,6 +36,7 @@ window.echarts = echarts;
33
36
  window.Turbo = Turbo;
34
37
 
35
38
  application.register("rails-pulse--context-menu", ContextMenuController);
39
+ application.register("rails-pulse--datepicker", DatePickerController);
36
40
  application.register("rails-pulse--dialog", DialogController);
37
41
  application.register("rails-pulse--menu", MenuController);
38
42
  application.register("rails-pulse--popover", PopoverController);
@@ -46,6 +50,8 @@ application.register("rails-pulse--icon", IconController);
46
50
  application.register("rails-pulse--expandable-rows", ExpandableRowsController);
47
51
  application.register("rails-pulse--collapsible", CollapsibleController);
48
52
  application.register("rails-pulse--table-sort", TableSortController);
53
+ application.register("rails-pulse--global-filters", GlobalFiltersController);
54
+ application.register("rails-pulse--custom-range", CustomRangeController);
49
55
 
50
56
  // Ensure Turbo Frames are loaded after page load
51
57
  document.addEventListener('DOMContentLoaded', () => {