rails_pulse 0.1.1 → 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 +72 -176
  3. data/Rakefile +77 -2
  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 +18 -7
  25. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +34 -41
  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 +0 -11
  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 +27 -11
  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
@@ -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
@@ -0,0 +1,53 @@
1
+ module RailsPulse
2
+ class SummaryJob < ApplicationJob
3
+ queue_as :low_priority
4
+
5
+ def perform(target_hour = nil)
6
+ target_hour ||= 1.hour.ago.beginning_of_hour
7
+
8
+ # Always run hourly summary
9
+ process_hourly_summary(target_hour)
10
+
11
+ # Check if we should run daily summary (at the start of a new day)
12
+ if target_hour.hour == 0
13
+ process_daily_summary(target_hour.to_date - 1.day)
14
+
15
+ # Check if we should run weekly summary (Monday at midnight)
16
+ if target_hour.wday == 1
17
+ process_weekly_summary((target_hour.to_date - 1.week).beginning_of_week)
18
+ end
19
+
20
+ # Check if we should run monthly summary (first day of month)
21
+ if target_hour.day == 1
22
+ process_monthly_summary((target_hour.to_date - 1.month).beginning_of_month)
23
+ end
24
+ end
25
+ rescue => e
26
+ Rails.logger.error "[RailsPulse] Summary job failed: #{e.message}"
27
+ Rails.logger.error e.backtrace.join("\n")
28
+ raise
29
+ end
30
+
31
+ private
32
+
33
+ def process_hourly_summary(hour)
34
+ Rails.logger.info "[RailsPulse] Processing hourly summary for #{hour}"
35
+ SummaryService.new("hour", hour).perform
36
+ end
37
+
38
+ def process_daily_summary(date)
39
+ Rails.logger.info "[RailsPulse] Processing daily summary for #{date}"
40
+ SummaryService.new("day", date).perform
41
+ end
42
+
43
+ def process_weekly_summary(week_start)
44
+ Rails.logger.info "[RailsPulse] Processing weekly summary for week starting #{week_start}"
45
+ SummaryService.new("week", week_start).perform
46
+ end
47
+
48
+ def process_monthly_summary(month_start)
49
+ Rails.logger.info "[RailsPulse] Processing monthly summary for month starting #{month_start}"
50
+ SummaryService.new("month", month_start).perform
51
+ end
52
+ end
53
+ end
@@ -8,17 +8,38 @@ module RailsPulse
8
8
  end_date = Time.current.to_date
9
9
  date_range = (start_date..end_date)
10
10
 
11
- # Get the actual data
12
- requests = RailsPulse::Request.where("occurred_at >= ?", start_date.beginning_of_day)
13
- actual_data = requests
14
- .group_by_day(:occurred_at)
15
- .average(:duration)
11
+ # 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
+
18
+ # Group by day manually for cross-database compatibility
19
+ actual_data = {}
20
+ summaries.each do |summary|
21
+ date = summary.period_start.to_date
22
+
23
+ if actual_data[date]
24
+ actual_data[date][:total_weighted] += (summary.avg_duration || 0) * (summary.count || 0)
25
+ actual_data[date][:total_count] += (summary.count || 0)
26
+ else
27
+ actual_data[date] = {
28
+ total_weighted: (summary.avg_duration || 0) * (summary.count || 0),
29
+ total_count: (summary.count || 0)
30
+ }
31
+ end
32
+ end
33
+
34
+ # Convert to final values
35
+ actual_data = actual_data.transform_values do |data|
36
+ data[:total_count] > 0 ? (data[:total_weighted] / data[:total_count]).round(0) : 0
37
+ end
16
38
 
17
39
  # Fill in all dates with zero values for missing days
18
40
  date_range.each_with_object({}) do |date, result|
19
41
  formatted_date = date.strftime("%b %-d")
20
- avg_duration = actual_data[date]
21
- result[formatted_date] = avg_duration&.round(0) || 0
42
+ result[formatted_date] = actual_data[date] || 0
22
43
  end
23
44
  end
24
45
  end
@@ -3,32 +3,28 @@ module RailsPulse
3
3
  module Charts
4
4
  class P95ResponseTime
5
5
  def to_chart_data
6
- start_date = 2.weeks.ago.beginning_of_day
6
+ # Create a range of all dates in the past 2 weeks
7
+ start_date = 2.weeks.ago.beginning_of_day.to_date
8
+ end_date = Time.current.to_date
9
+ date_range = (start_date..end_date)
7
10
 
