rails_pulse 0.1.2 → 0.1.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -4
  3. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  4. data/app/assets/images/rails_pulse/request.png +0 -0
  5. data/app/assets/stylesheets/rails_pulse/application.css +28 -5
  6. data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
  7. data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
  8. data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
  9. data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
  10. data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
  11. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
  12. data/app/controllers/concerns/zoom_range_concern.rb +31 -0
  13. data/app/controllers/rails_pulse/application_controller.rb +5 -1
  14. data/app/controllers/rails_pulse/queries_controller.rb +46 -1
  15. data/app/controllers/rails_pulse/requests_controller.rb +14 -1
  16. data/app/controllers/rails_pulse/routes_controller.rb +40 -1
  17. data/app/helpers/rails_pulse/chart_helper.rb +15 -7
  18. data/app/javascript/rails_pulse/application.js +34 -3
  19. data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
  20. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
  21. data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
  22. data/app/javascript/rails_pulse/controllers/index_controller.js +241 -11
  23. data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
  24. data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
  25. data/app/models/rails_pulse/queries/cards/average_query_times.rb +19 -19
  26. data/app/models/rails_pulse/queries/cards/execution_rate.rb +13 -8
  27. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +13 -8
  28. data/app/models/rails_pulse/query.rb +46 -0
  29. data/app/models/rails_pulse/routes/cards/average_response_times.rb +17 -19
  30. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +13 -8
  31. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +13 -8
  32. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +13 -8
  33. data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
  34. data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
  35. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
  36. data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
  37. data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
  38. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +146 -0
  39. data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
  40. data/app/services/rails_pulse/query_analysis_service.rb +125 -0
  41. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
  42. data/app/views/layouts/rails_pulse/application.html.erb +0 -2
  43. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
  44. data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
  45. data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
  46. data/app/views/rails_pulse/components/_metric_card.html.erb +27 -4
  47. data/app/views/rails_pulse/components/_panel.html.erb +1 -1
  48. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
  49. data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
  50. data/app/views/rails_pulse/dashboard/index.html.erb +1 -1
  51. data/app/views/rails_pulse/operations/show.html.erb +17 -15
  52. data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
  53. data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
  54. data/app/views/rails_pulse/queries/_analysis_results.html.erb +87 -0
  55. data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
  56. data/app/views/rails_pulse/queries/_show_table.html.erb +1 -1
  57. data/app/views/rails_pulse/queries/_table.html.erb +1 -1
  58. data/app/views/rails_pulse/queries/index.html.erb +48 -51
  59. data/app/views/rails_pulse/queries/show.html.erb +56 -52
  60. data/app/views/rails_pulse/requests/_operations.html.erb +30 -43
  61. data/app/views/rails_pulse/requests/_table.html.erb +3 -1
  62. data/app/views/rails_pulse/requests/index.html.erb +48 -51
  63. data/app/views/rails_pulse/routes/_table.html.erb +1 -1
  64. data/app/views/rails_pulse/routes/index.html.erb +49 -52
  65. data/app/views/rails_pulse/routes/show.html.erb +4 -4
  66. data/config/routes.rb +5 -1
  67. data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +13 -0
  68. data/db/rails_pulse_schema.rb +9 -0
  69. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +65 -0
  70. data/lib/generators/rails_pulse/install_generator.rb +71 -18
  71. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +22 -0
  72. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
  73. data/lib/generators/rails_pulse/upgrade_generator.rb +225 -0
  74. data/lib/rails_pulse/version.rb +1 -1
  75. data/lib/tasks/rails_pulse.rake +27 -8
  76. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  77. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  78. data/public/rails-pulse-assets/rails-pulse.js +53 -53
  79. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  80. metadata +23 -5
  81. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  82. data/app/assets/images/rails_pulse/routes.png +0 -0
  83. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
