rails_pulse 0.1.0 → 0.1.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +74 -178
  3. data/Rakefile +75 -173
  4. data/app/assets/stylesheets/rails_pulse/application.css +0 -12
  5. data/app/controllers/concerns/chart_table_concern.rb +21 -4
  6. data/app/controllers/concerns/response_range_concern.rb +6 -3
  7. data/app/controllers/concerns/time_range_concern.rb +5 -10
  8. data/app/controllers/concerns/zoom_range_concern.rb +1 -1
  9. data/app/controllers/rails_pulse/application_controller.rb +8 -4
  10. data/app/controllers/rails_pulse/dashboard_controller.rb +12 -0
  11. data/app/controllers/rails_pulse/queries_controller.rb +65 -50
  12. data/app/controllers/rails_pulse/requests_controller.rb +24 -12
  13. data/app/controllers/rails_pulse/routes_controller.rb +59 -24
  14. data/app/helpers/rails_pulse/application_helper.rb +0 -1
  15. data/app/helpers/rails_pulse/chart_formatters.rb +3 -3
  16. data/app/helpers/rails_pulse/chart_helper.rb +6 -2
  17. data/app/helpers/rails_pulse/status_helper.rb +10 -4
  18. data/app/javascript/rails_pulse/controllers/index_controller.js +117 -33
  19. data/app/javascript/rails_pulse/controllers/pagination_controller.js +17 -27
  20. data/app/jobs/rails_pulse/backfill_summaries_job.rb +41 -0
  21. data/app/jobs/rails_pulse/summary_job.rb +53 -0
  22. data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +28 -7
  23. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +18 -22
  24. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +20 -9
  25. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +19 -7
  26. data/app/models/rails_pulse/operation.rb +1 -1
  27. data/app/models/rails_pulse/queries/cards/average_query_times.rb +47 -23
  28. data/app/models/rails_pulse/queries/cards/execution_rate.rb +33 -26
  29. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +34 -45
  30. data/app/models/rails_pulse/queries/charts/average_query_times.rb +23 -97
  31. data/app/models/rails_pulse/queries/tables/index.rb +74 -0
  32. data/app/models/rails_pulse/query.rb +1 -0
  33. data/app/models/rails_pulse/requests/charts/average_response_times.rb +23 -84
  34. data/app/models/rails_pulse/route.rb +1 -6
  35. data/app/models/rails_pulse/routes/cards/average_response_times.rb +45 -23
  36. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +38 -45
  37. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +34 -47
  38. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +30 -25
  39. data/app/models/rails_pulse/routes/charts/average_response_times.rb +23 -100
  40. data/app/models/rails_pulse/routes/tables/index.rb +57 -40
  41. data/app/models/rails_pulse/summary.rb +143 -0
  42. data/app/services/rails_pulse/summary_service.rb +199 -0
  43. data/app/views/layouts/rails_pulse/application.html.erb +4 -4
  44. data/app/views/rails_pulse/components/_empty_state.html.erb +11 -0
  45. data/app/views/rails_pulse/components/_metric_card.html.erb +10 -24
  46. data/app/views/rails_pulse/dashboard/index.html.erb +54 -36
  47. data/app/views/rails_pulse/queries/_show_table.html.erb +1 -1
  48. data/app/views/rails_pulse/queries/_table.html.erb +10 -12
  49. data/app/views/rails_pulse/queries/index.html.erb +41 -34
  50. data/app/views/rails_pulse/queries/show.html.erb +38 -31
  51. data/app/views/rails_pulse/requests/_operations.html.erb +32 -26
  52. data/app/views/rails_pulse/requests/_table.html.erb +1 -3
  53. data/app/views/rails_pulse/requests/index.html.erb +42 -34
  54. data/app/views/rails_pulse/routes/_table.html.erb +13 -13
  55. data/app/views/rails_pulse/routes/index.html.erb +43 -35
  56. data/app/views/rails_pulse/routes/show.html.erb +42 -35
  57. data/config/initializers/rails_pulse.rb +0 -12
  58. data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +54 -0
  59. data/db/rails_pulse_schema.rb +121 -0
  60. data/lib/generators/rails_pulse/install_generator.rb +41 -4
  61. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +60 -0
  62. data/lib/generators/rails_pulse/templates/rails_pulse.rb +0 -12
  63. data/lib/rails_pulse/configuration.rb +6 -12
  64. data/lib/rails_pulse/engine.rb +0 -1
  65. data/lib/rails_pulse/version.rb +1 -1
  66. data/lib/tasks/rails_pulse.rake +58 -0
  67. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  68. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  69. data/public/rails-pulse-assets/rails-pulse.js +1 -1
  70. data/public/rails-pulse-assets/rails-pulse.js.map +3 -3
  71. data/public/rails-pulse-assets/search.svg +43 -0
  72. metadata +28 -12
  73. data/app/controllers/rails_pulse/caches_controller.rb +0 -115
  74. data/app/helpers/rails_pulse/cached_component_helper.rb +0 -73
  75. data/app/models/rails_pulse/component_cache_key.rb +0 -33
  76. data/app/views/rails_pulse/caches/show.html.erb +0 -9
  77. data/db/migrate/20250227235904_create_routes.rb +0 -12
  78. data/db/migrate/20250227235915_create_requests.rb +0 -19
  79. data/db/migrate/20250228000000_create_queries.rb +0 -14
  80. data/db/migrate/20250228000056_create_operations.rb +0 -24
  81. data/lib/rails_pulse/migration.rb +0 -29
