rails_pulse 0.1.2 → 0.1.4

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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -20
  3. data/Rakefile +169 -86
  4. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  5. data/app/assets/images/rails_pulse/request.png +0 -0
  6. data/app/assets/stylesheets/rails_pulse/application.css +28 -5
  7. data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
  8. data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
  9. data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
  10. data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
  11. data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
  12. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
  13. data/app/controllers/concerns/zoom_range_concern.rb +31 -0
  14. data/app/controllers/rails_pulse/application_controller.rb +5 -1
  15. data/app/controllers/rails_pulse/queries_controller.rb +49 -10
  16. data/app/controllers/rails_pulse/requests_controller.rb +46 -20
  17. data/app/controllers/rails_pulse/routes_controller.rb +40 -1
  18. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +1 -1
  19. data/app/helpers/rails_pulse/chart_helper.rb +16 -8
  20. data/app/helpers/rails_pulse/formatting_helper.rb +21 -2
  21. data/app/javascript/rails_pulse/application.js +34 -3
  22. data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
  23. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
  24. data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
  25. data/app/javascript/rails_pulse/controllers/index_controller.js +249 -11
  26. data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
  27. data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
  28. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +1 -1
  29. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
  30. data/app/models/rails_pulse/queries/cards/average_query_times.rb +20 -20
  31. data/app/models/rails_pulse/queries/cards/execution_rate.rb +58 -14
  32. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +14 -9
  33. data/app/models/rails_pulse/queries/charts/average_query_times.rb +3 -7
  34. data/app/models/rails_pulse/query.rb +46 -0
  35. data/app/models/rails_pulse/request.rb +1 -1
  36. data/app/models/rails_pulse/requests/charts/average_response_times.rb +2 -2
  37. data/app/models/rails_pulse/requests/tables/index.rb +77 -0
  38. data/app/models/rails_pulse/routes/cards/average_response_times.rb +18 -20
  39. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +14 -9
  40. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +14 -9
  41. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +29 -13
  42. data/app/models/rails_pulse/routes/tables/index.rb +4 -2
  43. data/app/models/rails_pulse/summary.rb +7 -7
  44. data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
  45. data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
  46. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
  47. data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
  48. data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
  49. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +154 -0
  50. data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
  51. data/app/services/rails_pulse/query_analysis_service.rb +125 -0
  52. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
  53. data/app/views/layouts/rails_pulse/application.html.erb +0 -2
  54. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
  55. data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
  56. data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
  57. data/app/views/rails_pulse/components/_metric_card.html.erb +28 -5
  58. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  59. data/app/views/rails_pulse/components/_panel.html.erb +1 -1
  60. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
  61. data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
  62. data/app/views/rails_pulse/dashboard/index.html.erb +2 -2
  63. data/app/views/rails_pulse/operations/show.html.erb +17 -15
  64. data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
  65. data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
  66. data/app/views/rails_pulse/queries/_analysis_results.html.erb +117 -0
  67. data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
  68. data/app/views/rails_pulse/queries/_show_table.html.erb +34 -6
  69. data/app/views/rails_pulse/queries/_table.html.erb +4 -8
  70. data/app/views/rails_pulse/queries/index.html.erb +48 -51
  71. data/app/views/rails_pulse/queries/show.html.erb +56 -52
  72. data/app/views/rails_pulse/requests/_operations.html.erb +30 -43
  73. data/app/views/rails_pulse/requests/_table.html.erb +31 -18
  74. data/app/views/rails_pulse/requests/index.html.erb +55 -50
  75. data/app/views/rails_pulse/requests/show.html.erb +0 -2
  76. data/app/views/rails_pulse/routes/_requests_table.html.erb +39 -0
  77. data/app/views/rails_pulse/routes/_table.html.erb +4 -10
  78. data/app/views/rails_pulse/routes/index.html.erb +49 -52
  79. data/app/views/rails_pulse/routes/show.html.erb +6 -8
  80. data/config/initializers/rails_charts_csp_patch.rb +32 -40
  81. data/config/routes.rb +5 -1
  82. data/db/migrate/20250930105043_install_rails_pulse_tables.rb +23 -0
  83. data/db/rails_pulse_schema.rb +10 -1
  84. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +81 -0
  85. data/lib/generators/rails_pulse/install_generator.rb +75 -18
  86. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +72 -2
  87. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +23 -0
  88. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
  89. data/lib/generators/rails_pulse/upgrade_generator.rb +226 -0
  90. data/lib/rails_pulse/engine.rb +21 -0
  91. data/lib/rails_pulse/version.rb +1 -1
  92. data/lib/tasks/rails_pulse.rake +27 -8
  93. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  94. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  95. data/public/rails-pulse-assets/rails-pulse.js +53 -53
  96. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  97. metadata +25 -6
  98. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  99. data/app/assets/images/rails_pulse/routes.png +0 -0
  100. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
  101. data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +0 -54