8
- # Performance optimization: Single query instead of N+1 queries (15 queries -> 1 query)
9
- # Fetch all requests for 2-week period, pre-sorted by date and duration
10
- # For optimal performance, ensure index exists: (occurred_at, duration)
11
- requests_by_day = RailsPulse::Request
12
- .where(occurred_at: start_date..)
13
- .select("occurred_at, duration, DATE(occurred_at) as request_date")
14
- .order("request_date, duration")
15
- .group_by { |r| r.request_date.to_date }
11
+ # 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
+ )
16
17
 
17
- # Generate all dates in range and calculate P95 for each
18
- (start_date.to_date..Time.current.to_date).each_with_object({}) do |date, hash|
19
- day_requests = requests_by_day[date] || []
20
-
21
- if day_requests.empty?
22
- p95_value = 0
23
- else
24
- # Calculate P95 from in-memory sorted array (already sorted by DB)
25
- count = day_requests.length
26
- p95_index = (count * 0.95).ceil - 1
27
- p95_value = day_requests[p95_index].duration.round(0)
28
- end
18
+ actual_data = summaries
19
+ .group_by_day(:period_start, time_zone: Time.zone)
20
+ .average(:p95_duration)
21
+ .transform_keys { |date| date.to_date }
22
+ .transform_values { |avg| avg&.round(0) || 0 }
29
23
 
24
+ # Fill in all dates with zero values for missing days
25
+ date_range.each_with_object({}) do |date, result|
30
26
  formatted_date = date.strftime("%b %-d")
31
- hash[formatted_date] = p95_value
27
+ result[formatted_date] = actual_data[date] || 0
32
28
  end
33
29
  end
34
30
  end
@@ -8,11 +8,22 @@ module RailsPulse
8
8
  this_week_start = 1.week.ago.beginning_of_week
9
9
  this_week_end = Time.current.end_of_week
10
10
 
11
- # Fetch query data for this week
12
- query_data = RailsPulse::Operation.joins(:query)
13
- .where(occurred_at: this_week_start..this_week_end)
14
- .group("rails_pulse_queries.id, rails_pulse_queries.normalized_sql")
15
- .select("rails_pulse_queries.id, rails_pulse_queries.normalized_sql, AVG(rails_pulse_operations.duration) as avg_duration, COUNT(*) as request_count, MAX(rails_pulse_operations.occurred_at) as last_seen")
11
+ # Fetch query data from Summary records for this week
12
+ query_data = RailsPulse::Summary
13
+ .joins("INNER JOIN rails_pulse_queries ON rails_pulse_queries.id = rails_pulse_summaries.summarizable_id")
14
+ .where(
15
+ summarizable_type: "RailsPulse::Query",
16
+ period_type: "day",
17
+ period_start: this_week_start..this_week_end
18
+ )
19
+ .group("rails_pulse_summaries.summarizable_id, rails_pulse_queries.normalized_sql")
20
+ .select(
21
+ "rails_pulse_summaries.summarizable_id as query_id",
22
+ "rails_pulse_queries.normalized_sql",
23
+ "SUM(rails_pulse_summaries.avg_duration * rails_pulse_summaries.count) / SUM(rails_pulse_summaries.count) as avg_duration",
24
+ "SUM(rails_pulse_summaries.count) as request_count",
25
+ "MAX(rails_pulse_summaries.period_end) as last_seen"
26
+ )
16
27
  .order("avg_duration DESC")
17
28
  .limit(5)
18
29
 
@@ -20,8 +31,8 @@ module RailsPulse
20
31
  data_rows = query_data.map do |record|