@@ -5,6 +5,13 @@ module RailsPulse
5
5
  before_action :set_request, only: :show
6
6
 
7
7
  def index
8
+ unless turbo_frame_request?
9
+ @average_response_times_metric_card = RailsPulse::Routes::Cards::AverageResponseTimes.new(route: nil).to_metric_card
10
+ @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil).to_metric_card
11
+ @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil).to_metric_card
12
+ @error_rate_per_route_metric_card = RailsPulse::Routes::Cards::ErrorRatePerRoute.new(route: nil).to_metric_card
13
+ end
14
+
8
15
  setup_chart_and_table_data
9
16
  end
10
17
 
@@ -15,7 +22,7 @@ module RailsPulse
15
22
  private
16
23
 
17
24
  def chart_model
18
- Request
25
+ Summary
19
26
  end
20
27
 
21
28
  def table_model
@@ -27,23 +34,27 @@ module RailsPulse
27
34
  end
28
35
 
29
36
  def chart_options
30
- { route: true }
37
+ {}
31
38
  end
32
39
 
33
40
  def build_chart_ransack_params(ransack_params)
34
- ransack_params.except(:s).merge(
35
- occurred_at_gteq: @start_time,
36
- occurred_at_lt: @end_time,
37
- duration_gteq: @start_duration
41
+ base_params = ransack_params.except(:s).merge(
42
+ period_start_gteq: Time.at(@start_time),
43
+ period_start_lt: Time.at(@end_time)
38
44
  )
45
+
46
+ # Only add duration filter if we have a meaningful threshold
47
+ base_params[:avg_duration_gteq] = @start_duration if @start_duration && @start_duration > 0
48
+ base_params
39
49
  end
40
50
 
41
51
  def build_table_ransack_params(ransack_params)
42
- ransack_params.merge(
43
- occurred_at_gteq: @table_start_time,
44
- occurred_at_lt: @table_end_time,
45
- duration_gteq: @start_duration
52
+ params = ransack_params.merge(
53
+ occurred_at_gteq: Time.at(@table_start_time),
54
+ occurred_at_lt: Time.at(@table_end_time)
46
55
  )
56
+ params[:duration_gteq] = @start_duration if @start_duration && @start_duration > 0
57
+ params
47
58
  end
48
59
 
49
60
  def default_table_sort
@@ -52,13 +63,14 @@ module RailsPulse
52
63
 
53
64
  def build_table_results
54
65
  @ransack_query.result
55
- .includes(:route)
66
+ .joins(:route)
56
67
  .select(
57
68
  "rails_pulse_requests.id",
58
69
  "rails_pulse_requests.occurred_at",
59
70
  "rails_pulse_requests.duration",
60
71
  "rails_pulse_requests.status",
61
- "rails_pulse_requests.route_id"
72
+ "rails_pulse_requests.route_id",
73
+ "rails_pulse_routes.path"
62
74
  )
63
75
  end
64
76
 
@@ -5,21 +5,32 @@ module RailsPulse
5
5
  before_action :set_route, only: :show
6
6
 
7
7
  def index
8
+ setup_metric_cards
8
9
  setup_chart_and_table_data
9
10
  end
10
11
 
11
12
  def show
13
+ setup_metric_cards
12
14
  setup_chart_and_table_data
13
15
  end
14
16
 
15
17
  private
16
18
 
19
+ def setup_metric_cards
20
+ return if turbo_frame_request?
21
+
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
26
+ end
27
+
17
28
  def chart_model
18
- show_action? ? Request : Route
29
+ Summary
19
30
  end
20
31
 
21
32
  def table_model
22
- show_action? ? Request : Route
33
+ show_action? ? Request : Summary
23
34
  end
24
35
 
25
36
  def chart_class
@@ -31,49 +42,53 @@ module RailsPulse
31
42
  end
32
43
 
33
44
  def build_chart_ransack_params(ransack_params)
34
- base_params = ransack_params.except(:s).merge(duration_field => @start_duration)
45
+ base_params = ransack_params.except(:s).merge(
46
+ period_start_gteq: Time.at(@start_time),
47
+ period_start_lt: Time.at(@end_time)
48
+ )
49
+
50
+ # Only add duration filter if we have a meaningful threshold
51
+ base_params[:avg_duration_gteq] = @start_duration if @start_duration && @start_duration > 0
35
52
 
36
53
  if show_action?
37
- base_params.merge(
38
- route_id_eq: @route.id,
39
- occurred_at_gteq: @start_time,
40
- occurred_at_lt: @end_time
41
- )
54
+ base_params.merge(summarizable_id_eq: @route.id)
42
55
  else
43
- base_params.merge(
44
- requests_occurred_at_gteq: @start_time,
45
- requests_occurred_at_lt: @end_time
46
- )
56
+ base_params
47
57
  end
48
58
  end
49
59
 
50
60
  def build_table_ransack_params(ransack_params)
51
- base_params = ransack_params.merge(duration_field => @start_duration)
52
-
53
61
  if show_action?
54
- base_params.merge(
55
- route_id_eq: @route.id,
56
- occurred_at_gteq: @table_start_time,
57
- occurred_at_lt: @table_end_time
62
+ # For Request model on show page
63
+ params = ransack_params.merge(
64
+ occurred_at_gteq: Time.at(@table_start_time),
65
+ occurred_at_lt: Time.at(@table_end_time),
66
+ route_id_eq: @route.id
58
67
  )
68
+ params[:duration_gteq] = @start_duration if @start_duration && @start_duration > 0
69
+ params
59
70
  else
60
- base_params.merge(
61
- requests_occurred_at_gteq: @table_start_time,
62
- requests_occurred_at_lt: @table_end_time
71
+ # For Summary model on index page
72
+ params = ransack_params.merge(
73
+ period_start_gteq: Time.at(@table_start_time),
74
+ period_start_lt: Time.at(@table_end_time)
63
75
  )
76
+ params[:avg_duration_gteq] = @start_duration if @start_duration && @start_duration > 0
77
+ params
64
78
  end
65
79
  end
66
80
 
67
81
  def default_table_sort
68
- show_action? ? "occurred_at desc" : "average_response_time_ms desc"
82
+ show_action? ? "occurred_at desc" : "avg_duration desc"
69
83
  end
70
84
 
71
85
  def build_table_results
72
86
  if show_action?
73
- @ransack_query.result.select("id", "route_id", "occurred_at", "duration", "status")
87
+ @ransack_query.result
74
88
  else
75
89
  Routes::Tables::Index.new(
76
90
  ransack_query: @ransack_query,
91
+ period_type: period_type,
77
92
  start_time: @start_time,
78
93
  params: params
79
94
  ).to_table
@@ -81,13 +96,33 @@ module RailsPulse
81
96
  end
82
97
 
83
98
  def duration_field
84
- show_action? ? :duration_gteq : :requests_duration_gteq
99
+ :avg_duration
85
100
  end
86
101
 
87
102
  def show_action?
88
103
  action_name == "show"
89
104
  end
90
105
 
106
+ def setup_table_data(ransack_params)
107
+ table_ransack_params = build_table_ransack_params(ransack_params)
108
+ @ransack_query = table_model.ransack(table_ransack_params)
109
+
110
+ # Only apply default sort if not using Routes::Tables::Index (which handles its own sorting)
111
+ if show_action?
112
+ @ransack_query.sorts = default_table_sort if @ransack_query.sorts.empty?
113
+ end
114
+
115
+ table_results = build_table_results
116
+ handle_pagination
117
+
118
+ @pagy, @table_data = pagy(table_results, limit: session_pagination_limit)
119
+ end
120
+
121
+ def handle_pagination
122
+ method = pagination_method
123
+ send(method, params[:limit]) if params[:limit].present?
124
+ end
125
+
91
126
  def pagination_method
92
127
  show_action? ? :set_pagination_limit : :store_pagination_limit
93
128
  end
@@ -3,7 +3,6 @@ module RailsPulse
3
3
  include Pagy::Frontend
4
4
 
5
5
  include BreadcrumbsHelper
6
- include CachedComponentHelper
7
6
  include ChartHelper
8
7
  include FormattingHelper
9
8
  include StatusHelper
@@ -1,6 +1,6 @@
1
1
  module RailsPulse
2
2
  module ChartFormatters
3
- def self.occurred_at_as_time_or_date(time_diff_hours)
3
+ def self.period_as_time_or_date(time_diff_hours)
4
4
  if time_diff_hours <= 25
5
5
  <<~JS
6
6
  function(value) {
@@ -25,7 +25,7 @@ module RailsPulse
25
25
  const data = params[0];
26
26
  const date = new Date(data.axisValue * 1000);
27
27
  const dateString = date.getHours().toString().padStart(2, '0') + ':00';
28
- return `${dateString} <br /> ${data.marker} ${parseInt(data.data.value)} ms`;
28
+ return `${dateString} <br /> ${data.marker} ${parseInt(data.data)} ms`;
29
29
  }
30
30
  JS
31
31
  else
@@ -34,7 +34,7 @@ module RailsPulse
34
34
  const data = params[0];
35
35
  const date = new Date(data.axisValue * 1000);
36
36
  const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
37
- return `${dateString} <br /> ${data.marker} ${parseInt(data.data.value)} ms`;
37
+ return `${dateString} <br /> ${data.marker} ${parseInt(data.data)} ms`;
38
38
  }
39
39
  JS
40
40
  end
@@ -117,9 +117,13 @@ module RailsPulse
117
117
  # Chart data is a hash like: { 1234567890 => { value: 123.45 } }
118
118
  chart_timestamps = chart_data.keys
119
119
 
120
+ # Convert zoom parameters to integers (timestamps)
121
+ zoom_start_int = zoom_start.respond_to?(:to_i) ? zoom_start.to_i : zoom_start
122
+ zoom_end_int = zoom_end.respond_to?(:to_i) ? zoom_end.to_i : zoom_end
123
+
120
124
  if chart_timestamps.any?
121
- closest_start = chart_timestamps.min_by { |ts| (ts - zoom_start).abs }
122
- closest_end = chart_timestamps.min_by { |ts| (ts - zoom_end).abs }
125
+ closest_start = chart_timestamps.min_by { |ts| (ts - zoom_start_int).abs }
126
+ closest_end = chart_timestamps.min_by { |ts| (ts - zoom_end_int).abs }
123
127
 
124
128
  # Find the array indices of these timestamps
125
129
  start_index = chart_timestamps.index(closest_start)
@@ -256,13 +256,19 @@ module RailsPulse
256
256
 
257
257
  def event_color(operation_type)
258
258
  case operation_type
259
- when "sql" then "#92c282;"
260
- when "template", "partial", "layout", "collection" then "#b77cbf"
261
- when "controller" then "#00adc4"
262
- else "gray"
259
+ when "sql"
260
+ "#d27d6b"
261
+ when "template", "partial", "layout", "collection"
262
+ "#6c7ab9"
263
+ when "controller"
264
+ "#5ba6b0"
265
+ else
266
+ "#a6a6a6"
263
267
  end
264
268
  end
265
269
 
270
+
271
+
266
272
  def duration_options(type = :route)
267
273
  thresholds = RailsPulse.configuration.public_send("#{type}_thresholds")
268
274
 
@@ -1,69 +1,108 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
- static targets = ["chart", "paginationLimit", "indexTable"] // The chart element to be monitored
4
+ static targets = ["chart", "paginationLimit", "indexTable"]
5
5
 
6
6
  static values = {
7
7
  chartId: String // The ID of the chart to be monitored
8
8
  }
9
9
 
10
- // Add a property to track the last request time
10
+ // Add properties for improved debouncing
11
11
  lastTurboFrameRequestAt = 0;
12
+ pendingRequestTimeout = null;
13
+ pendingRequestData = null;
12
14
 
13
15
  connect() {
14
16
  // Listen for the custom event 'chart:initialized' to set up the chart.
15
17
  // This event is sent from the RailsCharts library when the chart is ready.
16
18
  this.handleChartInitialized = this.onChartInitialized.bind(this);
17
- document.addEventListener('chart:initialized', this.handleChartInitialized);
19
+
20
+ document.addEventListener('chart:rendered', this.handleChartInitialized);
18
21
 
19
22
  // If the chart is already initialized (e.g., on back navigation), set up immediately
20
- if (window.RailsCharts?.charts?.[this.chartIdValue]) { this.setup(); }
23
+ if (window.RailsCharts?.charts?.[this.chartIdValue]) {
24
+ this.setup();
25
+ }
21
26
  }
22
27
 
23
28
  disconnect() {
24
29
  // Remove the event listener from RailsCharts when the controller is disconnected
25
- document.removeEventListener('chart:initialized', this.handleChartInitialized);
30
+ document.removeEventListener('chart:rendered', this.handleChartInitialized);
26
31
 
27
32
  // Remove chart event listeners if they exist
28
- if (this.chartTarget) {
33
+ if (this.hasChartTarget && this.chartTarget) {
29
34
  this.chartTarget.removeEventListener('mousedown', this.handleChartMouseDown);
30
35
  this.chartTarget.removeEventListener('mouseup', this.handleChartMouseUp);
31
36
  }
32
37
  document.removeEventListener('mouseup', this.handleDocumentMouseUp);
38
+
39
+ // Clear any pending timeout
40
+ if (this.pendingRequestTimeout) {
41
+ clearTimeout(this.pendingRequestTimeout);
42
+ }
33
43
  }
34
44
 
35
45
  // After the chart is initialized, set up the event listeners and data tracking
36
46
  onChartInitialized(event) {
37
- if (event.detail.chartId === this.chartIdValue) { this.setup(); }
47
+ if (event.detail.containerId === this.chartIdValue) {
48
+ this.setup();
49
+ }
38
50
  }
39
51
 
40
52
  setup() {
41
- if (this.setupDone) return; // Prevent multiple setups
53
+ if (this.setupDone) {
54
+ return; // Prevent multiple setups
55
+ }
42
56
 
57
+ // We need both the chart target in DOM and the chart object from RailsCharts
58
+ let hasTarget = false;
59
+ try {
60
+ hasTarget = !!this.chartTarget;
61
+ } catch (e) {
62
+ hasTarget = false;
63
+ }
64
+
43
65
  // Get the chart element which the RailsCharts library has created
44
66
  this.chart = window.RailsCharts.charts[this.chartIdValue];
45
- if (!this.chart) return;
67
+
68
+ // Only proceed if we have BOTH the DOM target and the chart object
69
+ if (!hasTarget || !this.chart) {
70
+ return;
71
+ }
46
72
 
47
73
  this.visibleData = this.getVisibleData();
48
74
  this.setupChartEventListeners();
49
75
  this.setupDone = true;
76
+
77
+ // Mark the chart as fully rendered for testing
78
+ if (hasTarget) {
79
+ document.getElementById(this.chartIdValue)?.setAttribute('data-chart-rendered', 'true');
80
+ }
50
81
  }
51
82
 
52
83
  // Add some event listeners to the chart so we can track the zoom changes
53
84
  setupChartEventListeners() {
54
85
  // When clicking on the chart, we want to store the current visible data so we can compare it later
55
- this.handleChartMouseDown = () => { this.visibleData = this.getVisibleData(); };
86
+ this.handleChartMouseDown = () => {
87
+ this.visibleData = this.getVisibleData();
88
+ };
56
89
  this.chartTarget.addEventListener('mousedown', this.handleChartMouseDown);
57
90
 
58
91
  // When releasing the mouse button, we want to check if the visible data has changed
59
- this.handleChartMouseUp = () => { this.handleZoomChange(); };
92
+ this.handleChartMouseUp = () => {
93
+ this.handleZoomChange();
94
+ };
60
95
  this.chartTarget.addEventListener('mouseup', this.handleChartMouseUp);
61
96
 
62
97
  // When the chart is zoomed, we want to check if the visible data has changed
63
- this.chart.on('datazoom', () => { this.handleZoomChange(); });
98
+ this.chart.on('datazoom', () => {
99
+ this.handleZoomChange();
100
+ });
64
101
 
65
102
  // When releasing the mouse button outside the chart, we want to check if the visible data has changed
66
- this.handleDocumentMouseUp = () => { this.handleZoomChange(); };
103
+ this.handleDocumentMouseUp = () => {
104
+ this.handleZoomChange();
105
+ };
67
106
  document.addEventListener('mouseup', this.handleDocumentMouseUp);
68
107
  }
69
108
 
@@ -71,18 +110,37 @@ export default class extends Controller {
71
110
  // The xAxis data and series data are sliced based on the start and end values of the dataZoom component.
72
111
  // The series data will contain the actual data points that are visible in the chart.
73
112
  getVisibleData() {
74
- const currentOption = this.chart.getOption();
75
- const dataZoom = currentOption.dataZoom[1];
76
- const xAxisData = currentOption.xAxis[0].data;
77
- const seriesData = currentOption.series[0].data;
113
+ try {
114
+ const currentOption = this.chart.getOption();
78
115
 
79
- const startValue = dataZoom.startValue;
80
- const endValue = dataZoom.endValue;
116
+ if (!currentOption.dataZoom || currentOption.dataZoom.length === 0) {
117
+ return { xAxis: [], series: [] };
118
+ }
81
119
 
82
- return {
83
- xAxis: xAxisData.slice(startValue, endValue + 1),
84
- series: seriesData.slice(startValue, endValue + 1)
85
- };
120
+ // Try to find the correct dataZoom component
121
+ let dataZoom = currentOption.dataZoom[1] || currentOption.dataZoom[0];
122
+
123
+ if (!currentOption.xAxis || !currentOption.xAxis[0] || !currentOption.xAxis[0].data) {
124
+ return { xAxis: [], series: [] };
125
+ }
126
+
127
+ if (!currentOption.series || !currentOption.series[0] || !currentOption.series[0].data) {
128
+ return { xAxis: [], series: [] };
129
+ }
130
+
131
+ const xAxisData = currentOption.xAxis[0].data;
132
+ const seriesData = currentOption.series[0].data;
133
+
134
+ const startValue = dataZoom.startValue || 0;
135
+ const endValue = dataZoom.endValue || xAxisData.length - 1;
136
+
137
+ return {
138
+ xAxis: xAxisData.slice(startValue, endValue + 1),
139
+ series: seriesData.slice(startValue, endValue + 1)
140
+ };
141
+ } catch (error) {
142
+ return { xAxis: [], series: [] };
143
+ }
86
144
  }
87
145
 
88
146
  // When the zoom level changes, we want to check if the visible data has changed
@@ -90,7 +148,10 @@ export default class extends Controller {
90
148
  // we can update the table with the new data that is visible in the chart.
91
149
  handleZoomChange() {
92
150
  const newVisibleData = this.getVisibleData();
93
- if (newVisibleData.xAxis.join() !== this.visibleData.xAxis.join()) {
151
+ const newDataString = newVisibleData.xAxis.join();
152
+ const currentDataString = this.visibleData.xAxis.join();
153
+
154
+ if (newDataString !== currentDataString) {
94
155
  this.visibleData = newVisibleData;
95
156
  this.updateUrlWithZoomParams(newVisibleData);
96
157
  this.sendTurboFrameRequest(newVisibleData);
@@ -124,14 +185,35 @@ export default class extends Controller {
124
185
  window.history.replaceState({}, '', url);
125
186
  }
126
187
 
127
- // After the zoom level changes, we want to send a request to the server with the new visible data.
128
- // The server will then return the full page HTML with the updated table data wrapped in a turbo-frame.
129
- // We will then replace the innerHTML of the turbo-frame with the new HTML.
188
+ // Improved debouncing with guaranteed final request
130
189
  sendTurboFrameRequest(data) {
131
190
  const now = Date.now();
132
- // If less than 1 second since last request, ignore this call
133
- if (now - this.lastTurboFrameRequestAt < 1000) { return; }
134
- this.lastTurboFrameRequestAt = now;
191
+ const timeSinceLastRequest = now - this.lastTurboFrameRequestAt;
192
+
193
+ // Store the latest data for potential delayed execution
194
+ this.pendingRequestData = data;
195
+
196
+ // Clear any existing timeout
197
+ if (this.pendingRequestTimeout) {
198
+ clearTimeout(this.pendingRequestTimeout);
199
+ }
200
+
201
+ // If enough time has passed since last request, execute immediately
202
+ if (timeSinceLastRequest >= 1000) {
203
+ this.executeTurboFrameRequest(data);
204
+ } else {
205
+ // Otherwise, schedule execution for later to ensure final request goes through
206
+ const remainingTime = 1000 - timeSinceLastRequest;
207
+ this.pendingRequestTimeout = setTimeout(() => {
208
+ this.executeTurboFrameRequest(this.pendingRequestData);
209
+ this.pendingRequestTimeout = null;
210
+ }, remainingTime);
211
+ }
212
+ }
213
+
214
+ // Execute the actual AJAX request
215
+ executeTurboFrameRequest(data) {
216
+ this.lastTurboFrameRequestAt = Date.now();
135
217
 
136
218
  // Start with the current page's URL
137
219
  const url = new URL(window.location.href);
@@ -159,7 +241,9 @@ export default class extends Controller {
159
241
  'Turbo-Frame': this.chartIdValue
160
242
  }
161
243
  })
162
- .then(response => response.text()) // Get the raw HTML response
244
+ .then(response => {
245
+ return response.text();
246
+ })
163
247
  .then(html => {
164
248
  // Find the turbo-frame in the document using the target
165
249
  const frame = this.indexTableTarget;
@@ -179,7 +263,7 @@ export default class extends Controller {
179
263
  }
180
264
  }
181
265
  })
182
- .catch(error => console.error('Error:', error));
266
+ .catch(error => console.error('[IndexController] Fetch error:', error));
183
267
  }
184
268
 
185
269
  // CSP-safe method to replace frame content using DOM methods
@@ -209,7 +293,7 @@ export default class extends Controller {
209
293
  // Parse HTML safely
210
294
  const parser = new DOMParser();
211
295
  const doc = parser.parseFromString(html, 'text/html');
212
-
296
+
213
297
  // Clear existing content
214
298
  while (targetFrame.firstChild) {
215
299
  targetFrame.removeChild(targetFrame.firstChild);
@@ -11,33 +11,25 @@ export default class extends Controller {
11
11
  this.restorePaginationLimit()
12
12
  }
13
13
 
14
- // Update pagination limit via AJAX and reload the page to reflect changes
15
- async updateLimit() {
14
+ // Update pagination limit and refresh the turbo frame
15
+ updateLimit() {
16
16
  const limit = this.limitTarget.value
17
17
 
18
- // Save to session storage
18
+ // Save to session storage only - no server request needed
19
19
  sessionStorage.setItem(this.storageKeyValue, limit)
20
20
 
21
- try {
22
- // Send AJAX request to update server session using Rails.ajax
23
- const response = await fetch(this.urlValue, {
24
- method: 'PATCH',
25
- headers: {
26
- 'Content-Type': 'application/json',
27
- 'X-CSRF-Token': this.getCSRFToken()
28
- },
29
- body: JSON.stringify({ limit: limit })
30
- })
31
-
32
- if (response.ok) {
33
- // Reload the page to reflect the new pagination limit
34
- // This preserves all current URL parameters including Ransack search params
35
- window.location.reload()
36
- } else {
37
- throw new Error(`HTTP error! status: ${response.status}`)
38
- }
39
- } catch (error) {
40
- console.error('Error updating pagination limit:', error)
21
+ // Find the closest turbo frame and reload it to apply new pagination
22
+ const turboFrame = this.element.closest('turbo-frame')
23
+ if (turboFrame) {
24
+ // Add the limit as a URL parameter so server picks it up
25
+ const currentUrl = new URL(window.location)
26
+ currentUrl.searchParams.set('limit', limit)
27
+ turboFrame.src = currentUrl.pathname + currentUrl.search
28
+ } else {
29
+ // Fallback to page reload if not within a turbo frame
30
+ const currentUrl = new URL(window.location)
31
+ currentUrl.searchParams.set('limit', limit)
32
+ window.location.href = currentUrl.pathname + currentUrl.search
41
33
  }
42
34
  }
43
35
 
@@ -60,10 +52,8 @@ export default class extends Controller {
60
52
  // Only set if the current value is different (prevents unnecessary DOM updates)
61
53
  if (this.limitTarget.value !== savedLimit) {
62
54
  this.limitTarget.value = savedLimit
63
-
64
- // Trigger a change event to ensure any other listeners are notified
65
- this.limitTarget.dispatchEvent(new Event('change', { bubbles: true }))
55
+ // Don't trigger change event when restoring from session - prevents infinite loops
66
56
  }
67
57
  }
68
58
  }
69
- }
59
+ }
@@ -0,0 +1,41 @@
1
+ module RailsPulse
2
+ class BackfillSummariesJob < ApplicationJob
3
+ queue_as :low_priority
4
+
5
+ def perform(start_date, end_date, period_types = [ "hour", "day" ])
6
+ start_date = start_date.to_datetime
7
+ end_date = end_date.to_datetime
8
+
9
+ period_types.each do |period_type|
10
+ backfill_period(period_type, start_date, end_date)
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def backfill_period(period_type, start_date, end_date)
17
+ current = Summary.normalize_period_start(period_type, start_date)
18
+ period_end = Summary.calculate_period_end(period_type, end_date)
19
+
20
+ while current <= period_end
21
+ Rails.logger.info "[RailsPulse] Backfilling #{period_type} summary for #{current}"
22
+
23
+ SummaryService.new(period_type, current).perform
24
+
25
+ current = advance_period(current, period_type)
26
+
27
+ # Add small delay to avoid overwhelming the database
28
+ sleep 0.1
29
+ end
30
+ end
31
+
32
+ def advance_period(time, period_type)
33
+ case period_type
34
+ when "hour" then time + 1.hour
35
+ when "day" then time + 1.day
36
+ when "week" then time + 1.week
37
+ when "month" then time + 1.month
38
+ end
39
+ end
40
+ end
41
+ end