@@ -0,0 +1,58 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ toggle(event) {
5
+ // Delegate clicks from tbody to the nearest row
6
+ const triggerRow = event.target.closest('tr')
7
+ if (!triggerRow || triggerRow.closest('tbody') !== this.element) return
8
+
9
+ // Ignore clicks on the details row itself
10
+ if (triggerRow.classList.contains('operation-details-row')) return
11
+
12
+ // Do not toggle when clicking the final Actions column
13
+ const clickedCell = event.target.closest('td,th')
14
+ if (clickedCell && clickedCell.parentElement === triggerRow) {
15
+ const isLastCell = clickedCell.cellIndex === (triggerRow.cells.length - 1)
16
+ if (isLastCell) return
17
+ }
18
+
19
+ event.preventDefault()
20
+ event.stopPropagation()
21
+
22
+ const detailsRow = triggerRow.nextElementSibling
23
+ if (!detailsRow || detailsRow.tagName !== 'TR' || !detailsRow.classList.contains('operation-details-row')) return
24
+
25
+ const isHidden = detailsRow.classList.contains('hidden')
26
+ if (isHidden) {
27
+ this.expand(triggerRow, detailsRow)
28
+ } else {
29
+ this.collapse(triggerRow, detailsRow)
30
+ }
31
+ }
32
+
33
+ expand(triggerRow, detailsRow) {
34
+ detailsRow.classList.remove('hidden')
35
+
36
+ // Rotate chevron to point down
37
+ const chevron = triggerRow.querySelector('.chevron')
38
+ if (chevron) chevron.style.transform = 'rotate(90deg)'
39
+
40
+ triggerRow.classList.add('expanded')
41
+
42
+ // Lazy load operation details once
43
+ const frame = detailsRow.querySelector('turbo-frame')
44
+ if (frame && !frame.getAttribute('src')) {
45
+ const url = frame.dataset.operationUrl
46
+ if (url) frame.setAttribute('src', url)
47
+ }
48
+ }
49
+
50
+ collapse(triggerRow, detailsRow) {
51
+ detailsRow.classList.add('hidden')
52
+
53
+ const chevron = triggerRow.querySelector('.chevron')
54
+ if (chevron) chevron.style.transform = 'rotate(0deg)'
55
+
56
+ triggerRow.classList.remove('expanded')
57
+ }
58
+ }
@@ -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
 
