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,115 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["selectWrapper", "pickerWrapper"]
5
+
6
+ connect() {
7
+ const selectElement = this.selectWrapperTarget.querySelector('select')
8
+ if (!selectElement) return
9
+
10
+ // Check if we're in recent_custom mode
11
+ const mode = selectElement.dataset.mode
12
+
13
+ if (mode === 'recent_custom') {
14
+ // In recent_custom mode, "recent" shows no picker, "custom" shows picker
15
+ if (selectElement.value === "custom") {
16
+ this.showPicker()
17
+ this.initializeDatePicker()
18
+ }
19
+ } else {
20
+ // In preset mode, "custom" shows picker
21
+ if (selectElement.value === "custom") {
22
+ this.showPicker()
23
+ this.initializeDatePicker()
24
+ }
25
+ }
26
+ }
27
+
28
+ // When time range is selected from dropdown
29
+ handleChange(event) {
30
+ const mode = event.target.dataset.mode
31
+
32
+ if (mode === 'recent_custom') {
33
+ // In recent_custom mode
34
+ if (event.target.value === "custom") {
35
+ this.showPicker()
36
+ this.openDatePicker()
37
+ } else {
38
+ // "recent" is selected - hide picker
39
+ this.pickerWrapperTarget.style.display = "none"
40
+ this.selectWrapperTarget.style.display = "block"
41
+ }
42
+ } else {
43
+ // In preset mode
44
+ if (event.target.value === "custom") {
45
+ this.showPicker()
46
+ this.openDatePicker()
47
+ }
48
+ }
49
+ }
50
+
51
+ // Show picker, hide select
52
+ showPicker() {
53
+ this.selectWrapperTarget.style.display = "none"
54
+ this.pickerWrapperTarget.style.display = "flex"
55
+ }
56
+
57
+ // Open the flatpickr calendar
58
+ openDatePicker() {
59
+ // Wait a bit for the DOM to update and flatpickr to initialize
60
+ setTimeout(() => {
61
+ // Find the original hidden input that has the datepicker controller
62
+ const hiddenInput = this.pickerWrapperTarget.querySelector('input[name*="custom_date_range"]')
63
+ if (!hiddenInput) return
64
+
65
+ // Get the datepicker controller from the hidden input
66
+ const datepickerController = this.application.getControllerForElementAndIdentifier(
67
+ hiddenInput,
68
+ 'rails-pulse--datepicker'
69
+ )
70
+
71
+ if (datepickerController && datepickerController.flatpickr) {
72
+ datepickerController.flatpickr.open()
73
+ }
74
+ }, 50)
75
+ }
76
+
77
+ // Show select, hide picker
78
+ showSelect() {
79
+ this.pickerWrapperTarget.style.display = "none"
80
+ this.selectWrapperTarget.style.display = "block"
81
+
82
+ // Reset select to default value based on mode
83
+ const selectElement = this.selectWrapperTarget.querySelector('select')
84
+ if (selectElement) {
85
+ const mode = selectElement.dataset.mode
86
+ if (mode === 'recent_custom') {
87
+ selectElement.value = "recent"
88
+ } else {
89
+ selectElement.value = "last_day"
90
+ }
91
+ }
92
+ }
93
+
94
+ // Initialize flatpickr with existing date value
95
+ initializeDatePicker() {
96
+ const dateInput = this.pickerWrapperTarget.querySelector('input[type="text"]')
97
+ if (!dateInput || !dateInput.value) return
98
+
99
+ // Get the datepicker controller
100
+ const datepickerController = this.application.getControllerForElementAndIdentifier(
101
+ dateInput,
102
+ 'rails-pulse--datepicker'
103
+ )
104
+
105
+ if (datepickerController && datepickerController.flatpickr) {
106
+ const value = dateInput.value
107
+ // Parse the "start to end" format
108
+ if (value.includes(' to ')) {
109
+ const [start, end] = value.split(' to ').map(d => d.trim())
110
+ // Set the dates in flatpickr
111
+ datepickerController.flatpickr.setDate([start, end], false)
112
+ }
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,48 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import flatpickr from "flatpickr"
3
+
4
+ export default class extends Controller {
5
+ static targets = [ "details" ]
6
+ static values = {
7
+ type: String, disable: Array,
8
+ mode: { type: String, default: "single" },
9
+ showMonths: { type: Number, default: 1 },
10
+ dateFormat: { type: String, default: "F d, Y" },
11
+ dateTimeFormat: { type: String, default: "M d, Y h:i K" }
12
+ }
13
+
14
+ connect() {
15
+ if (this.typeValue == "time") {
16
+ this.flatpickr = flatpickr(this.element, this.#timeOptions)
17
+ } else if (this.typeValue == "datetime") {
18
+ this.flatpickr = flatpickr(this.element, this.#dateTimeOptions)
19
+ } else {
20
+ this.flatpickr = flatpickr(this.element, this.#basicOptions)
21
+ }
22
+ }
23
+
24
+ disconnect() {
25
+ this.flatpickr.destroy()
26
+ }
27
+
28
+ get #timeOptions() {
29
+ return { dateFormat: "H:i", enableTime: true, noCalendar: true }
30
+ }
31
+
32
+ get #dateTimeOptions() {
33
+ return { ...this.#baseOptions, altFormat: this.dateTimeFormatValue, dateFormat: "Y-m-d H:i", enableTime: true }
34
+ }
35
+
36
+ get #basicOptions() {
37
+ return { ...this.#baseOptions, altFormat: this.dateFormatValue, dateFormat: "Y-m-d" }
38
+ }
39
+
40
+ get #baseOptions() {
41
+ return {
42
+ altInput: true,
43
+ disable: this.disableValue,
44
+ mode: this.modeValue,
45
+ showMonths: this.showMonthsValue
46
+ }
47
+ }
48
+ }
@@ -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
+ }
@@ -0,0 +1,63 @@
1
+ module RailsPulse
2
+ module Taggable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ # Callbacks
7
+ before_save :ensure_tags_is_array
8
+
9
+ # Scopes with table name qualification to avoid ambiguity
10
+ scope :with_tag, ->(tag) { where("#{table_name}.tags LIKE ?", "%#{tag}%") }
11
+ scope :without_tag, ->(tag) { where.not("#{table_name}.tags LIKE ?", "%#{tag}%") }
12
+ scope :with_tags, -> { where("#{table_name}.tags IS NOT NULL AND #{table_name}.tags != '[]'") }
13
+ end
14
+
15
+ # Tag management methods
16
+ def tag_list
17
+ parsed_tags || []
18
+ end
19
+
20
+ def tag_list=(value)
21
+ self.tags = value.to_json
22
+ end
23
+
24
+ def has_tag?(tag)
25
+ tag_list.include?(tag.to_s)
26
+ end
27
+
28
+ def add_tag(tag)
29
+ current_tags = tag_list
30
+ unless current_tags.include?(tag.to_s)
31
+ current_tags << tag.to_s
32
+ self.tag_list = current_tags
33
+ save
34
+ end
35
+ end
36
+
37
+ def remove_tag(tag)
38
+ current_tags = tag_list
39
+ if current_tags.include?(tag.to_s)
40
+ current_tags.delete(tag.to_s)
41
+ self.tag_list = current_tags
42
+ save
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def parsed_tags
49
+ return [] if tags.nil? || tags.empty?
50
+ JSON.parse(tags)
51
+ rescue JSON::ParserError
52
+ []
53
+ end
54
+
55
+ def ensure_tags_is_array
56
+ if tags.nil?
57
+ self.tags = "[]"
58
+ elsif tags.is_a?(Array)
59
+ self.tags = tags.to_json
60
+ end
61
+ end
62
+ end
63
+ end
@@ -2,6 +2,11 @@ module RailsPulse
2
2
  module Dashboard
3
3
  module Charts
4
4
  class AverageResponseTime
5
+ def initialize(disabled_tags: [], show_non_tagged: true)
6
+ @disabled_tags = disabled_tags
7
+ @show_non_tagged = show_non_tagged
8
+ end
9
+
5
10
  def to_chart_data
6
11
  # Create a range of all dates in the past 2 weeks
7
12
  start_date = 2.weeks.ago.beginning_of_day.to_date
@@ -9,11 +14,13 @@ module RailsPulse
9
14
  date_range = (start_date..end_date)
10
15
 
11
16
  # Get the actual data from Summary records (routes)
12
- summaries = RailsPulse::Summary.where(
13
- summarizable_type: "RailsPulse::Route",
14
- period_type: "day",
15
- period_start: start_date.beginning_of_day..end_date.end_of_day
16
- )
17
+ summaries = RailsPulse::Summary
18
+ .with_tag_filters(@disabled_tags, @show_non_tagged)
19
+ .where(
20
+ summarizable_type: "RailsPulse::Route",
21
+ period_type: "day",
22
+ period_start: start_date.beginning_of_day..end_date.end_of_day
23
+ )
17
24
 
18
25
  # Group by day manually for cross-database compatibility
19
26
  actual_data = {}
@@ -2,6 +2,11 @@ module RailsPulse
2
2
  module Dashboard
3
3
  module Charts
4
4
  class P95ResponseTime
5
+ def initialize(disabled_tags: [], show_non_tagged: true)
6
+ @disabled_tags = disabled_tags
7
+ @show_non_tagged = show_non_tagged
8
+ end
9
+
5
10
  def to_chart_data
6
11
  # Create a range of all dates in the past 2 weeks
7
12
  start_date = 2.weeks.ago.beginning_of_day.to_date
@@ -9,11 +14,13 @@ module RailsPulse
9
14
  date_range = (start_date..end_date)
10
15
 
11
16
  # Get the actual data from Summary records (queries for P95)
12
- summaries = RailsPulse::Summary.where(
13
- summarizable_type: "RailsPulse::Query",
14
- period_type: "day",
15
- period_start: start_date.beginning_of_day..end_date.end_of_day
16
- )
17
+ summaries = RailsPulse::Summary
18
+ .with_tag_filters(@disabled_tags, @show_non_tagged)
19
+ .where(
20
+ summarizable_type: "RailsPulse::Query",
21
+ period_type: "day",
22
+ period_start: start_date.beginning_of_day..end_date.end_of_day
23
+ )
17
24
 
18
25
  actual_data = summaries
19
26
  .group_by_day(:period_start, time_zone: Time.zone)
@@ -3,6 +3,12 @@ module RailsPulse
3
3
  module Tables
4
4
  class SlowQueries
5
5
  include RailsPulse::FormattingHelper
6
+
7
+ def initialize(disabled_tags: [], show_non_tagged: true)
8
+ @disabled_tags = disabled_tags
9
+ @show_non_tagged = show_non_tagged
10
+ end
11
+
6
12
  def to_table_data
7
13
  # Get data for this week
8
14
  this_week_start = 1.week.ago.beginning_of_week
@@ -10,6 +16,7 @@ module RailsPulse
10
16
 
11
17
  # Fetch query data from Summary records for this week
12
18
  query_data = RailsPulse::Summary
19
+ .with_tag_filters(@disabled_tags, @show_non_tagged)
13
20
  .joins("INNER JOIN rails_pulse_queries ON rails_pulse_queries.id = rails_pulse_summaries.summarizable_id")
14
21
  .where(
15
22
  summarizable_type: "RailsPulse::Query",
@@ -4,6 +4,11 @@ module RailsPulse
4
4
  class SlowRoutes
5
5
  include RailsPulse::FormattingHelper
6
6
 
7
+ def initialize(disabled_tags: [], show_non_tagged: true)
8
+ @disabled_tags = disabled_tags
9
+ @show_non_tagged = show_non_tagged
10
+ end
11
+
7
12
  def to_table_data
8
13
  # Get data for this week
9
14
  this_week_start = 1.week.ago.beginning_of_week
@@ -11,6 +16,7 @@ module RailsPulse
11
16
 
12
17
  # Fetch route data from Summary records for this week
13
18
  route_data = RailsPulse::Summary
19
+ .with_tag_filters(@disabled_tags, @show_non_tagged)
14
20
  .joins("INNER JOIN rails_pulse_routes ON rails_pulse_routes.id = rails_pulse_summaries.summarizable_id")
15
21
  .where(
16
22
  summarizable_type: "RailsPulse::Route",
@@ -2,8 +2,10 @@ module RailsPulse
2
2
  module Queries
3
3
  module Cards
4
4
  class AverageQueryTimes
5
- def initialize(query: nil)
5
+ def initialize(query: nil, disabled_tags: [], show_non_tagged: true)
6
6
  @query = query
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 aggregated metrics with conditional sums
14
- base_query = RailsPulse::Summary.where(
15
- summarizable_type: "RailsPulse::Query",
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::Query",
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: @query.id) if @query
20
24
 
21
25
  metrics = base_query.select(
@@ -2,8 +2,10 @@ module RailsPulse
2
2
  module Queries
3
3
  module Cards
4
4
  class ExecutionRate
5
- def initialize(query: nil)
5
+ def initialize(query: nil, disabled_tags: [], show_non_tagged: true)
6
6
  @query = query
7
+ @disabled_tags = disabled_tags
8
+ @show_non_tagged = show_non_tagged
7
9
  end
8
10
 
9
11
  def to_metric_card
@@ -12,20 +14,24 @@ module RailsPulse
12
14
 
13
15
  # Get the most common period type for this query, or fall back to "day"
14
16
  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"
17
+ RailsPulse::Summary
18
+ .with_tag_filters(@disabled_tags, @show_non_tagged)
19
+ .where(
20
+ summarizable_type: "RailsPulse::Query",
21
+ summarizable_id: @query.id
22
+ ).group(:period_type).count.max_by(&:last)&.first || "day"
19
23
  else
20
24
  "day"
21
25
  end
22
26
 
23
27
  # Single query to get all count metrics with conditional aggregation
24
- base_query = RailsPulse::Summary.where(
25
- summarizable_type: "RailsPulse::Query",
26
- period_type: period_type,
27
- period_start: 2.weeks.ago.beginning_of_day..Time.current
28
- )
28
+ base_query = RailsPulse::Summary
29
+ .with_tag_filters(@disabled_tags, @show_non_tagged)
30
+ .where(
31
+ summarizable_type: "RailsPulse::Query",
32
+ period_type: period_type,
33
+ period_start: 2.weeks.ago.beginning_of_day..Time.current
34
+ )
29
35
  base_query = base_query.where(summarizable_id: @query.id) if @query
30
36
 
31
37
  metrics = base_query.select(
@@ -2,8 +2,10 @@ module RailsPulse
2
2
  module Queries
3
3
  module Cards
4
4
  class PercentileQueryTimes
5
- def initialize(query: nil)
5
+ def initialize(query: nil, disabled_tags: [], show_non_tagged: true)
6
6
  @query = query
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 P95 metrics with conditional aggregation
14
- base_query = RailsPulse::Summary.where(
15
- summarizable_type: "RailsPulse::Query",
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::Query",
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: @query.id) if @query
20
24
 
21
25
  metrics = base_query.select(
@@ -2,18 +2,21 @@ module RailsPulse
2
2
  module Queries
3
3
  module Charts
4
4
  class AverageQueryTimes
5
- def initialize(ransack_query:, period_type: nil, query: nil, start_time: nil, end_time: nil, start_duration: nil)
5
+ def initialize(ransack_query:, period_type: nil, query: 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
  @query = query
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
- # The ransack query already contains the correct filters, just add period_type
17
+ # The ransack query already contains the correct filters, just add period_type and tag filters
16
18
  summaries = @ransack_query.result(distinct: false)
19
+ .with_tag_filters(@disabled_tags, @show_non_tagged)
17
20
  .where(period_type: @period_type)
18
21
  .group(:period_start)
19
22
  .having("AVG(avg_duration) > ?", @start_duration || 0)
@@ -2,12 +2,14 @@ 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: [], 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
  @query = query
11
+ @disabled_tags = disabled_tags
12
+ @show_non_tagged = show_non_tagged
11
13
  end
12
14
 
13
15
  def to_table
@@ -21,6 +23,20 @@ module RailsPulse
21
23
  period_type: @period_type
22
24
  )
23
25
 
26
+ # Apply tag filters by excluding queries with disabled tags
27
+ # Separate "non_tagged" from actual tags (it's a virtual tag)
28
+ actual_disabled_tags = @disabled_tags.reject { |tag| tag == "non_tagged" }
29
+
30
+ # Exclude queries with actual disabled tags
31
+ actual_disabled_tags.each do |tag|
32
+ base_query = base_query.where.not("rails_pulse_queries.tags LIKE ?", "%#{tag}%")
33
+ end
34
+
35
+ # Exclude non-tagged queries if show_non_tagged is false
36
+ unless @show_non_tagged
37
+ base_query = base_query.where("rails_pulse_queries.tags IS NOT NULL AND rails_pulse_queries.tags != '[]'")
38
+ end
39
+
24
40
  base_query = base_query.where(summarizable_id: @query.id) if @query
25
41
 
26
42
  # Apply grouping and aggregation
@@ -29,13 +45,15 @@ module RailsPulse
29
45
  "rails_pulse_summaries.summarizable_id",
30
46
  "rails_pulse_summaries.summarizable_type",
31
47
  "rails_pulse_queries.id",
32
- "rails_pulse_queries.normalized_sql"
48
+ "rails_pulse_queries.normalized_sql",
49
+ "rails_pulse_queries.tags"
33
50
  )
34
51
  .select(
35
52
  "rails_pulse_summaries.summarizable_id",
36
53
  "rails_pulse_summaries.summarizable_type",
37
54
  "rails_pulse_queries.id as query_id",
38
55
  "rails_pulse_queries.normalized_sql",
56
+ "rails_pulse_queries.tags",
39
57
  "AVG(rails_pulse_summaries.avg_duration) as avg_duration",
40
58
  "MAX(rails_pulse_summaries.max_duration) as max_duration",
41
59
  "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