@@ -11,6 +11,8 @@ export default class extends Controller {
11
11
  lastTurboFrameRequestAt = 0;
12
12
  pendingRequestTimeout = null;
13
13
  pendingRequestData = null;
14
+ selectedColumnIndex = null;
15
+ originalSeriesOption = null;
14
16
 
15
17
  connect() {
16
18
  // Listen for the custom event 'chart:initialized' to set up the chart.
@@ -61,16 +63,20 @@ export default class extends Controller {
61
63
  } catch (e) {
62
64
  hasTarget = false;
63
65
  }
64
-
66
+
65
67
  // Get the chart element which the RailsCharts library has created
66
68
  this.chart = window.RailsCharts.charts[this.chartIdValue];
67
-
69
+
68
70
  // Only proceed if we have BOTH the DOM target and the chart object
69
71
  if (!hasTarget || !this.chart) {
70
72
  return;
71
73
  }
72
74
 
73
75
  this.visibleData = this.getVisibleData();
76
+
77
+ // Store the original series configuration BEFORE any modifications
78
+ this.storeOriginalSeriesOption();
79
+
74
80
  this.setupChartEventListeners();
75
81
  this.setupDone = true;
76
82
 
@@ -78,6 +84,10 @@ export default class extends Controller {
78
84
  if (hasTarget) {
79
85
  document.getElementById(this.chartIdValue)?.setAttribute('data-chart-rendered', 'true');
80
86
  }
87
+
88
+ // Initialize column selection from URL parameters after chart is fully ready
89
+ // This must come AFTER storing the original option to avoid storing modified state
90
+ this.initializeColumnSelectionFromUrl();
81
91
  }
82
92
 
83
93
  // Add some event listeners to the chart so we can track the zoom changes
@@ -104,6 +114,11 @@ export default class extends Controller {
104
114
  this.handleZoomChange();
105
115
  };
106
116
  document.addEventListener('mouseup', this.handleDocumentMouseUp);
117
+
118
+ // Add click event handler for bar chart columns
119
+ this.chart.on('click', (params) => {
120
+ this.handleColumnClick(params);
121
+ });
107
122
  }
108
123
 
109
124
  // This returns the visible data from the chart based on the current zoom level.
@@ -166,7 +181,7 @@ export default class extends Controller {
166
181
  const startTimestamp = data.xAxis[0];
167
182
  const endTimestamp = data.xAxis[data.xAxis.length - 1];
168
183
 
169
- // Update zoom parameters in URL
184
+ // Update zoom parameters in URL while preserving all other parameters including sort
170
185
  currentParams.set('zoom_start_time', startTimestamp);
171
186
  currentParams.set('zoom_end_time', endTimestamp);
172
187
 
@@ -177,6 +192,8 @@ export default class extends Controller {
177
192
  updatePaginationLimit() {
178
193
  // Update or set the limit param in the browser so if the user refreshes the page,
179
194
  // the limit will be preserved.
195
+ if (!this.hasPaginationLimitTarget) return;
196
+
180
197
  const url = new URL(window.location.href);
181
198
  const currentParams = new URLSearchParams(url.search);
182
199
  const limit = this.paginationLimitTarget.value;
@@ -189,15 +206,15 @@ export default class extends Controller {
189
206
  sendTurboFrameRequest(data) {
190
207
  const now = Date.now();
191
208
  const timeSinceLastRequest = now - this.lastTurboFrameRequestAt;
192
-
209
+
193
210
  // Store the latest data for potential delayed execution
194
211
  this.pendingRequestData = data;
195
-
212
+
196
213
  // Clear any existing timeout
197
214
  if (this.pendingRequestTimeout) {
198
215
  clearTimeout(this.pendingRequestTimeout);
199
216
  }
200
-
217
+
201
218
  // If enough time has passed since last request, execute immediately
202
219
  if (timeSinceLastRequest >= 1000) {
203
220
  this.executeTurboFrameRequest(data);
@@ -215,10 +232,10 @@ export default class extends Controller {
215
232
  executeTurboFrameRequest(data) {
216
233
  this.lastTurboFrameRequestAt = Date.now();
217
234
 
218
- // Start with the current page's URL
235
+ // Start with the current page's URL to preserve all existing parameters including sort
219
236
  const url = new URL(window.location.href);
220
237
 
221
- // Preserve existing URL parameters
238
+ // Preserve existing URL parameters (including sort parameters like q[s])
222
239
  const currentParams = new URLSearchParams(url.search);
223
240
 
224
241
  const startTimestamp = data.xAxis[0];
@@ -229,7 +246,9 @@ export default class extends Controller {
229
246
  currentParams.set('zoom_end_time', endTimestamp);
230
247
 
231
248
  // Set the limit param based on the value in the pagination selector
232
- url.searchParams.set('limit', this.paginationLimitTarget.value);
249
+ if (this.hasPaginationLimitTarget) {
250
+ currentParams.set('limit', this.paginationLimitTarget.value);
251
+ }
233
252
 
234
253
  // Update the URL's search parameters
235
254
  url.search = currentParams.toString();
@@ -237,8 +256,9 @@ export default class extends Controller {
237
256
  fetch(url, {
238
257
  method: 'GET',
239
258
  headers: {
240
- 'Accept': 'text/html; turbo-frame',
241
- 'Turbo-Frame': this.chartIdValue
259
+ 'Accept': 'text/vnd.turbo-stream.html, text/html',
260
+ 'Turbo-Frame': this.indexTableTarget.id,
261
+ 'X-Requested-With': 'XMLHttpRequest'
242
262
  }
243
263
  })
244
264
  .then(response => {
@@ -311,4 +331,222 @@ export default class extends Controller {
311
331
  targetFrame.innerHTML = html;
312
332
  }
313
333
  }
334
+
335
+ handleColumnClick(params) {
336
+ const clickedIndex = params.dataIndex;
337
+
338
+ // If clicking the same column that's already selected, deselect all
339
+ if (this.selectedColumnIndex === clickedIndex) {
340
+ this.resetColumnColors();
341
+ this.selectedColumnIndex = null;
342
+ this.sendColumnDeselectionRequest();
343
+ } else {
344
+ // Select the clicked column and gray out others
345
+ this.highlightColumn(clickedIndex);
346
+ this.selectedColumnIndex = clickedIndex;
347
+ this.sendColumnSelectionRequest(clickedIndex);
348
+ }
349
+ }
350
+
351
+ highlightColumn(selectedIndex) {
352
+ try {
353
+ const option = this.chart.getOption();
354
+ if (!option.series || !option.series[0] || !option.series[0].data) {
355
+ return;
356
+ }
357
+
358
+ const seriesData = option.series[0].data;
359
+
360
+ // Instead of changing data structure, modify the series itemStyle
361
+ const currentOption = this.chart.getOption();
362
+
363
+ // Get the default color from the chart theme
364
+ const defaultColor = currentOption.color?.[0] || '#5470c6'; // ECharts default blue
365
+
366
+ this.chart.setOption({
367
+ series: [{
368
+ data: seriesData, // Keep original data format for tooltips
369
+ itemStyle: {
370
+ color: (params) => {
371
+ return params.dataIndex === selectedIndex ? defaultColor : '#cccccc';
372
+ }
373
+ }
374
+ }]
375
+ });
376
+ } catch (error) {
377
+ console.error('Error highlighting column:', error);
378
+ }
379
+ }
380
+
381
+ storeOriginalSeriesOption() {
382
+ try {
383
+ const option = this.chart.getOption();
384
+ if (option.series && option.series[0]) {
385
+ // Deep clone the original series configuration to restore later
386
+ const originalSeries = JSON.parse(JSON.stringify(option.series[0]));
387
+
388
+ // Ensure we don't store any column selection modifications
389
+ // Remove any custom itemStyle that might have color functions
390
+ if (originalSeries.itemStyle && typeof originalSeries.itemStyle.color === 'function') {
391
+ delete originalSeries.itemStyle.color;
392
+ }
393
+
394
+ this.originalSeriesOption = originalSeries;
395
+ }
396
+ } catch (error) {
397
+ console.error('Error storing original series option:', error);
398
+ }
399
+ }
400
+
401
+ resetColumnColors() {
402
+ try {
403
+ if (!this.originalSeriesOption) {
404
+ console.warn('No original series option stored, cannot reset properly');
405
+ return;
406
+ }
407
+
408
+ const option = this.chart.getOption();
409
+ const seriesData = option.series[0].data;
410
+
411
+ // Restore original series configuration but keep current data
412
+ const restoredOption = {
413
+ ...this.originalSeriesOption,
414
+ data: seriesData // Keep current data to preserve tooltips
415
+ };
416
+
417
+ // Explicitly set color back to default yellow theme color
418
+ if (!restoredOption.itemStyle) {
419
+ restoredOption.itemStyle = {};
420
+ }
421
+ restoredOption.itemStyle.color = '#ffc91f'; // Default yellow from railspulse theme
422
+
423
+ this.chart.setOption({
424
+ series: [restoredOption]
425
+ }, false); // Use replace mode to ensure clean state
426
+ } catch (error) {
427
+ console.error('Error resetting column colors:', error);
428
+ }
429
+ }
430
+
431
+ sendColumnSelectionRequest(columnIndex) {
432
+ // Get the timestamp for the selected column
433
+ const option = this.chart.getOption();
434
+ const xAxisData = option.xAxis[0].data;
435
+ const selectedTimestamp = xAxisData[columnIndex];
436
+
437
+ if (!selectedTimestamp) {
438
+ console.error('Could not find timestamp for column index:', columnIndex);
439
+ return;
440
+ }
441
+
442
+ // Build the request URL with column selection parameter, preserving all existing params including sort
443
+ const url = new URL(window.location.href);
444
+ const currentParams = new URLSearchParams(url.search);
445
+
446
+ // Keep all existing parameters (including sort like q[s]) and add column selection parameter
447
+ currentParams.set('selected_column_time', selectedTimestamp);
448
+
449
+ // Preserve pagination limit
450
+ if (this.hasPaginationLimitTarget) {
451
+ currentParams.set('limit', this.paginationLimitTarget.value);
452
+ }
453
+
454
+ url.search = currentParams.toString();
455
+
456
+ // Update browser URL to persist column selection
457
+ window.history.replaceState({}, '', url);
458
+
459
+ // Send the turbo frame request
460
+ this.executeTurboFrameRequestForColumn(url);
461
+ }
462
+
463
+ sendColumnDeselectionRequest() {
464
+ // Build the request URL without column selection parameter, preserving all other params including sort
465
+ const url = new URL(window.location.href);
466
+ const currentParams = new URLSearchParams(url.search);
467
+
468
+ // Remove only the column selection parameter, keep all others (including sort like q[s])
469
+ currentParams.delete('selected_column_time');
470
+
471
+ // Preserve pagination limit
472
+ if (this.hasPaginationLimitTarget) {
473
+ currentParams.set('limit', this.paginationLimitTarget.value);
474
+ }
475
+
476
+ url.search = currentParams.toString();
477
+
478
+ // Update browser URL to remove column selection
479
+ window.history.replaceState({}, '', url);
480
+
481
+ // Send the turbo frame request to restore default/zoom view
482
+ this.executeTurboFrameRequestForColumn(url);
483
+ }
484
+
485
+ executeTurboFrameRequestForColumn(url) {
486
+ fetch(url, {
487
+ method: 'GET',
488
+ headers: {
489
+ 'Accept': 'text/vnd.turbo-stream.html, text/html',
490
+ 'Turbo-Frame': this.indexTableTarget.id,
491
+ 'X-Requested-With': 'XMLHttpRequest'
492
+ }
493
+ })
494
+ .then(response => {
495
+ return response.text();
496
+ })
497
+ .then(html => {
498
+ // Find the turbo-frame in the document using the target
499
+ const frame = this.indexTableTarget;
500
+ if (frame) {
501
+ // Parse the response HTML
502
+ const parser = new DOMParser();
503
+ const doc = parser.parseFromString(html, 'text/html');
504
+
505
+ // Find the turbo-frame in the response using the frame's ID
506
+ const responseFrame = doc.querySelector(`turbo-frame#${frame.id}`);
507
+ if (responseFrame) {
508
+ // CSP-safe content replacement using DOM methods
509
+ this.replaceFrameContent(frame, responseFrame);
510
+ } else {
511
+ // Fallback: parse the entire HTML response
512
+ this.replaceFrameContentFromHTML(frame, html);
513
+ }
514
+ }
515
+ })
516
+ .catch(error => console.error('[IndexController] Column selection fetch error:', error));
517
+ }
518
+
519
+ initializeColumnSelectionFromUrl() {
520
+ // Check if there's a selected_column_time parameter in the URL
521
+ const urlParams = new URLSearchParams(window.location.search);
522
+ const selectedColumnTime = urlParams.get('selected_column_time');
523
+
524
+ if (selectedColumnTime) {
525
+ // Find the column index that matches this timestamp
526
+ const option = this.chart.getOption();
527
+ if (!option.xAxis || !option.xAxis[0] || !option.xAxis[0].data) {
528
+ return;
529
+ }
530
+
531
+ const xAxisData = option.xAxis[0].data;
532
+
533
+ // Try exact match first
534
+ let columnIndex = xAxisData.findIndex(timestamp => timestamp.toString() === selectedColumnTime);
535
+
536
+ // If no exact match, try converting to numbers and comparing
537
+ if (columnIndex === -1) {
538
+ const selectedTimeNumber = parseInt(selectedColumnTime);
539
+ columnIndex = xAxisData.findIndex(timestamp => parseInt(timestamp) === selectedTimeNumber);
540
+ }
541
+
542
+ if (columnIndex !== -1) {
543
+ // Set the selected column index and apply visual styling
544
+ this.selectedColumnIndex = columnIndex;
545
+ // Use requestAnimationFrame to ensure ECharts is ready
546
+ requestAnimationFrame(() => {
547
+ this.highlightColumn(columnIndex);
548
+ });
549
+ }
550
+ }
551
+ }
314
552
  }
@@ -1,9 +1,33 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import { computePosition, flip, shift, offset, autoUpdate } from "@floating-ui/dom"
3
3
 
4
+ /**
5
+ * Popover Stimulus Controller
6
+ *
7
+ * Usage:
8
+ * <div data-controller="popover" data-popover-placement-value="top">
9
+ * <button data-popover-target="button" data-action="click->popover#toggle">Toggle</button>
10
+ * <div data-popover-target="menu" popover>Menu content</div>
11
+ * </div>
12
+ *
13
+ * Targets:
14
+ * - button: The element that triggers the popover
15
+ * - menu: The popover content element
16
+ *
17
+ * Values:
18
+ * - placement: Controls popover positioning (default: "top")
19
+ * Valid values: "top", "top-start", "top-end", "bottom", "bottom-start",
20
+ * "bottom-end", "left", "left-start", "left-end", "right", "right-start", "right-end"
21
+ *
22
+ * Features:
23
+ * - Auto-positioning with collision detection
24
+ * - Lazy loading of operation details via Turbo frames
25
+ * - CSP-compliant styling using CSS custom properties
26
+ */
27
+
4
28
  export default class extends Controller {
5
29
  static targets = [ "button", "menu" ]
6
- static values = { placement: { type: String, default: "bottom" } }
30
+ static values = { placement: { type: String, default: "top" } }
7
31
 
8
32
  #showTimer = null
9
33
  #hideTimer = null
@@ -64,16 +88,16 @@ export default class extends Controller {
64
88
  // Check if this popover has operation details to load
65
89
  const operationUrl = this.menuTarget.dataset.operationUrl
66
90
  if (!operationUrl) return
67
-
91
+
68
92
  // Find the turbo frame inside the popover
69
93
  const turboFrame = this.menuTarget.querySelector('turbo-frame')
70
94
  if (!turboFrame) return
71
-
95
+
72
96
  // Only load if not already loaded (check if still shows loading content)
73
97
  // Use CSP-safe method to check for loading content
74
98
  const hasLoadingContent = this.hasLoadingContent(turboFrame)
75
99
  if (!hasLoadingContent) return
76
-
100
+
77
101
  // Set the src attribute to trigger the turbo frame loading
78
102
  turboFrame.src = operationUrl
79
103
  }
@@ -0,0 +1,14 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ updateUrl(event) {
5
+ // Get the href from the clicked link
6
+ const link = event.currentTarget;
7
+ const href = link.getAttribute('href');
8
+
9
+ if (href) {
10
+ // Update the browser URL to match the sort link
11
+ window.history.replaceState({}, '', href);
12
+ }
13
+ }
14
+ }
@@ -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)
@@ -36,27 +36,27 @@ module RailsPulse
36
36
  trend_icon = percentage < 0.1 ? "move-right" : current_period_avg < previous_period_avg ? "trending-down" : "trending-up"
37
37
  trend_amount = previous_period_avg.zero? ? "0%" : "#{percentage}%"
38
38
 
39
- # Separate query for sparkline data - manually calculate weighted averages by week
40
- sparkline_data = {}
41
- base_query.each do |summary|
42
- week_start = summary.period_start.beginning_of_week
43
- formatted_date = week_start.strftime("%b %-d")
39
+ # Sparkline data by day with zero-filled days over the last 14 days
40
+ # Use Groupdate to get grouped sums and compute weighted averages per day
41
+ grouped_weighted = base_query
42
+ .group_by_day(:period_start, time_zone: "UTC")
43
+ .sum(Arel.sql("avg_duration * count"))
44
44
 
45
- if sparkline_data[formatted_date]
46
- sparkline_data[formatted_date][:total_weighted] += (summary.avg_duration || 0) * (summary.count || 0)
47
- sparkline_data[formatted_date][:total_count] += (summary.count || 0)
48
- else
49
- sparkline_data[formatted_date] = {
50
- total_weighted: (summary.avg_duration || 0) * (summary.count || 0),
51
- total_count: (summary.count || 0)
52
- }
53
- end
54
- end
45
+ grouped_counts = base_query
46
+ .group_by_day(:period_start, time_zone: "UTC")
47
+ .sum(:count)
55
48
 
56
- # Convert to final format
57
- sparkline_data = sparkline_data.transform_values do |data|
58
- weighted_avg = data[:total_count] > 0 ? (data[:total_weighted] / data[:total_count]).round(0) : 0
59
- { value: weighted_avg }
49
+ # Build a continuous 14-day range, fill missing days with 0
50
+ start_day = 2.weeks.ago.beginning_of_day.to_date
51
+ end_day = Time.current.to_date
52
+
53
+ sparkline_data = {}
54
+ (start_day..end_day).each do |day|
55
+ weighted_sum = grouped_weighted[day] || 0
56
+ count_sum = grouped_counts[day] || 0
57
+ avg = count_sum > 0 ? (weighted_sum.to_f / count_sum).round(0) : 0
58
+ label = day.strftime("%b %-d")
59
+ sparkline_data[label] = { value: avg }
60
60
  end
61
61
 
62
62
  {
@@ -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,26 +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
- # Separate query for sparkline data - group by week using Rails
37
- sparkline_data = base_query
38
- .group_by_week(:period_start, time_zone: "UTC")
39
- .sum(:count)
40
- .each_with_object({}) do |(week_start, total_count), hash|
41
- formatted_date = week_start.strftime("%b %-d")
42
- value = total_count || 0
43
- hash[formatted_date] = { value: value }
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
54
+
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 }
44
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)
66
+
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
77
+ end
78
+
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
45
82
 
46
- # Calculate average executions per minute over 2-week period
47
- total_minutes = 2.weeks / 1.minute
48
- average_executions_per_minute = total_execution_count / total_minutes
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
49
93
 
50
94
  {
51
95
  id: "execution_rate",
52
96
  context: "queries",
53
97
  title: "Execution Rate",
54
- summary: "#{average_executions_per_minute.round(2)} / min",
55
- line_chart_data: sparkline_data,
98
+ summary: summary,
99
+ chart_data: sparkline_data,
56
100
  trend_icon: trend_icon,
57
101
  trend_amount: trend_amount,
58
102
  trend_text: "Compared to last week"
@@ -33,22 +33,27 @@ module RailsPulse
33
33
  trend_icon = percentage < 0.1 ? "move-right" : current_period_p95 < previous_period_p95 ? "trending-down" : "trending-up"
34
34
  trend_amount = previous_period_p95.zero? ? "0%" : "#{percentage}%"
35
35
 
36
- # Separate query for sparkline data - group by week using Rails
37
- sparkline_data = base_query
38
- .group_by_week(:period_start, time_zone: "UTC")
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
39
  .average(:p95_duration)
40
- .each_with_object({}) do |(week_start, avg_p95), hash|
41
- formatted_date = week_start.strftime("%b %-d")
42
- value = (avg_p95 || 0).round(0)
43
- hash[formatted_date] = { value: value }
44
- end
40
+
41
+ start_day = 2.weeks.ago.beginning_of_day.to_date
42
+ end_day = Time.current.to_date
43
+
44
+ sparkline_data = {}
45
+ (start_day..end_day).each do |day|
46
+ avg = grouped_daily[day]&.round(0) || 0
47
+ label = day.strftime("%b %-d")
48
+ sparkline_data[label] = { value: avg }
49
+ end
45
50
 
46
51
  {
47
52
  id: "percentile_query_times",
48
53
  context: "queries",
49
54
  title: "95th Percentile Query Time",
50
55
  summary: "#{p95_query_time} ms",
51
- line_chart_data: sparkline_data,
56
+ chart_data: sparkline_data,
52
57
  trend_icon: trend_icon,
53
58
  trend_amount: trend_amount,
54
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)