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
@@ -0,0 +1,110 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["wrapper", "dialog", "dateRange", "indicator", "form"]
5
+ static values = {
6
+ active: { type: Boolean, default: false }
7
+ }
8
+
9
+ connect() {
10
+ this.updateIndicator()
11
+ }
12
+
13
+ // Open the global filters dialog
14
+ open(event) {
15
+ event.preventDefault()
16
+
17
+ // If there's a value in the date range input, make sure flatpickr knows about it
18
+ if (this.dateRangeTarget.value) {
19
+ const datepickerController = this.application.getControllerForElementAndIdentifier(
20
+ this.dateRangeTarget,
21
+ 'rails-pulse--datepicker'
22
+ )
23
+
24
+ if (datepickerController && datepickerController.flatpickr) {
25
+ const value = this.dateRangeTarget.value
26
+ // Parse the "start to end" format
27
+ if (value.includes(' to ')) {
28
+ const [start, end] = value.split(' to ').map(d => d.trim())
29
+ // Set the dates in flatpickr
30
+ datepickerController.flatpickr.setDate([start, end], false)
31
+ }
32
+ }
33
+ }
34
+
35
+ this.wrapperTarget.style.display = 'flex'
36
+ // Prevent body scroll when dialog is open
37
+ document.body.style.overflow = 'hidden'
38
+ }
39
+
40
+ // Close the dialog
41
+ close(event) {
42
+ if (event) {
43
+ event.preventDefault()
44
+ }
45
+ this.wrapperTarget.style.display = 'none'
46
+ // Restore body scroll
47
+ document.body.style.overflow = ''
48
+ }
49
+
50
+ // Close dialog when clicking outside
51
+ closeOnClickOutside(event) {
52
+ if (event.target === this.wrapperTarget) {
53
+ this.close(event)
54
+ }
55
+ }
56
+
57
+ // Handle form submission - parse date range and add individual params
58
+ submit(event) {
59
+ // If clear button was clicked, let it through as-is
60
+ if (event.submitter && event.submitter.name === "clear") {
61
+ return
62
+ }
63
+
64
+ const dateRangeValue = this.dateRangeTarget.value
65
+ const form = event.target
66
+
67
+ // Parse date range if provided
68
+ if (dateRangeValue && dateRangeValue.includes(' to ')) {
69
+ const [startTime, endTime] = dateRangeValue.split(' to ').map(d => d.trim())
70
+
71
+ // Remove any existing hidden inputs
72
+ form.querySelectorAll('input[name="start_time"], input[name="end_time"]').forEach(el => el.remove())
73
+
74
+ // Add new hidden inputs
75
+ const startInput = document.createElement('input')
76
+ startInput.type = 'hidden'
77
+ startInput.name = 'start_time'
78
+ startInput.value = startTime
79
+ form.appendChild(startInput)
80
+
81
+ const endInput = document.createElement('input')
82
+ endInput.type = 'hidden'
83
+ endInput.name = 'end_time'
84
+ endInput.value = endTime
85
+ form.appendChild(endInput)
86
+ }
87
+
88
+ // Tag switches are already being submitted as enabled_tags[]
89
+ // The controller will convert these to disabled_tags
90
+ // No additional processing needed here
91
+
92
+ // No validation needed - user can apply any combination of filters
93
+ }
94
+
95
+ // Update visual indicator based on activeValue
96
+ updateIndicator() {
97
+ if (this.hasIndicatorTarget) {
98
+ if (this.activeValue) {
99
+ this.indicatorTarget.classList.add("global-filters-active")
100
+ } else {
101
+ this.indicatorTarget.classList.remove("global-filters-active")
102
+ }
103
+ }
104
+ }
105
+
106
+ // Called when activeValue changes
107
+ activeValueChanged() {
108
+ this.updateIndicator()
109
+ }
110
+ }
@@ -192,6 +192,8 @@ export default class extends Controller {
192
192
  updatePaginationLimit() {
193
193
  // Update or set the limit param in the browser so if the user refreshes the page,
194
194
  // the limit will be preserved.
195
+ if (!this.hasPaginationLimitTarget) return;
196
+
195
197
  const url = new URL(window.location.href);
196
198
  const currentParams = new URLSearchParams(url.search);
197
199
  const limit = this.paginationLimitTarget.value;
@@ -244,7 +246,9 @@ export default class extends Controller {
244
246
  currentParams.set('zoom_end_time', endTimestamp);
245
247
 
246
248
  // Set the limit param based on the value in the pagination selector
247
- currentParams.set('limit', this.paginationLimitTarget.value);
249
+ if (this.hasPaginationLimitTarget) {
250
+ currentParams.set('limit', this.paginationLimitTarget.value);
251
+ }
248
252
 
249
253
  // Update the URL's search parameters
250
254
  url.search = currentParams.toString();
@@ -443,7 +447,9 @@ export default class extends Controller {
443
447
  currentParams.set('selected_column_time', selectedTimestamp);
444
448
 
445
449
  // Preserve pagination limit
446
- currentParams.set('limit', this.paginationLimitTarget.value);
450
+ if (this.hasPaginationLimitTarget) {
451
+ currentParams.set('limit', this.paginationLimitTarget.value);
452
+ }
447
453
 
448
454
  url.search = currentParams.toString();
449
455
 
@@ -463,7 +469,9 @@ export default class extends Controller {
463
469
  currentParams.delete('selected_column_time');
464
470
 
465
471
  // Preserve pagination limit
466
- currentParams.set('limit', this.paginationLimitTarget.value);
472
+ if (this.hasPaginationLimitTarget) {
473
+ currentParams.set('limit', this.paginationLimitTarget.value);
474
+ }
467
475
 
468
476
  url.search = currentParams.toString();
469
477
 
@@ -0,0 +1,61 @@
1
+ module Taggable
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ # Callbacks
6
+ before_save :ensure_tags_is_array
7
+
8
+ # Scopes with table name qualification to avoid ambiguity
9
+ scope :with_tag, ->(tag) { where("#{table_name}.tags LIKE ?", "%#{tag}%") }
10
+ scope :without_tag, ->(tag) { where.not("#{table_name}.tags LIKE ?", "%#{tag}%") }
11
+ scope :with_tags, -> { where("#{table_name}.tags IS NOT NULL AND #{table_name}.tags != '[]'") }
12
+ end
13
+
14
+ # Tag management methods
15
+ def tag_list
16
+ parsed_tags || []
17
+ end
18
+
19
+ def tag_list=(value)
20
+ self.tags = value.to_json
21
+ end
22
+
23
+ def has_tag?(tag)
24
+ tag_list.include?(tag.to_s)
25
+ end
26
+
27
+ def add_tag(tag)
28
+ current_tags = tag_list
29
+ unless current_tags.include?(tag.to_s)
30
+ current_tags << tag.to_s
31
+ self.tag_list = current_tags
32
+ save
33
+ end
34
+ end
35
+
36
+ def remove_tag(tag)
37
+ current_tags = tag_list
38
+ if current_tags.include?(tag.to_s)
39
+ current_tags.delete(tag.to_s)
40
+ self.tag_list = current_tags
41
+ save
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def parsed_tags
48
+ return [] if tags.nil? || tags.empty?
49
+ JSON.parse(tags)
50
+ rescue JSON::ParserError
51
+ []
52
+ end
53
+
54
+ def ensure_tags_is_array
55
+ if tags.nil?
56
+ self.tags = "[]"
57
+ elsif tags.is_a?(Array)
58
+ self.tags = tags.to_json
59
+ end
60
+ end
61
+ end
@@ -32,7 +32,7 @@ module RailsPulse
32
32
  {
33
33
  query_text: truncate_query(record.normalized_sql),
34
34
  query_id: record.query_id,
35
- query_link: "/rails_pulse/queries/#{record.query_id}",
35
+ query_link: RailsPulse::Engine.routes.url_helpers.query_path(record.query_id),
36
36
  average_time: record.avg_duration.to_f.round(0),
37
37
  request_count: record.request_count,
38
38
  last_request: time_ago_in_words(record.last_seen)
@@ -33,7 +33,7 @@ module RailsPulse
33
33
  {
34
34
  route_path: record.path,
35
35
  route_id: record.route_id,
36
- route_link: "/rails_pulse/routes/#{record.route_id}",
36
+ route_link: RailsPulse::Engine.routes.url_helpers.route_path(record.route_id),
37
37
  average_time: record.avg_duration.to_f.round(0),
38
38
  request_count: record.request_count,
39
39
  last_request: time_ago_in_words(record.last_seen)
@@ -64,7 +64,7 @@ module RailsPulse
64
64
  context: "queries",
65
65
  title: "Average Query Time",
66
66
  summary: "#{average_query_time} ms",
67
- line_chart_data: sparkline_data,
67
+ chart_data: sparkline_data,
68
68
  trend_icon: trend_icon,
69
69
  trend_amount: trend_amount,
70
70
  trend_text: "Compared to last week"
@@ -10,10 +10,20 @@ module RailsPulse
10
10
  last_7_days = 7.days.ago.beginning_of_day
11
11
  previous_7_days = 14.days.ago.beginning_of_day
12
12
 
13
+ # Get the most common period type for this query, or fall back to "day"
14
+ period_type = if @query
15
+ RailsPulse::Summary.where(
16
+ summarizable_type: "RailsPulse::Query",
17
+ summarizable_id: @query.id
18
+ ).group(:period_type).count.max_by(&:last)&.first || "day"
19
+ else
20
+ "day"
21
+ end
22
+
13
23
  # Single query to get all count metrics with conditional aggregation
14
24
  base_query = RailsPulse::Summary.where(
15
25
  summarizable_type: "RailsPulse::Query",
16
- period_type: "day",
26
+ period_type: period_type,
17
27
  period_start: 2.weeks.ago.beginning_of_day..Time.current
18
28
  )
19
29
  base_query = base_query.where(summarizable_id: @query.id) if @query
@@ -33,31 +43,60 @@ module RailsPulse
33
43
  trend_icon = percentage < 0.1 ? "move-right" : current_period_count < previous_period_count ? "trending-down" : "trending-up"
34
44
  trend_amount = previous_period_count.zero? ? "0%" : "#{percentage}%"
35
45
 
36
- # Sparkline data by day with zero-filled days over the last 14 days
37
- grouped_daily = base_query
38
- .group_by_day(:period_start, time_zone: "UTC")
39
- .sum(:count)
46
+ # Sparkline data with zero-filled periods over the last 14 days
47
+ if period_type == "day"
48
+ grouped_data = base_query
49
+ .group_by_day(:period_start, time_zone: "UTC")
50
+ .sum(:count)
51
+
52
+ start_period = 2.weeks.ago.beginning_of_day.to_date
53
+ end_period = Time.current.to_date
40
54
 
41
- start_day = 2.weeks.ago.beginning_of_day.to_date
42
- end_day = Time.current.to_date
55
+ sparkline_data = {}
56
+ (start_period..end_period).each do |day|
57
+ total = grouped_data[day] || 0
58
+ label = day.strftime("%b %-d")
59
+ sparkline_data[label] = { value: total }
60
+ end
61
+ else
62
+ # For hourly data, group by day for sparkline display
63
+ grouped_data = base_query
64
+ .group("DATE(period_start)")
65
+ .sum(:count)
43
66
 
44
- sparkline_data = {}
45
- (start_day..end_day).each do |day|
46
- total = grouped_daily[day] || 0
47
- label = day.strftime("%b %-d")
48
- sparkline_data[label] = { value: total }
67
+ start_period = 2.weeks.ago.beginning_of_day.to_date
68
+ end_period = Time.current.to_date
69
+
70
+ sparkline_data = {}
71
+ (start_period..end_period).each do |day|
72
+ date_key = day.strftime("%Y-%m-%d")
73
+ total = grouped_data[date_key] || 0
74
+ label = day.strftime("%b %-d")
75
+ sparkline_data[label] = { value: total }
76
+ end
49
77
  end
50
78
 
51
- # Calculate average executions per minute over 2-week period
52
- total_minutes = 2.weeks / 1.minute
53
- average_executions_per_minute = total_execution_count / total_minutes
79
+ # Calculate appropriate rate display based on frequency
80
+ total_minutes = 2.weeks / 1.minute.to_f
81
+ executions_per_minute = total_execution_count.to_f / total_minutes
82
+
83
+ # Choose appropriate time unit for display
84
+ if executions_per_minute >= 1
85
+ summary = "#{executions_per_minute.round(2)} / min"
86
+ elsif executions_per_minute * 60 >= 1
87
+ executions_per_hour = executions_per_minute * 60
88
+ summary = "#{executions_per_hour.round(2)} / hour"
89
+ else
90
+ executions_per_day = executions_per_minute * 60 * 24
91
+ summary = "#{executions_per_day.round(2)} / day"
92
+ end
54
93
 
55
94
  {
56
95
  id: "execution_rate",
57
96
  context: "queries",
58
97
  title: "Execution Rate",
59
- summary: "#{average_executions_per_minute.round(2)} / min",
60
- line_chart_data: sparkline_data,
98
+ summary: summary,
99
+ chart_data: sparkline_data,
61
100
  trend_icon: trend_icon,
62
101
  trend_amount: trend_amount,
63
102
  trend_text: "Compared to last week"
@@ -53,7 +53,7 @@ module RailsPulse
53
53
  context: "queries",
54
54
  title: "95th Percentile Query Time",
55
55
  summary: "#{p95_query_time} ms",
56
- line_chart_data: sparkline_data,
56
+ chart_data: sparkline_data,
57
57
  trend_icon: trend_icon,
58
58
  trend_amount: trend_amount,
59
59
  trend_text: "Compared to last week"
@@ -12,13 +12,9 @@ module RailsPulse
12
12
  end
13
13
 
14
14
  def to_rails_chart
15
- summaries = @ransack_query.result(distinct: false).where(
16
- summarizable_type: "RailsPulse::Query",
17
- period_type: @period_type
18
- )
19
-
20
- summaries = summaries.where(summarizable_id: @query.id) if @query
21
- summaries = summaries
15
+ # The ransack query already contains the correct filters, just add period_type
16
+ summaries = @ransack_query.result(distinct: false)
17
+ .where(period_type: @period_type)
22
18
  .group(:period_start)
23
19
  .having("AVG(avg_duration) > ?", @start_duration || 0)
24
20
  .average(:avg_duration)
@@ -2,12 +2,13 @@ module RailsPulse
2
2
  module Queries
3
3
  module Tables
4
4
  class Index
5
- def initialize(ransack_query:, period_type: nil, start_time:, params:, query: nil)
5
+ def initialize(ransack_query:, period_type: nil, start_time:, params:, query: nil, disabled_tags: [])
6
6
  @ransack_query = ransack_query
7
7
  @period_type = period_type
8
8
  @start_time = start_time
9
9
  @params = params
10
10
  @query = query
11
+ @disabled_tags = disabled_tags
11
12
  end
12
13
 
13
14
  def to_table
@@ -21,6 +22,11 @@ module RailsPulse
21
22
  period_type: @period_type
22
23
  )
23
24
 
25
+ # Apply tag filters by excluding queries with disabled tags
26
+ @disabled_tags.each do |tag|
27
+ base_query = base_query.where.not("rails_pulse_queries.tags LIKE ?", "%#{tag}%")
28
+ end
29
+
24
30
  base_query = base_query.where(summarizable_id: @query.id) if @query
25
31
 
26
32
  # Apply grouping and aggregation
@@ -29,13 +35,15 @@ module RailsPulse
29
35
  "rails_pulse_summaries.summarizable_id",
30
36
  "rails_pulse_summaries.summarizable_type",
31
37
  "rails_pulse_queries.id",
32
- "rails_pulse_queries.normalized_sql"
38
+ "rails_pulse_queries.normalized_sql",
39
+ "rails_pulse_queries.tags"
33
40
  )
34
41
  .select(
35
42
  "rails_pulse_summaries.summarizable_id",
36
43
  "rails_pulse_summaries.summarizable_type",
37
44
  "rails_pulse_queries.id as query_id",
38
45
  "rails_pulse_queries.normalized_sql",
46
+ "rails_pulse_queries.tags",
39
47
  "AVG(rails_pulse_summaries.avg_duration) as avg_duration",
40
48
  "MAX(rails_pulse_summaries.max_duration) as max_duration",
41
49
  "SUM(rails_pulse_summaries.count) as execution_count",
@@ -1,5 +1,7 @@
1
1
  module RailsPulse
2
2
  class Query < RailsPulse::ApplicationRecord
3
+ include Taggable
4
+
3
5
  self.table_name = "rails_pulse_queries"
4
6
 
5
7
  # Associations
@@ -1,5 +1,7 @@
1
1
  module RailsPulse
2
2
  class Request < RailsPulse::ApplicationRecord
3
+ include Taggable
4
+
3
5
  self.table_name = "rails_pulse_requests"
4
6
 
5
7
  # Associations
@@ -17,7 +19,7 @@ module RailsPulse
17
19
  before_create :set_request_uuid
18
20
 
19
21
  def self.ransackable_attributes(auth_object = nil)
20
- %w[id route_id occurred_at duration status status_indicator route_path]
22
+ %w[id route_id occurred_at duration status status_category status_indicator route_path]
21
23
  end
22
24
 
23
25
  def self.ransackable_associations(auth_object = nil)
@@ -32,6 +34,12 @@ module RailsPulse
32
34
  Arel.sql("rails_pulse_routes.path")
33
35
  end
34
36
 
37
+ ransacker :status_category do |parent|
38
+ # Returns the first digit of the status code (2, 3, 4, or 5)
39
+ # Use FLOOR instead of CAST for cross-database compatibility
40
+ Arel.sql("FLOOR(#{parent.table[:status].name} / 100)")
41
+ end
42
+
35
43
  ransacker :status_indicator do |parent|
36
44
  # Calculate status indicator based on request_thresholds with safe defaults
37
45
  config = RailsPulse.configuration rescue nil
@@ -52,7 +60,7 @@ module RailsPulse
52
60
  end
53
61
 
54
62
  def to_s
55
- occurred_at.strftime("%b %d, %Y %l:%M %p")
63
+ occurred_at.getlocal.strftime("%b %d, %Y %l:%M %p")
56
64
  end
57
65
 
58
66
  private
@@ -13,11 +13,11 @@ module RailsPulse
13
13
 
14
14
  def to_rails_chart
15
15
  summaries = @ransack_query.result(distinct: false).where(
16
- summarizable_type: "RailsPulse::Route",
16
+ summarizable_type: "RailsPulse::Request",
17
+ summarizable_id: 0, # Overall request summaries
17
18
  period_type: @period_type
18
19
  )
19
20
 
20
- summaries = summaries.where(summarizable_id: @route.id) if @route
21
21
  summaries = summaries
22
22
  .group(:period_start)
23
23
  .having("AVG(avg_duration) > ?", @start_duration || 0)
@@ -0,0 +1,77 @@
1
+ module RailsPulse
2
+ module Requests
3
+ module Tables
4
+ class Index
5
+ def initialize(ransack_query:, period_type: nil, start_time:, params:)
6
+ @ransack_query = ransack_query
7
+ @period_type = period_type
8
+ @start_time = start_time
9
+ @params = params
10
+ end
11
+
12
+ def to_table
13
+ # Check if we have explicit ransack sorts
14
+ has_sorts = @ransack_query.sorts.any?
15
+
16
+ base_query = @ransack_query.result(distinct: false)
17
+ .where(
18
+ summarizable_type: "RailsPulse::Request",
19
+ summarizable_id: 0, # Overall request summaries
20
+ period_type: @period_type
21
+ )
22
+
23
+ # Apply grouping and aggregation for time periods
24
+ grouped_query = base_query
25
+ .group(
26
+ "rails_pulse_summaries.period_start",
27
+ "rails_pulse_summaries.period_end",
28
+ "rails_pulse_summaries.period_type"
29
+ )
30
+ .select(
31
+ "rails_pulse_summaries.period_start",
32
+ "rails_pulse_summaries.period_end",
33
+ "rails_pulse_summaries.period_type",
34
+ "AVG(rails_pulse_summaries.avg_duration) as avg_duration",
35
+ "MAX(rails_pulse_summaries.max_duration) as max_duration",
36
+ "MIN(rails_pulse_summaries.min_duration) as min_duration",
37
+ "SUM(rails_pulse_summaries.count) as count",
38
+ "SUM(rails_pulse_summaries.error_count) as error_count",
39
+ "SUM(rails_pulse_summaries.success_count) as success_count"
40
+ )
41
+
42
+ # Apply sorting based on ransack sorts or use default
43
+ if has_sorts
44
+ # Apply custom sorting based on ransack parameters
45
+ sort = @ransack_query.sorts.first
46
+ direction = sort.dir == "desc" ? :desc : :asc
47
+
48
+ case sort.name
49
+ when "avg_duration"
50
+ grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").send(direction))
51
+ when "max_duration"
52
+ grouped_query = grouped_query.order(Arel.sql("MAX(rails_pulse_summaries.max_duration)").send(direction))
53
+ when "min_duration"
54
+ grouped_query = grouped_query.order(Arel.sql("MIN(rails_pulse_summaries.min_duration)").send(direction))
55
+ when "count"
56
+ grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count)").send(direction))
57
+ when "requests_per_minute"
58
+ grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count) / 60.0").send(direction))
59
+ when "error_rate_percentage"
60
+ grouped_query = grouped_query.order(Arel.sql("(SUM(rails_pulse_summaries.error_count) * 100.0) / SUM(rails_pulse_summaries.count)").send(direction))
61
+ when "period_start"
62
+ grouped_query = grouped_query.order(period_start: direction)
63
+ else
64
+ # Unknown sort field, fallback to default
65
+ grouped_query = grouped_query.order(period_start: :desc)
66
+ end
67
+ else
68
+ # Apply default sort when no explicit sort is provided (matches controller default_table_sort)
69
+ grouped_query = grouped_query.order(period_start: :desc)
70
+ end
71
+
72
+ grouped_query
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -1,5 +1,7 @@
1
1
  module RailsPulse
2
2
  class Route < RailsPulse::ApplicationRecord
3
+ include Taggable
4
+
3
5
  self.table_name = "rails_pulse_routes"
4
6
 
5
7
  # Associations
@@ -62,7 +62,7 @@ module RailsPulse
62
62
  context: "routes",
63
63
  title: "Average Response Time",
64
64
  summary: "#{average_response_time} ms",
65
- line_chart_data: sparkline_data,
65
+ chart_data: sparkline_data,
66
66
  trend_icon: trend_icon,
67
67
  trend_amount: trend_amount,
68
68
  trend_text: "Compared to last week"
@@ -59,7 +59,7 @@ module RailsPulse
59
59
  context: "routes",
60
60
  title: "Error Rate Per Route",
61
61
  summary: "#{overall_error_rate}%",
62
- line_chart_data: sparkline_data,
62
+ chart_data: sparkline_data,
63
63
  trend_icon: trend_icon,
64
64
  trend_amount: trend_amount,
65
65
  trend_text: "Compared to last week"
@@ -53,7 +53,7 @@ module RailsPulse
53
53
  context: "routes",
54
54
  title: "95th Percentile Response Time",
55
55
  summary: "#{p95_response_time} ms",
56
- line_chart_data: sparkline_data,
56
+ chart_data: sparkline_data,
57
57
  trend_icon: trend_icon,
58
58
  trend_amount: trend_amount,
59
59
  trend_text: "Compared to last week"
@@ -48,16 +48,27 @@ module RailsPulse
48
48
  sparkline_data[label] = { value: total }
49
49
  end
50
50
 
51
- # Calculate average requests per minute over 2-week period
52
- total_minutes = 2.weeks / 1.minute
53
- average_requests_per_minute = total_request_count / total_minutes
51
+ # Calculate appropriate rate display based on frequency
52
+ total_minutes = 2.weeks / 1.minute.to_f
53
+ requests_per_minute = total_request_count.to_f / total_minutes
54
+
55
+ # Choose appropriate time unit for display
56
+ if requests_per_minute >= 1
57
+ summary = "#{requests_per_minute.round(2)} / min"
58
+ elsif requests_per_minute * 60 >= 1
59
+ requests_per_hour = requests_per_minute * 60
60
+ summary = "#{requests_per_hour.round(2)} / hour"
61
+ else
62
+ requests_per_day = requests_per_minute * 60 * 24
63
+ summary = "#{requests_per_day.round(2)} / day"
64
+ end
54
65
 
55
66
  {
56
67
  id: "request_count_totals",
57
68
  context: "routes",
58
69
  title: "Request Count Total",
59
- summary: "#{average_requests_per_minute.round(2)} / min",
60
- line_chart_data: sparkline_data,
70
+ summary: summary,
71
+ chart_data: sparkline_data,
61
72
  trend_icon: trend_icon,
62
73
  trend_amount: trend_amount,
63
74
  trend_text: "Compared to last week"