@@ -189,15 +204,15 @@ export default class extends Controller {
189
204
  sendTurboFrameRequest(data) {
190
205
  const now = Date.now();
191
206
  const timeSinceLastRequest = now - this.lastTurboFrameRequestAt;
192
-
207
+
193
208
  // Store the latest data for potential delayed execution
194
209
  this.pendingRequestData = data;
195
-
210
+
196
211
  // Clear any existing timeout
197
212
  if (this.pendingRequestTimeout) {
198
213
  clearTimeout(this.pendingRequestTimeout);
199
214
  }
200
-
215
+
201
216
  // If enough time has passed since last request, execute immediately
202
217
  if (timeSinceLastRequest >= 1000) {
203
218
  this.executeTurboFrameRequest(data);
@@ -215,10 +230,10 @@ export default class extends Controller {
215
230
  executeTurboFrameRequest(data) {
216
231
  this.lastTurboFrameRequestAt = Date.now();
217
232
 
218
- // Start with the current page's URL
233
+ // Start with the current page's URL to preserve all existing parameters including sort
219
234
  const url = new URL(window.location.href);
220
235
 
221
- // Preserve existing URL parameters
236
+ // Preserve existing URL parameters (including sort parameters like q[s])
222
237
  const currentParams = new URLSearchParams(url.search);
223
238
 
224
239
  const startTimestamp = data.xAxis[0];
@@ -229,7 +244,7 @@ export default class extends Controller {
229
244
  currentParams.set('zoom_end_time', endTimestamp);
230
245
 
231
246
  // Set the limit param based on the value in the pagination selector
232
- url.searchParams.set('limit', this.paginationLimitTarget.value);
247
+ currentParams.set('limit', this.paginationLimitTarget.value);
233
248
 
234
249
  // Update the URL's search parameters
235
250
  url.search = currentParams.toString();
@@ -237,8 +252,9 @@ export default class extends Controller {
237
252
  fetch(url, {
238
253
  method: 'GET',
239
254
  headers: {
240
- 'Accept': 'text/html; turbo-frame',
241
- 'Turbo-Frame': this.chartIdValue
255
+ 'Accept': 'text/vnd.turbo-stream.html, text/html',
256
+ 'Turbo-Frame': this.indexTableTarget.id,
257
+ 'X-Requested-With': 'XMLHttpRequest'
242
258
  }
243
259
  })
244
260
  .then(response => {
@@ -311,4 +327,218 @@ export default class extends Controller {
311
327
  targetFrame.innerHTML = html;
312
328
  }
313
329
  }
330
+
331
+ handleColumnClick(params) {
332
+ const clickedIndex = params.dataIndex;
333
+
334
+ // If clicking the same column that's already selected, deselect all
335
+ if (this.selectedColumnIndex === clickedIndex) {
336
+ this.resetColumnColors();
337
+ this.selectedColumnIndex = null;
338
+ this.sendColumnDeselectionRequest();
339
+ } else {
340
+ // Select the clicked column and gray out others
341
+ this.highlightColumn(clickedIndex);
342
+ this.selectedColumnIndex = clickedIndex;
343
+ this.sendColumnSelectionRequest(clickedIndex);
344
+ }
345
+ }
346
+
347
+ highlightColumn(selectedIndex) {
348
+ try {
349
+ const option = this.chart.getOption();
350
+ if (!option.series || !option.series[0] || !option.series[0].data) {
351
+ return;
352
+ }
353
+
354
+ const seriesData = option.series[0].data;
355
+
356
+ // Instead of changing data structure, modify the series itemStyle
357
+ const currentOption = this.chart.getOption();
358
+
359
+ // Get the default color from the chart theme
360
+ const defaultColor = currentOption.color?.[0] || '#5470c6'; // ECharts default blue
361
+
362
+ this.chart.setOption({
363
+ series: [{
364
+ data: seriesData, // Keep original data format for tooltips
365
+ itemStyle: {
366
+ color: (params) => {
367
+ return params.dataIndex === selectedIndex ? defaultColor : '#cccccc';
368
+ }
369
+ }
370
+ }]
371
+ });
372
+ } catch (error) {
373
+ console.error('Error highlighting column:', error);
374
+ }
375
+ }
376
+
377
+ storeOriginalSeriesOption() {
378
+ try {
379
+ const option = this.chart.getOption();
380
+ if (option.series && option.series[0]) {
381
+ // Deep clone the original series configuration to restore later
382
+ const originalSeries = JSON.parse(JSON.stringify(option.series[0]));
383
+
384
+ // Ensure we don't store any column selection modifications
385
+ // Remove any custom itemStyle that might have color functions
386
+ if (originalSeries.itemStyle && typeof originalSeries.itemStyle.color === 'function') {
387
+ delete originalSeries.itemStyle.color;
388
+ }
389
+
390
+ this.originalSeriesOption = originalSeries;
391
+ }
392
+ } catch (error) {
393
+ console.error('Error storing original series option:', error);
394
+ }
395
+ }
396
+
397
+ resetColumnColors() {
398
+ try {
399
+ if (!this.originalSeriesOption) {
400
+ console.warn('No original series option stored, cannot reset properly');
401
+ return;
402
+ }
403
+
404
+ const option = this.chart.getOption();
405
+ const seriesData = option.series[0].data;
406
+
407
+ // Restore original series configuration but keep current data
408
+ const restoredOption = {
409
+ ...this.originalSeriesOption,
410
+ data: seriesData // Keep current data to preserve tooltips
411
+ };
412
+
413
+ // Explicitly set color back to default yellow theme color
414
+ if (!restoredOption.itemStyle) {
415
+ restoredOption.itemStyle = {};
416
+ }
417
+ restoredOption.itemStyle.color = '#ffc91f'; // Default yellow from railspulse theme
418
+
419
+ this.chart.setOption({
420
+ series: [restoredOption]
421
+ }, false); // Use replace mode to ensure clean state
422
+ } catch (error) {
423
+ console.error('Error resetting column colors:', error);
424
+ }
425
+ }
426
+
427
+ sendColumnSelectionRequest(columnIndex) {
428
+ // Get the timestamp for the selected column
429
+ const option = this.chart.getOption();
430
+ const xAxisData = option.xAxis[0].data;
431
+ const selectedTimestamp = xAxisData[columnIndex];
432
+
433
+ if (!selectedTimestamp) {
434
+ console.error('Could not find timestamp for column index:', columnIndex);
435
+ return;
436
+ }
437
+
438
+ // Build the request URL with column selection parameter, preserving all existing params including sort
439
+ const url = new URL(window.location.href);
440
+ const currentParams = new URLSearchParams(url.search);
441
+
442
+ // Keep all existing parameters (including sort like q[s]) and add column selection parameter
443
+ currentParams.set('selected_column_time', selectedTimestamp);
444
+
445
+ // Preserve pagination limit
446
+ currentParams.set('limit', this.paginationLimitTarget.value);
447
+
448
+ url.search = currentParams.toString();
449
+
450
+ // Update browser URL to persist column selection
451
+ window.history.replaceState({}, '', url);
452
+
453
+ // Send the turbo frame request
454
+ this.executeTurboFrameRequestForColumn(url);
455
+ }
456
+
457
+ sendColumnDeselectionRequest() {
458
+ // Build the request URL without column selection parameter, preserving all other params including sort
459
+ const url = new URL(window.location.href);
460
+ const currentParams = new URLSearchParams(url.search);
461
+
462
+ // Remove only the column selection parameter, keep all others (including sort like q[s])
463
+ currentParams.delete('selected_column_time');
464
+
465
+ // Preserve pagination limit
466
+ currentParams.set('limit', this.paginationLimitTarget.value);
467
+
468
+ url.search = currentParams.toString();
469
+
470
+ // Update browser URL to remove column selection
471
+ window.history.replaceState({}, '', url);
472
+
473
+ // Send the turbo frame request to restore default/zoom view
474
+ this.executeTurboFrameRequestForColumn(url);
475
+ }
476
+
477
+ executeTurboFrameRequestForColumn(url) {
478
+ fetch(url, {
479
+ method: 'GET',
480
+ headers: {
481
+ 'Accept': 'text/vnd.turbo-stream.html, text/html',
482
+ 'Turbo-Frame': this.indexTableTarget.id,
483
+ 'X-Requested-With': 'XMLHttpRequest'
484
+ }
485
+ })
486
+ .then(response => {
487
+ return response.text();
488
+ })
489
+ .then(html => {
490
+ // Find the turbo-frame in the document using the target
491
+ const frame = this.indexTableTarget;
492
+ if (frame) {
493
+ // Parse the response HTML
494
+ const parser = new DOMParser();
495
+ const doc = parser.parseFromString(html, 'text/html');
496
+
497
+ // Find the turbo-frame in the response using the frame's ID
498
+ const responseFrame = doc.querySelector(`turbo-frame#${frame.id}`);
499
+ if (responseFrame) {
500
+ // CSP-safe content replacement using DOM methods
501
+ this.replaceFrameContent(frame, responseFrame);
502
+ } else {
503
+ // Fallback: parse the entire HTML response
504
+ this.replaceFrameContentFromHTML(frame, html);
505
+ }
506
+ }
507
+ })
508
+ .catch(error => console.error('[IndexController] Column selection fetch error:', error));
509
+ }
510
+
511
+ initializeColumnSelectionFromUrl() {
512
+ // Check if there's a selected_column_time parameter in the URL
513
+ const urlParams = new URLSearchParams(window.location.search);
514
+ const selectedColumnTime = urlParams.get('selected_column_time');
515
+
516
+ if (selectedColumnTime) {
517
+ // Find the column index that matches this timestamp
518
+ const option = this.chart.getOption();
519
+ if (!option.xAxis || !option.xAxis[0] || !option.xAxis[0].data) {
520
+ return;
521
+ }
522
+
523
+ const xAxisData = option.xAxis[0].data;
524
+
525
+ // Try exact match first
526
+ let columnIndex = xAxisData.findIndex(timestamp => timestamp.toString() === selectedColumnTime);
527
+
528
+ // If no exact match, try converting to numbers and comparing
529
+ if (columnIndex === -1) {
530
+ const selectedTimeNumber = parseInt(selectedColumnTime);
531
+ columnIndex = xAxisData.findIndex(timestamp => parseInt(timestamp) === selectedTimeNumber);
532
+ }
533
+
534
+ if (columnIndex !== -1) {
535
+ // Set the selected column index and apply visual styling
536
+ this.selectedColumnIndex = columnIndex;
537
+ // Use requestAnimationFrame to ensure ECharts is ready
538
+ requestAnimationFrame(() => {
539
+ this.highlightColumn(columnIndex);
540
+ });
541
+ }
542
+ }
543
+ }
314
544
  }
@@ -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
+ }
@@ -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
  {
@@ -33,15 +33,20 @@ module RailsPulse
33
33
  trend_icon = percentage < 0.1 ? "move-right" : current_period_count < previous_period_count ? "trending-down" : "trending-up"
34
34
  trend_amount = previous_period_count.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
  .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 }
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
+ total = grouped_daily[day] || 0
47
+ label = day.strftime("%b %-d")
48
+ sparkline_data[label] = { value: total }
49
+ end
45
50
 
46
51
  # Calculate average executions per minute over 2-week period
47
52
  total_minutes = 2.weeks / 1.minute
@@ -33,15 +33,20 @@ 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",
@@ -9,6 +9,13 @@ module RailsPulse
9
9
  # Validations
10
10
  validates :normalized_sql, presence: true, uniqueness: true
11
11
 
12
+ # JSON serialization for analysis columns
13
+ serialize :issues, type: Array, coder: JSON
14
+ serialize :metadata, type: Hash, coder: JSON
15
+ serialize :query_stats, type: Hash, coder: JSON
16
+ serialize :backtrace_analysis, type: Hash, coder: JSON
17
+ serialize :suggestions, type: Array, coder: JSON
18
+
12
19
  def self.ransackable_attributes(auth_object = nil)
13
20
  %w[id normalized_sql average_query_time_ms execution_count total_time_consumed performance_status occurred_at]
14
21
  end
@@ -52,6 +59,45 @@ module RailsPulse
52
59
  Arel.sql("MAX(rails_pulse_operations.occurred_at)")
53
60
  end
54
61
 
62
+ # Analysis helper methods
63
+ def analyzed?
64
+ analyzed_at.present?
65
+ end
66
+
67
+ def has_recent_operations?
68
+ operations.where("occurred_at > ?", 48.hours.ago).exists?
69
+ end
70
+
71
+ def needs_reanalysis?
72
+ return true unless analyzed?
73
+
74
+ # Check if there are new operations since analysis
75
+ last_operation_time = operations.maximum(:occurred_at)
76
+ return false unless last_operation_time
77
+
78
+ last_operation_time > analyzed_at
79
+ end
80
+
81
+ def analysis_status
82
+ return "not_analyzed" unless analyzed?
83
+ return "needs_update" if needs_reanalysis?
84
+ "current"
85
+ end
86
+
87
+ def issues_by_severity
88
+ return {} unless analyzed? && issues.present?
89
+
90
+ issues.group_by { |issue| issue["severity"] || "unknown" }
91
+ end
92
+
93
+ def critical_issues_count
94
+ issues_by_severity["critical"]&.count || 0
95
+ end
96
+
97
+ def warning_issues_count
98
+ issues_by_severity["warning"]&.count || 0
99
+ end
100
+
55
101
  def to_s
56
102
  id
57
103
  end
@@ -36,27 +36,25 @@ 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
+ grouped_weighted = base_query
41
+ .group_by_day(:period_start, time_zone: "UTC")
42
+ .sum(Arel.sql("avg_duration * count"))
44
43
 
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
44
+ grouped_counts = base_query
45
+ .group_by_day(:period_start, time_zone: "UTC")
46
+ .sum(:count)
55
47
 
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 }
48
+ start_day = 2.weeks.ago.beginning_of_day.to_date
49
+ end_day = Time.current.to_date
50
+
51
+ sparkline_data = {}
52
+ (start_day..end_day).each do |day|
53
+ weighted_sum = grouped_weighted[day] || 0
54
+ count_sum = grouped_counts[day] || 0
55
+ avg = count_sum > 0 ? (weighted_sum.to_f / count_sum).round(0) : 0
56
+ label = day.strftime("%b %-d")
57
+ sparkline_data[label] = { value: avg }
60
58
  end
61
59
 
62
60
  {