21
32
  {
22
33
  query_text: truncate_query(record.normalized_sql),
23
- query_id: record.id,
24
- query_link: "/rails_pulse/queries/#{record.id}",
34
+ query_id: record.query_id,
35
+ query_link: "/rails_pulse/queries/#{record.query_id}",
25
36
  average_time: record.avg_duration.to_f.round(0),
26
37
  request_count: record.request_count,
27
38
  last_request: time_ago_in_words(record.last_seen)
@@ -5,58 +5,51 @@ module RailsPulse
5
5
  include RailsPulse::FormattingHelper
6
6
 
7
7
  def to_table_data
8
- # Get data for this week and last week
8
+ # Get data for this week
9
9
  this_week_start = 1.week.ago.beginning_of_week
10
10
  this_week_end = Time.current.end_of_week
11
- last_week_start = 2.weeks.ago.beginning_of_week
12
- last_week_end = 1.week.ago.beginning_of_week
13
11
 
14
- # Get this week's data
15
- this_week_data = RailsPulse::Request.joins(:route)
16
- .where(occurred_at: this_week_start..this_week_end)
17
- .group("rails_pulse_routes.path, rails_pulse_routes.id")
18
- .select("rails_pulse_routes.path, rails_pulse_routes.id, AVG(rails_pulse_requests.duration) as avg_duration, COUNT(*) as request_count")
12
+ # Fetch route data from Summary records for this week
13
+ route_data = RailsPulse::Summary
14
+ .joins("INNER JOIN rails_pulse_routes ON rails_pulse_routes.id = rails_pulse_summaries.summarizable_id")
15
+ .where(
16
+ summarizable_type: "RailsPulse::Route",
17
+ period_type: "day",
18
+ period_start: this_week_start..this_week_end
19
+ )
20
+ .group("rails_pulse_summaries.summarizable_id, rails_pulse_routes.path")
21
+ .select(
22
+ "rails_pulse_summaries.summarizable_id as route_id",
23
+ "rails_pulse_routes.path",
24
+ "SUM(rails_pulse_summaries.avg_duration * rails_pulse_summaries.count) / SUM(rails_pulse_summaries.count) as avg_duration",
25
+ "SUM(rails_pulse_summaries.count) as request_count",
26
+ "MAX(rails_pulse_summaries.period_end) as last_seen"
27
+ )
19
28
  .order("avg_duration DESC")
20
29
  .limit(5)
21
30
 
22
- # Get last week's data for comparison
23
- last_week_averages = RailsPulse::Request.joins(:route)
24
- .where(occurred_at: last_week_start..last_week_end)
25
- .group("rails_pulse_routes.path")
26
- .average("rails_pulse_requests.duration")
27
-
28
- # Build result array matching test expectations
29
- this_week_data.map do |record|
30
- this_week_avg = record.avg_duration.to_f.round(0)
31
- last_week_avg = last_week_averages[record.path]&.round(0) || 0
32
-
33
- # Calculate percentage change
34
- percentage_change = if last_week_avg == 0
35
- this_week_avg > 0 ? 100.0 : 0.0
36
- else
37
- ((this_week_avg - last_week_avg) / last_week_avg.to_f * 100).round(1)
38
- end
39
-
40
- # Determine trend (worse = slower response times)
41
- trend = if last_week_avg == 0
42
- this_week_avg > 0 ? "worse" : "stable"
43
- elsif this_week_avg > last_week_avg
44
- "worse" # Slower = worse
45
- elsif this_week_avg < last_week_avg
46
- "better" # Faster = better
47
- else
48
- "stable"
49
- end
50
-
31
+ # Build data rows
32
+ data_rows = route_data.map do |record|
51
33
  {
52
34
  route_path: record.path,
53
- this_week_avg: this_week_avg,
54
- last_week_avg: last_week_avg,
55
- percentage_change: percentage_change,
35
+ route_id: record.route_id,
36
+ route_link: "/rails_pulse/routes/#{record.route_id}",
37
+ average_time: record.avg_duration.to_f.round(0),
56
38
  request_count: record.request_count,
57
- trend: trend
39
+ last_request: time_ago_in_words(record.last_seen)
58
40
  }
59
41
  end
42
+
43
+ # Return new structure with columns and data
44
+ {
45
+ columns: [
46
+ { field: :route_path, label: "Route", link_to: :route_link, class: "w-auto" },
47
+ { field: :average_time, label: "Average Time", class: "w-32" },
48
+ { field: :request_count, label: "Requests", class: "w-24" },
49
+ { field: :last_request, label: "Last Request", class: "w-32" }
50
+ ],
51
+ data: data_rows
52
+ }
60
53
  end
61
54
  end
62
55
  end
@@ -34,7 +34,7 @@ module RailsPulse
34
34
  before_validation :associate_query
35
35
 
36
36
  def self.ransackable_attributes(auth_object = nil)
37
- %w[id occurred_at label duration start_time average_query_time_ms query_count operation_type]
37
+ %w[id occurred_at label duration start_time average_query_time_ms query_count operation_type query_id]
38
38
  end
39
39
 
40
40
  def self.ransackable_associations(auth_object = nil)